diff --git a/F-Droid/AndroidManifest.xml b/F-Droid/AndroidManifest.xml index 63c9900de..be3883ec7 100644 --- a/F-Droid/AndroidManifest.xml +++ b/F-Droid/AndroidManifest.xml @@ -396,6 +396,17 @@ android:name="android.support.PARENT_ACTIVITY" android:value=".FDroid" /> + + + + android:paddingTop="2dip"> + android:paddingTop="2dip"> + + + + \ No newline at end of file diff --git a/F-Droid/src/org/fdroid/fdroid/FDroid.java b/F-Droid/src/org/fdroid/fdroid/FDroid.java index a532dbbc2..a04e3d4ba 100644 --- a/F-Droid/src/org/fdroid/fdroid/FDroid.java +++ b/F-Droid/src/org/fdroid/fdroid/FDroid.java @@ -51,6 +51,7 @@ import org.fdroid.fdroid.views.AppListFragmentPagerAdapter; import org.fdroid.fdroid.views.ManageReposActivity; import org.fdroid.fdroid.views.swap.ConnectSwapActivity; import org.fdroid.fdroid.views.swap.SwapActivity; +import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; public class FDroid extends ActionBarActivity { @@ -257,7 +258,7 @@ public class FDroid extends ActionBarActivity { return true; case R.id.action_swap: - startActivity(new Intent(this, SwapActivity.class)); + startActivity(new Intent(this, SwapWorkflowActivity.class)); return true; case R.id.action_search: diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java new file mode 100644 index 000000000..33f41643a --- /dev/null +++ b/F-Droid/src/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java @@ -0,0 +1,185 @@ +package org.fdroid.fdroid.views.swap; + +import android.app.ProgressDialog; +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.v4.app.FragmentActivity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.localrepo.LocalRepoManager; + +import java.util.Set; + +public class SwapWorkflowActivity extends FragmentActivity { + + private ViewGroup container; + + private enum State { + INTRO, SELECT_APPS, JOIN_WIFI + } + + public interface InnerView { + /** @return True if the menu should be shown. */ + boolean buildMenu(Menu menu, @NonNull MenuInflater inflater); + } + + private State currentState = State.INTRO; + private InnerView currentView; + private boolean hasPreparedLocalRepo = false; + private UpdateAsyncTask updateSwappableAppsTask = null; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.swap_activity); + container = (ViewGroup) findViewById(R.id.fragment_container); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + boolean parent = super.onPrepareOptionsMenu(menu); + boolean inner = currentView.buildMenu(menu, getMenuInflater()); + return parent || inner; + } + + @Override + protected void onResume() { + super.onResume(); + showView(); + } + + private void showView() { + if (currentState == State.INTRO) { + showIntro(); + } + } + + private void inflateInnerView(@LayoutRes int viewRes) { + container.removeAllViews(); + View view = ((LayoutInflater)getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(viewRes, container, false); + currentView = (InnerView)view; + container.addView(view); + supportInvalidateOptionsMenu(); + } + + private void showIntro() { + inflateInnerView(R.layout.swap_blank); + } + + public void showSelectApps() { + inflateInnerView(R.layout.swap_select_apps); + } + + // TODO: Pass in the selected apps, then we can figure out whether they have changed. + public void onAppsSelected(Set selectedApps) { + if (updateSwappableAppsTask == null && !hasPreparedLocalRepo) { + updateSwappableAppsTask = new UpdateAsyncTask(this, selectedApps); + updateSwappableAppsTask.execute(); + } else { + showJoinWifi(); + } + } + + /** + * Once the UpdateAsyncTask has finished preparing our repository index, we can + * show the next screen to the user. + */ + private void onLocalRepoPrepared() { + updateSwappableAppsTask = null; + hasPreparedLocalRepo = true; + showJoinWifi(); + } + + private void showJoinWifi() { + inflateInnerView(R.layout.swap_join_wifi); + } + + class UpdateAsyncTask extends AsyncTask { + + @SuppressWarnings("UnusedDeclaration") + private static final String TAG = "UpdateAsyncTask"; + + @NonNull + private final ProgressDialog progressDialog; + + @NonNull + private final Set selectedApps; + + @NonNull + private final Uri sharingUri; + + @NonNull + private final Context context; + + public UpdateAsyncTask(Context c, @NonNull Set apps) { + context = SwapWorkflowActivity.this.getApplicationContext(); + selectedApps = apps; + progressDialog = new ProgressDialog(c); + progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); + progressDialog.setTitle(R.string.updating); + sharingUri = Utils.getSharingUri(FDroidApp.repo); + } + + @Override + protected void onPreExecute() { + progressDialog.show(); + } + + @Override + protected Void doInBackground(Void... params) { + try { + final LocalRepoManager lrm = LocalRepoManager.get(context); + publishProgress(getString(R.string.deleting_repo)); + lrm.deleteRepo(); + for (String app : selectedApps) { + publishProgress(String.format(getString(R.string.adding_apks_format), app)); + lrm.addApp(context, app); + } + lrm.writeIndexPage(sharingUri.toString()); + publishProgress(getString(R.string.writing_index_jar)); + lrm.writeIndexJar(); + publishProgress(getString(R.string.linking_apks)); + lrm.copyApksToRepo(); + publishProgress(getString(R.string.copying_icons)); + // run the icon copy without progress, its not a blocker + new AsyncTask() { + + @Override + protected Void doInBackground(Void... params) { + lrm.copyIconsToRepo(); + return null; + } + }.execute(); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + @Override + protected void onProgressUpdate(String... progress) { + super.onProgressUpdate(progress); + progressDialog.setMessage(progress[0]); + } + + @Override + protected void onPostExecute(Void result) { + progressDialog.dismiss(); + Toast.makeText(context, R.string.updated_local_repo, Toast.LENGTH_SHORT).show(); + onLocalRepoPrepared(); + } + } + +} diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/views/SelectAppsView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/views/SelectAppsView.java new file mode 100644 index 000000000..7a17788da --- /dev/null +++ b/F-Droid/src/org/fdroid/fdroid/views/swap/views/SelectAppsView.java @@ -0,0 +1,302 @@ +package org.fdroid.fdroid.views.swap.views; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.view.MenuItemCompat; +import android.support.v4.widget.CursorAdapter; +import android.support.v7.widget.SearchView; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.InstalledAppProvider; +import org.fdroid.fdroid.localrepo.LocalRepoManager; +import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; + +import java.util.HashSet; + +public class SelectAppsView extends ListView implements + SwapWorkflowActivity.InnerView, + LoaderManager.LoaderCallbacks, + SearchView.OnQueryTextListener { + + public SelectAppsView(Context context) { + super(context); + } + + public SelectAppsView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SelectAppsView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public SelectAppsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + private SwapWorkflowActivity getActivity() { + return (SwapWorkflowActivity)getContext(); + } + + private static final int LOADER_INSTALLED_APPS = 253341534; + + private AppListAdapter adapter; + private String mCurrentFilterString; + private final Presenter presenter = new Presenter(); + + public static class Presenter { + + private SelectAppsView view; + + public void setView(@NonNull SelectAppsView view) { + this.view = view; + } + + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + adapter = new AppListAdapter(this, getContext(), + getContext().getContentResolver().query(InstalledAppProvider.getContentUri(), InstalledAppProvider.DataColumns.ALL, null, null, null)); + setAdapter(adapter); + addHeaderView(inflate(getContext(), R.layout.swap_create_header, null), null, false); + setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + + // either reconnect with an existing loader or start a new one + getActivity().getSupportLoaderManager().initLoader(LOADER_INSTALLED_APPS, null, this); + + // build list of existing apps from what is on the file system + if (FDroidApp.selectedApps == null) { + FDroidApp.selectedApps = new HashSet<>(); + for (String filename : LocalRepoManager.get(getActivity()).repoDir.list()) { + if (filename.matches(".*\\.apk")) { + String packageName = filename.substring(0, filename.indexOf("_")); + FDroidApp.selectedApps.add(packageName); + } + } + } + + setOnItemClickListener(new AdapterView.OnItemClickListener() { + public void onItemClick(AdapterView parent, View v, int position, long id) { + if (position > 0) { + // Ignore the headerView at position 0. + toggleAppSelected(position); + } + } + }); + + presenter.setView(this); + } + + @Override + public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) { + + inflater.inflate(R.menu.swap_next_search, menu); + MenuItem nextMenuItem = menu.findItem(R.id.action_next); + int flags = MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT; + MenuItemCompat.setShowAsAction(nextMenuItem, flags); + nextMenuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + getActivity().onAppsSelected(FDroidApp.selectedApps); + return true; + } + }); + + SearchView searchView = new SearchView(getActivity()); + + MenuItem searchMenuItem = menu.findItem(R.id.action_search); + MenuItemCompat.setActionView(searchMenuItem, searchView); + MenuItemCompat.setShowAsAction(searchMenuItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); + + searchView.setOnQueryTextListener(this); + return true; + } + + private void toggleAppSelected(int position) { + Cursor c = (Cursor) adapter.getItem(position - 1); + String packageName = c.getString(c.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID)); + if (FDroidApp.selectedApps.contains(packageName)) { + FDroidApp.selectedApps.remove(packageName); + } else { + FDroidApp.selectedApps.add(packageName); + } + } + + @Override + public CursorLoader onCreateLoader(int id, Bundle args) { + Uri uri; + if (TextUtils.isEmpty(mCurrentFilterString)) { + uri = InstalledAppProvider.getContentUri(); + } else { + uri = InstalledAppProvider.getSearchUri(mCurrentFilterString); + } + return new CursorLoader( + getActivity(), + uri, + InstalledAppProvider.DataColumns.ALL, + null, + null, + InstalledAppProvider.DataColumns.APPLICATION_LABEL); + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + adapter.swapCursor(cursor); + + String fdroid = loader.getContext().getPackageName(); + for (int i = 0; i < getCount() - 1; i++) { + Cursor c = ((Cursor) getItemAtPosition(i + 1)); + String packageName = c.getString(c.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID)); + if (TextUtils.equals(packageName, fdroid)) { + setItemChecked(i + 1, true); // always include FDroid + } else { + for (String selected : FDroidApp.selectedApps) { + if (TextUtils.equals(packageName, selected)) { + setItemChecked(i + 1, true); + } + } + } + } + } + + @Override + public void onLoaderReset(Loader loader) { + adapter.swapCursor(null); + } + + @Override + public boolean onQueryTextChange(String newText) { + String newFilter = !TextUtils.isEmpty(newText) ? newText : null; + if (mCurrentFilterString == null && newFilter == null) { + return true; + } + if (mCurrentFilterString != null && mCurrentFilterString.equals(newFilter)) { + return true; + } + mCurrentFilterString = newFilter; + getActivity().getSupportLoaderManager().restartLoader(LOADER_INSTALLED_APPS, null, this); + return true; + } + + @Override + public boolean onQueryTextSubmit(String query) { + // this is not needed since we respond to every change in text + return true; + } + + private class AppListAdapter extends CursorAdapter { + + @SuppressWarnings("UnusedDeclaration") + private static final String TAG = "AppListAdapter"; + + @Nullable + private LayoutInflater inflater; + + @Nullable + private Drawable defaultAppIcon; + + @NonNull + private final ListView listView; + + public AppListAdapter(@NonNull ListView listView, @NonNull Context context, @Nullable Cursor c) { + super(context, c, FLAG_REGISTER_CONTENT_OBSERVER); + this.listView = listView; + } + + @NonNull + private LayoutInflater getInflater(Context context) { + if (inflater == null) { + Context themedContext = new ContextThemeWrapper(context, R.style.SwapTheme_AppList_ListItem); + inflater = (LayoutInflater)themedContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + return inflater; + } + + private Drawable getDefaultAppIcon(Context context) { + if (defaultAppIcon == null) { + defaultAppIcon = context.getResources().getDrawable(android.R.drawable.sym_def_app_icon); + } + return defaultAppIcon; + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + View view = getInflater(context).inflate(R.layout.select_local_apps_list_item, parent, false); + bindView(view, context, cursor); + return view; + } + + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + + TextView packageView = (TextView)view.findViewById(R.id.package_name); + TextView labelView = (TextView)view.findViewById(R.id.application_label); + ImageView iconView = (ImageView)view.findViewById(android.R.id.icon); + + String packageName = cursor.getString(cursor.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID)); + String appLabel = cursor.getString(cursor.getColumnIndex(InstalledAppProvider.DataColumns.APPLICATION_LABEL)); + + Drawable icon; + try { + icon = context.getPackageManager().getApplicationIcon(packageName); + } catch (PackageManager.NameNotFoundException e) { + icon = getDefaultAppIcon(context); + } + + packageView.setText(packageName); + labelView.setText(appLabel); + iconView.setImageDrawable(icon); + + // Since v11, the Android SDK provided the ability to show selected list items + // by highlighting their background. Prior to this, we need to handle this ourselves + // by adding a checkbox which can toggle selected items. + View checkBoxView = view.findViewById(R.id.checkbox); + if (checkBoxView != null) { + CheckBox checkBox = (CheckBox)checkBoxView; + checkBox.setOnCheckedChangeListener(null); + + final int listPosition = cursor.getPosition() + 1; // To account for the header view. + + checkBox.setChecked(listView.isItemChecked(listPosition)); + checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + listView.setItemChecked(listPosition, isChecked); + toggleAppSelected(listPosition); + } + }); + } + } + } + +} diff --git a/F-Droid/src/org/fdroid/fdroid/views/swap/views/StartSwapView.java b/F-Droid/src/org/fdroid/fdroid/views/swap/views/StartSwapView.java index 97a5e7095..72560f92d 100644 --- a/F-Droid/src/org/fdroid/fdroid/views/swap/views/StartSwapView.java +++ b/F-Droid/src/org/fdroid/fdroid/views/swap/views/StartSwapView.java @@ -3,18 +3,18 @@ package org.fdroid.fdroid.views.swap.views; import android.annotation.TargetApi; import android.content.Context; import android.os.Build; +import android.support.annotation.NonNull; import android.util.AttributeSet; import android.view.ContextThemeWrapper; +import android.view.Menu; +import android.view.MenuInflater; import android.view.View; import android.widget.LinearLayout; -import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.R; -import org.fdroid.fdroid.views.swap.SwapActivity; +import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; -import java.util.Set; - -public class StartSwapView extends LinearLayout { +public class StartSwapView extends LinearLayout implements SwapWorkflowActivity.InnerView { public StartSwapView(Context context) { super(context); @@ -36,9 +36,9 @@ public class StartSwapView extends LinearLayout { - private SwapActivity getActivity() { + private SwapWorkflowActivity getActivity() { // TODO: Try and find a better way to get to the SwapActivity, which makes less asumptions. - return (SwapActivity)((ContextThemeWrapper)getContext()).getBaseContext(); + return (SwapWorkflowActivity)getContext(); } @Override @@ -54,4 +54,8 @@ public class StartSwapView extends LinearLayout { } + @Override + public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) { + return false; + } }