diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6dfd02709..7888e3515 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: fdroid/ci:client-20161223 +image: registry.gitlab.com/fdroid/ci-images:client cache: paths: diff --git a/app/build.gradle b/app/build.gradle index 094e1aea2..536765ef6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,14 +17,21 @@ repositories { jcenter() } +ext { + supportLibVersion = '25.0.1' +} + dependencies { - compile 'com.android.support:support-v4:24.2.1' - compile 'com.android.support:appcompat-v7:24.2.1' - compile 'com.android.support:gridlayout-v7:24.2.1' - compile 'com.android.support:support-annotations:24.2.1' - compile 'com.android.support:design:24.2.1' - compile 'com.android.support:cardview-v7:24.2.1' - compile "com.android.support:recyclerview-v7:24.2.1" + compile "com.android.support:support-v4:${supportLibVersion}" + compile "com.android.support:appcompat-v7:${supportLibVersion}" + compile "com.android.support:gridlayout-v7:${supportLibVersion}" + compile "com.android.support:support-annotations:${supportLibVersion}" + compile "com.android.support:recyclerview-v7:${supportLibVersion}" + compile "com.android.support:cardview-v7:${supportLibVersion}" + compile "com.android.support:design:${supportLibVersion}" + compile "com.android.support:support-vector-drawable:${supportLibVersion}" + compile 'com.android.support.constraint:constraint-layout:1.0.1' + compile "com.android.support:palette-v7:${supportLibVersion}" compile 'com.nostra13.universalimageloader:universal-image-loader:1.9.5' compile 'com.google.zxing:core:3.2.1' @@ -50,7 +57,7 @@ dependencies { testCompile "org.mockito:mockito-core:1.10.19" - androidTestCompile 'com.android.support:support-annotations:24.2.1' + androidTestCompile "com.android.support:support-annotations:${supportLibVersion}" androidTestCompile 'com.android.support.test:runner:0.5' androidTestCompile 'com.android.support.test:rules:0.5' } @@ -94,20 +101,24 @@ if (!hasProperty('sourceDeps')) { 'ch.acra:acra:d2762968c448757a7d6acc9f141881d9632f664988e9723ece33b5f7c79f3bc9', 'commons-io:commons-io:a10418348d234968600ccb1d988efcbbd08716e1d96936ccc1880e7d22513474', 'commons-net:commons-net:c25b0da668b3c5649f002d504def22d1b4cb30d206f05428d2fe168fa1a901c2', - 'com.android.support:animated-vector-drawable:5aa30f578e1daefb26bef0ce06414266fbb4cdf5d4259f42a92c7bd83dcd81b4', - 'com.android.support:appcompat-v7:ead7ac8011fb40676df8adc2856cae934edab55fc4444654c0ac6ea443736088', - 'com.android.support:support-annotations:1e4d471c5378b283d95abfb128e7ed3c6b3cb19bb6f0c317a9b75e48e99365ff', - 'com.android.support:support-compat:8e4fe0078b68073e8f5bcb52aa5b6407fd456d47c51aa0f8e8d1e23c69da06c1', - 'com.android.support:support-core-ui:ecc9184b7f438980e1c4a08b089d62dbc53ff90091f442d91fec27322a02c73c', - 'com.android.support:support-core-utils:0fbc508e41dd6e8c634f310ee88452aaf8f48b6a843a369b115130b80d2fc05f', - 'com.android.support:support-fragment:d8030f0bf0f64214a29dc4e14d5ccd225e59f66ed15eb37f3a5022e773dd1fda', - 'com.android.support:support-media-compat:fa29a23eadd685631584b2c0c624a36e3bb79a33e257b00304501ad682fa2be3', - 'com.android.support:support-v4:cac2956f5c4bb363cc0ba824ac16ea2a687d1c305d434416a34772a5f9375ed7', - 'com.android.support:support-vector-drawable:6ee37a7f7b93c1df1294e6f6f97df3724ac989fcda0549faf677001085330548', - 'com.android.support:design:89842bb1243507fe3079066ea4ea58795effe69cdf9a819e05274d21760adfc2', - 'com.android.support:cardview-v7:2303b351686d1db060b5fcf1a9c709c79b4a54a85bfda0fb3c4849e244606ee1', - 'com.android.support:gridlayout-v7:1a31c248d69faa815cc155883ddcb0ccc7ba8e14e69ec58dd18d8017e23d76f5', - 'com.android.support:recyclerview-v7:9077766a1a0f4e89528fbf9dcdf6d5880a8686f0266fa852d58d803beeef18fa', + 'com.android.support.constraint:constraint-layout-solver:d03a406eb505dfa673b0087bf17e16d5a4d6bf8afdf452ee175e346207948cdf', + 'com.android.support.constraint:constraint-layout:df1add69d11063eebba521818d63537b22207376b65f30cc35feea172b84e300', + 'com.android.support:animated-vector-drawable:70443a2857f9968c4e2c27c107657ce2291d774f8a50f03444e12ab637451175', + 'com.android.support:appcompat-v7:7fead560a22ea4b15848ce3000f312ef611fac0953bf90ca8710a72a1f6e36ea', + 'com.android.support:cardview-v7:50d88fae8cd1076cb90504d36ca5ee9df4018555c8f041bd28f43274c0fc9e1f', + 'com.android.support:design:07a72eb68c888b38d7b78e450e1af8a84e571406e0cf911889e0645d5a41f1e4', + 'com.android.support:gridlayout-v7:cc11d2a3ee484e078c358a51d23a37e4bfbc542de410cacf275eafc5624bb888', + 'com.android.support:palette-v7:89700afeedd988b471f0ce528ba916f368f549b47889b86b84d68eee42ea487c', + 'com.android.support:recyclerview-v7:803baba7be537ace8c5cb8a775e37547c22a04c4b028833796c45c26ec1deca2', + 'com.android.support:support-annotations:bd94ab42c841db16fb480f4c65d33d297e544655ecc498b37c5cf33a0c5f1968', + 'com.android.support:support-compat:d04f15aa5f2ae9e8cb7d025bf02dfd4fd6f6800628ceb107e0589634c9e4e537', + 'com.android.support:support-core-ui:29205ac978a1839d92be3d32db2385dac10f8688bba649e51650023c76de2f00', + 'com.android.support:support-core-utils:632c3750bd991da8b591f24a8916e74ca6063ae7f525f005c96981725c9bf491', + 'com.android.support:support-fragment:da47261a1d7c3d33e6e911335a7f4ce01135923bb221d3ab84625d005fa1969f', + 'com.android.support:support-media-compat:01cac57af687bed9a6cb4ce803bebd1b7e6b8469c14f1f9ac6b4596637ff73d6', + 'com.android.support:support-v4:50da261acc4ca3d2dea9a43106bf65488711ca97b20a4daa095dba381c205c98', + 'com.android.support:support-vector-drawable:071ae3695bf8427d3cbfc8791492a3d9c804a4b111aa2a72fbfe7790ea268e5d', + 'com.android.support:transition:9fd1e6d27cb70b3c5cd19f842b48bbb05cb4e5c93a22372769c342523393e8ea', 'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259', 'com.madgag.spongycastle:core:9b6b7ac856b91bcda2ede694eccd26cefb0bf0b09b89f13cda05b5da5ff68c6b', 'com.madgag.spongycastle:pkix:6aba9b2210907a3d46dd3dcac782bb3424185290468d102d5207ebdc9796a905', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c0ed4e35..3d280e78e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,12 +137,6 @@ android:configChanges="layoutDirection|locale|keyboardHidden|orientation|screenSize" > - - - - - - @@ -407,7 +401,6 @@ android:label="@string/app_details" android:exported="true" android:parentActivityName=".FDroid" - android:theme="@style/AppThemeLight.NoActionBar" android:configChanges="layoutDirection|locale" > + + + + + + + + + + + diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java index 96686b35a..8c8c980ca 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java @@ -16,7 +16,6 @@ import android.support.design.widget.CoordinatorLayout; import android.support.v4.content.LocalBroadcastManager; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; -import android.support.v7.app.AppCompatDelegate; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.Toolbar; @@ -47,9 +46,6 @@ import org.fdroid.fdroid.views.AppDetailsRecyclerViewAdapter; import org.fdroid.fdroid.views.ShareChooserDialog; public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog.ShareChooserDialogListener, AppDetailsRecyclerViewAdapter.AppDetailsRecyclerViewAdapterCallbacks { - static { - AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); - } private static final String TAG = "AppDetails2"; diff --git a/app/src/main/java/org/fdroid/fdroid/FDroid.java b/app/src/main/java/org/fdroid/fdroid/FDroid.java index 519424ba6..f8a6a30ff 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroid.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroid.java @@ -242,7 +242,7 @@ public class FDroid extends AppCompatActivity implements SearchView.OnQueryTextL if (!TextUtils.isEmpty(packageName)) { Utils.debugLog(TAG, "FDroid launched via app link for '" + packageName + "'"); - Intent intentToInvoke = new Intent(this, AppDetails.class); + Intent intentToInvoke = new Intent(this, AppDetails2.class); intentToInvoke.putExtra(AppDetails.EXTRA_APPID, packageName); startActivity(intentToInvoke); finish(); diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index 58b3ce6d4..95336f431 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -410,7 +410,7 @@ public class UpdateService extends IntentService { // now that downloading the index is done, start downloading updates if (changes && fdroidPrefs.isAutoDownloadEnabled()) { - autoDownloadUpdates(); + autoDownloadUpdates(this); } } @@ -469,8 +469,8 @@ public class UpdateService extends IntentService { } } - private void autoDownloadUpdates() { - Cursor cursor = getContentResolver().query( + public static void autoDownloadUpdates(Context context) { + Cursor cursor = context.getContentResolver().query( AppProvider.getCanUpdateUri(), Schema.AppMetadataTable.Cols.ALL, null, null, null); @@ -478,8 +478,8 @@ public class UpdateService extends IntentService { cursor.moveToFirst(); for (int i = 0; i < cursor.getCount(); i++) { App app = new App(cursor); - Apk apk = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode); - InstallManagerService.queue(this, app, apk); + Apk apk = ApkProvider.Helper.findApkFromAnyRepo(context, app.packageName, app.suggestedVersionCode); + InstallManagerService.queue(context, app, apk); cursor.moveToNext(); } cursor.close(); diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java index 08a39be78..b26768c86 100644 --- a/app/src/main/java/org/fdroid/fdroid/Utils.java +++ b/app/src/main/java/org/fdroid/fdroid/Utils.java @@ -29,7 +29,11 @@ import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; import android.text.Editable; import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.text.style.TypefaceSpan; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; @@ -487,6 +491,23 @@ public final class Utils { return formatDateFormat(TIME_FORMAT, date, fallback); } + /** + * Formats the app name using "sans-serif" and then appends the summary after a space with + * "sans-serif-light". Doesn't mandate any font sizes or any other styles, that is up to the + * {@link android.widget.TextView} which it ends up being displayed in. + */ + public static CharSequence formatAppNameAndSummary(String appName, String summary) { + String toFormat = appName + ' ' + summary; + CharacterStyle normal = new TypefaceSpan("sans-serif"); + CharacterStyle light = new TypefaceSpan("sans-serif-light"); + + SpannableStringBuilder sb = new SpannableStringBuilder(toFormat); + sb.setSpan(normal, 0, appName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + sb.setSpan(light, appName.length(), toFormat.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return sb; + } + // Need this to add the unimplemented support for ordered and unordered // lists to Html.fromHtml(). public static class HtmlTagHandler implements Html.TagHandler { diff --git a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java index 5ba551ecb..349f71fa0 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java @@ -199,16 +199,26 @@ public class AppProvider extends FDroidProvider { public AppQuerySelection add(AppQuerySelection query) { QuerySelection both = super.add(query); AppQuerySelection bothWithJoin = new AppQuerySelection(both.getSelection(), both.getArgs()); - if (this.naturalJoinToInstalled() || query.naturalJoinToInstalled()) { - bothWithJoin.requireNaturalInstalledTable(); - } - - if (this.leftJoinToPrefs() || query.leftJoinToPrefs()) { - bothWithJoin.requireLeftJoinPrefs(); - } + ensureJoinsCopied(query, bothWithJoin); return bothWithJoin; } + public AppQuerySelection not(AppQuerySelection query) { + QuerySelection both = super.not(query); + AppQuerySelection bothWithJoin = new AppQuerySelection(both.getSelection(), both.getArgs()); + ensureJoinsCopied(query, bothWithJoin); + return bothWithJoin; + } + + private void ensureJoinsCopied(AppQuerySelection toAdd, AppQuerySelection newlyCreated) { + if (this.naturalJoinToInstalled() || toAdd.naturalJoinToInstalled()) { + newlyCreated.requireNaturalInstalledTable(); + } + + if (this.leftJoinToPrefs() || toAdd.leftJoinToPrefs()) { + newlyCreated.requireLeftJoinPrefs(); + } + } } protected class Query extends QueryBuilder { @@ -373,7 +383,6 @@ public class AppProvider extends FDroidProvider { protected static final String PATH_APPS = "apps"; protected static final String PATH_SPECIFIC_APP = "app"; private static final String PATH_RECENTLY_UPDATED = "recentlyUpdated"; - private static final String PATH_NEWLY_ADDED = "newlyAdded"; private static final String PATH_CATEGORY = "category"; private static final String PATH_REPO = "repo"; private static final String PATH_HIGHEST_PRIORITY = "highestPriority"; @@ -386,8 +395,7 @@ public class AppProvider extends FDroidProvider { private static final int SEARCH_TEXT = INSTALLED + 1; private static final int SEARCH_TEXT_AND_CATEGORIES = SEARCH_TEXT + 1; private static final int RECENTLY_UPDATED = SEARCH_TEXT_AND_CATEGORIES + 1; - private static final int NEWLY_ADDED = RECENTLY_UPDATED + 1; - private static final int CATEGORY = NEWLY_ADDED + 1; + private static final int CATEGORY = RECENTLY_UPDATED + 1; private static final int CALC_SUGGESTED_APKS = CATEGORY + 1; private static final int REPO = CALC_SUGGESTED_APKS + 1; private static final int SEARCH_REPO = REPO + 1; @@ -401,7 +409,6 @@ public class AppProvider extends FDroidProvider { MATCHER.addURI(getAuthority(), null, CODE_LIST); MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS, CALC_SUGGESTED_APKS); MATCHER.addURI(getAuthority(), PATH_RECENTLY_UPDATED, RECENTLY_UPDATED); - MATCHER.addURI(getAuthority(), PATH_NEWLY_ADDED, NEWLY_ADDED); MATCHER.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY); MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*/*", SEARCH_TEXT_AND_CATEGORIES); MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH_TEXT); @@ -425,10 +432,6 @@ public class AppProvider extends FDroidProvider { return Uri.withAppendedPath(getContentUri(), PATH_RECENTLY_UPDATED); } - public static Uri getNewlyAddedUri() { - return Uri.withAppendedPath(getContentUri(), PATH_NEWLY_ADDED); - } - private static Uri calcSuggestedApksUri() { return Uri.withAppendedPath(getContentUri(), PATH_CALC_SUGGESTED_APKS); } @@ -571,7 +574,8 @@ public class AppProvider extends FDroidProvider { final String ignoreAll = "COALESCE(prefs." + AppPrefsTable.Cols.IGNORE_ALL_UPDATES + ", 0) != 1"; final String ignore = " (" + ignoreCurrent + " AND " + ignoreAll + ") "; - final String where = ignore + " AND " + app + "." + Cols.SUGGESTED_VERSION_CODE + " > installed." + InstalledAppTable.Cols.VERSION_CODE; + final String nullChecks = app + "." + Cols.SUGGESTED_VERSION_CODE + " IS NOT NULL AND installed." + InstalledAppTable.Cols.VERSION_CODE + " IS NOT NULL "; + final String where = nullChecks + " AND " + ignore + " AND " + app + "." + Cols.SUGGESTED_VERSION_CODE + " > installed." + InstalledAppTable.Cols.VERSION_CODE; return new AppQuerySelection(where).requireNaturalInstalledTable().requireLeftJoinPrefs(); } @@ -583,7 +587,7 @@ public class AppProvider extends FDroidProvider { } private AppQuerySelection queryInstalled() { - return new AppQuerySelection().requireNaturalInstalledTable(); + return new AppQuerySelection().requireNaturalInstalledTable().not(queryCanUpdate()); } private AppQuerySelection querySearch(String query) { @@ -666,12 +670,6 @@ public class AppProvider extends FDroidProvider { return new AppQuerySelection(selection); } - private AppQuerySelection queryNewlyAdded() { - final String selection = getTableName() + "." + Cols.ADDED + " > ?"; - final String[] args = {Utils.formatDate(Preferences.get().calcMaxHistory(), "")}; - return new AppQuerySelection(selection, args); - } - /** * Ensures that for each app metadata row with the same package name, only the one from the repo * with the best priority is represented in the result set. While possible to calculate this @@ -689,9 +687,7 @@ public class AppProvider extends FDroidProvider { } private AppQuerySelection queryRecentlyUpdated() { - final String app = getTableName(); - final String lastUpdated = app + "." + Cols.LAST_UPDATED; - final String selection = app + "." + Cols.ADDED + " != " + lastUpdated + " AND " + lastUpdated + " > ?"; + final String selection = getTableName() + "." + Cols.LAST_UPDATED + " > ? "; final String[] args = {Utils.formatDate(Preferences.get().calcMaxHistory(), "")}; return new AppQuerySelection(selection, args); } @@ -810,12 +806,6 @@ public class AppProvider extends FDroidProvider { includeSwap = false; break; - case NEWLY_ADDED: - sortOrder = getTableName() + "." + Cols.ADDED + " DESC"; - selection = selection.add(queryNewlyAdded()); - includeSwap = false; - break; - case HIGHEST_PRIORITY: selection = selection.add(queryPackageName(uri.getLastPathSegment())); includeSwap = false; diff --git a/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java b/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java index 5c32395a7..d5461799f 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java +++ b/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java @@ -78,4 +78,8 @@ public class QuerySelection { return new QuerySelection(s, a); } + public QuerySelection not(QuerySelection querySelection) { + String where = " NOT (" + querySelection.getSelection() + ") "; + return add(where, querySelection.getArgs()); + } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java index 5c2b99f66..13407e616 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java @@ -29,17 +29,15 @@ import android.net.Uri; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; import android.os.AsyncTask; -import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; -import android.support.v4.app.FragmentManager; -import android.support.v4.app.ListFragment; import android.support.v4.app.LoaderManager; import android.support.v4.app.NavUtils; import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; -import android.support.v7.app.ActionBarActivity; import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; @@ -47,9 +45,9 @@ import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.AdapterView; import android.widget.Button; import android.widget.EditText; -import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; @@ -74,7 +72,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.Locale; -public class ManageReposActivity extends ActionBarActivity { +public class ManageReposActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks, RepoAdapter.EnabledListener { private static final String TAG = "ManageReposActivity"; private static final String DEFAULT_NEW_REPO_TEXT = "https://"; @@ -85,7 +83,7 @@ public class ManageReposActivity extends ActionBarActivity { IS_SWAP } - private RepoListFragment listFragment; + private Toolbar toolbar; /** * True if activity started with an intent such as from QR code. False if @@ -99,31 +97,23 @@ public class ManageReposActivity extends ActionBarActivity { ((FDroidApp) getApplication()).applyTheme(this); super.onCreate(savedInstanceState); - FragmentManager fm = getSupportFragmentManager(); - if (fm.findFragmentById(android.R.id.content) == null) { - /* - * Need to set a dummy view (which will get overridden by the - * fragment manager below) so that we can call setContentView(). - * This is a work around for a (bug?) thing in 3.0, 3.1 which - * requires setContentView to be invoked before the actionbar is - * played with: - * http://blog.perpetumdesign.com/2011/08/strange-case-of - * -dr-action-and-mr-bar.html - */ - if (Build.VERSION.SDK_INT >= 11 && Build.VERSION.SDK_INT <= 13) { - setContentView(new LinearLayout(this)); - } - - listFragment = new RepoListFragment(); - - fm.beginTransaction() - .add(android.R.id.content, listFragment) - .commit(); - } + setContentView(R.layout.repo_list_activity); + toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); - // title is "Repositories" here, but "F-Droid" in VIEW Intent chooser - getSupportActionBar().setTitle(R.string.menu_manage); + + final ListView repoList = (ListView) findViewById(R.id.list); + repoAdapter = RepoAdapter.create(this, null, CursorAdapterCompat.FLAG_AUTO_REQUERY); + repoAdapter.setEnabledListener(this); + repoList.setAdapter(repoAdapter); + repoList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Repo repo = new Repo((Cursor) repoList.getItemAtPosition(position)); + editRepo(repo); + } + }); } @Override @@ -133,6 +123,9 @@ public class ManageReposActivity extends ActionBarActivity { /* let's see if someone is trying to send us a new repo */ addRepoFromIntent(getIntent()); + + // Starts a new or restarts an existing Loader in this manager + getSupportLoaderManager().restartLoader(0, null, this); } @Override @@ -149,7 +142,7 @@ public class ManageReposActivity extends ActionBarActivity { @Override public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.manage_repos, menu); + toolbar.inflateMenu(R.menu.manage_repos); return super.onCreateOptionsMenu(menu); } @@ -649,7 +642,7 @@ public class ManageReposActivity extends ActionBarActivity { values.put(RepoTable.Cols.IN_USE, 1); values.put(RepoTable.Cols.FINGERPRINT, fingerprint); RepoProvider.Helper.update(context, repo, values); - listFragment.notifyDataSetChanged(); + notifyDataSetChanged(); finishedAddingRepo(); } @@ -703,112 +696,78 @@ public class ManageReposActivity extends ActionBarActivity { } } - public static class RepoListFragment extends ListFragment - implements LoaderManager.LoaderCallbacks, RepoAdapter.EnabledListener { + private RepoAdapter repoAdapter; - private RepoAdapter repoAdapter; + @Override + public Loader onCreateLoader(int i, Bundle bundle) { + Uri uri = RepoProvider.allExceptSwapUri(); + final String[] projection = { + RepoTable.Cols._ID, + RepoTable.Cols.NAME, + RepoTable.Cols.SIGNING_CERT, + RepoTable.Cols.FINGERPRINT, + RepoTable.Cols.IN_USE, + }; + return new CursorLoader(this, uri, projection, null, null, null); + } - @Override - public Loader onCreateLoader(int i, Bundle bundle) { - Uri uri = RepoProvider.allExceptSwapUri(); - Utils.debugLog(TAG, "Creating repo loader '" + uri + "'."); - final String[] projection = { - RepoTable.Cols._ID, - RepoTable.Cols.NAME, - RepoTable.Cols.SIGNING_CERT, - RepoTable.Cols.FINGERPRINT, - RepoTable.Cols.IN_USE, - }; - return new CursorLoader(getActivity(), uri, projection, null, null, null); - } + @Override + public void onLoadFinished(Loader cursorLoader, Cursor cursor) { + repoAdapter.swapCursor(cursor); + } - @Override - public void onLoadFinished(Loader cursorLoader, Cursor cursor) { - repoAdapter.swapCursor(cursor); - } + @Override + public void onLoaderReset(Loader cursorLoader) { + repoAdapter.swapCursor(null); + } - @Override - public void onLoaderReset(Loader cursorLoader) { - repoAdapter.swapCursor(null); - } + /** + * NOTE: If somebody toggles a repo off then on again, it will have + * removed all apps from the index when it was toggled off, so when it + * is toggled on again, then it will require a updateViews. Previously, I + * toyed with the idea of remembering whether they had toggled on or + * off, and then only actually performing the function when the activity + * stopped, but I think that will be problematic. What about when they + * press the home button, or edit a repos details? It will start to + * become somewhat-random as to when the actual enabling, disabling is + * performed. So now, it just does the disable as soon as the user + * clicks "Off" and then removes the apps. To compensate for the removal + * of apps from index, it notifies the user via a toast that the apps + * have been removed. Also, as before, it will still prompt the user to + * update the repos if you toggled on on. + */ + @Override + public void onSetEnabled(Repo repo, boolean isEnabled) { + if (repo.inuse != isEnabled) { + ContentValues values = new ContentValues(1); + values.put(RepoTable.Cols.IN_USE, isEnabled ? 1 : 0); + RepoProvider.Helper.update(this, repo, values); - /** - * NOTE: If somebody toggles a repo off then on again, it will have - * removed all apps from the index when it was toggled off, so when it - * is toggled on again, then it will require a updateViews. Previously, I - * toyed with the idea of remembering whether they had toggled on or - * off, and then only actually performing the function when the activity - * stopped, but I think that will be problematic. What about when they - * press the home button, or edit a repos details? It will start to - * become somewhat-random as to when the actual enabling, disabling is - * performed. So now, it just does the disable as soon as the user - * clicks "Off" and then removes the apps. To compensate for the removal - * of apps from index, it notifies the user via a toast that the apps - * have been removed. Also, as before, it will still prompt the user to - * update the repos if you toggled on on. - */ - @Override - public void onSetEnabled(Repo repo, boolean isEnabled) { - if (repo.inuse != isEnabled) { - ContentValues values = new ContentValues(1); - values.put(RepoTable.Cols.IN_USE, isEnabled ? 1 : 0); - RepoProvider.Helper.update(getActivity(), repo, values); - - if (isEnabled) { - UpdateService.updateNow(getActivity()); - } else { - RepoProvider.Helper.purgeApps(getActivity(), repo); - String notification = getString(R.string.repo_disabled_notification, repo.name); - Toast.makeText(getActivity(), notification, Toast.LENGTH_LONG).show(); - } + if (isEnabled) { + UpdateService.updateNow(this); + } else { + RepoProvider.Helper.purgeApps(this, repo); + String notification = getString(R.string.repo_disabled_notification, repo.name); + Toast.makeText(this, notification, Toast.LENGTH_LONG).show(); } } + } - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public static final int SHOW_REPO_DETAILS = 1; - setRetainInstance(true); - setHasOptionsMenu(true); + public void editRepo(Repo repo) { + Intent intent = new Intent(this, RepoDetailsActivity.class); + intent.putExtra(RepoDetailsActivity.ARG_REPO_ID, repo.getId()); + startActivityForResult(intent, SHOW_REPO_DETAILS); + } - repoAdapter = RepoAdapter.create(getActivity(), null, CursorAdapterCompat.FLAG_AUTO_REQUERY); - repoAdapter.setEnabledListener(this); - setListAdapter(repoAdapter); - } - - @Override - public void onResume() { - super.onResume(); - - // Starts a new or restarts an existing Loader in this manager - getLoaderManager().restartLoader(0, null, this); - } - - @Override - public void onListItemClick(ListView l, View v, int position, long id) { - - super.onListItemClick(l, v, position, id); - - Repo repo = new Repo((Cursor) getListView().getItemAtPosition(position)); - editRepo(repo); - } - - public static final int SHOW_REPO_DETAILS = 1; - - public void editRepo(Repo repo) { - Intent intent = new Intent(getActivity(), RepoDetailsActivity.class); - intent.putExtra(RepoDetailsActivity.ARG_REPO_ID, repo.getId()); - startActivityForResult(intent, SHOW_REPO_DETAILS); - } - - /** - * This is necessary because even though the list will listen to content changes - * in the RepoProvider, it doesn't update the list items if they are changed (but not - * added or removed. The example which made this necessary was enabling an existing - * repo, and wanting the switch to be changed to on). - */ - private void notifyDataSetChanged() { - getLoaderManager().restartLoader(0, null, this); - } + /** + * This is necessary because even though the list will listen to content changes + * in the RepoProvider, it doesn't update the list items if they are changed (but not + * added or removed. The example which made this necessary was enabling an existing + * repo, and wanting the switch to be changed to on). + */ + private void notifyDataSetChanged() { + getSupportLoaderManager().restartLoader(0, null, this); } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java index 2ff31d5fd..dcc435adb 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java @@ -17,6 +17,7 @@ import android.support.v4.app.NavUtils; import android.support.v4.content.LocalBroadcastManager; import android.support.v7.app.ActionBarActivity; import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; import android.text.TextUtils; import android.text.format.DateUtils; import android.view.Menu; @@ -93,8 +94,12 @@ public class RepoDetailsActivity extends ActionBarActivity { ((FDroidApp) getApplication()).applyTheme(this); super.onCreate(savedInstanceState); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); setContentView(R.layout.repodetails); + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + repoView = findViewById(R.id.repoView); repoId = getIntent().getLongExtra(ARG_REPO_ID, 0); diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java new file mode 100644 index 000000000..4b68022f7 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java @@ -0,0 +1,123 @@ +package org.fdroid.fdroid.views.apps; + +import android.content.Intent; +import android.database.Cursor; +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.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.EditText; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.Schema; + +public class AppListActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks, CategoryTextWatcher.SearchTermsChangedListener { + + public static final String EXTRA_CATEGORY = "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_CATEGORY"; + private RecyclerView appView; + private AppListAdapter appAdapter; + private String category; + private String searchTerms; + private EditText searchInput; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_app_list); + + searchInput = (EditText) findViewById(R.id.search); + searchInput.addTextChangedListener(new CategoryTextWatcher(this, searchInput, this)); + + View backButton = findViewById(R.id.back); + backButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + finish(); + } + }); + + View clearButton = findViewById(R.id.clear); + clearButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchInput.setText(""); + } + }); + + appAdapter = new AppListAdapter(this); + + appView = (RecyclerView) findViewById(R.id.app_list); + appView.setHasFixedSize(true); + appView.setLayoutManager(new LinearLayoutManager(this)); + appView.setAdapter(appAdapter); + } + + @Override + protected void onResume() { + super.onResume(); + + Intent intent = getIntent(); + category = intent.hasExtra(EXTRA_CATEGORY) ? intent.getStringExtra(EXTRA_CATEGORY) : null; + + searchInput.setText(getSearchText(category, null)); + searchInput.setSelection(searchInput.getText().length()); + + if (category != null) { + // Do this so that the search input does not get focus by default. This allows for a user + // experience where the user scrolls through the apps in the category. + appView.requestFocus(); + } + + getSupportLoaderManager().initLoader(0, null, this); + } + + private CharSequence getSearchText(@Nullable String category, @Nullable String searchTerms) { + StringBuilder string = new StringBuilder(); + if (category != null) { + string.append(category).append(":"); + } + + if (searchTerms != null) { + string.append(searchTerms); + } + + return string.toString(); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new CursorLoader( + this, + AppProvider.getSearchUri(searchTerms, category), + Schema.AppMetadataTable.Cols.ALL, + null, + null, + null + ); + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + appAdapter.setAppCursor(cursor); + } + + @Override + public void onLoaderReset(Loader loader) { + appAdapter.setAppCursor(null); + } + + @Override + public void onSearchTermsChanged(@Nullable String category, @NonNull String searchTerms) { + this.category = category; + this.searchTerms = searchTerms; + getSupportLoaderManager().restartLoader(0, null, this); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java new file mode 100644 index 000000000..126202ced --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java @@ -0,0 +1,54 @@ +package org.fdroid.fdroid.views.apps; + +import android.app.Activity; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; + +class AppListAdapter extends RecyclerView.Adapter { + + private Cursor cursor; + private final Activity activity; + private final AppListItemDivider divider; + + AppListAdapter(Activity activity) { + this.activity = activity; + divider = new AppListItemDivider(activity); + } + + public void setAppCursor(Cursor cursor) { + this.cursor = cursor; + notifyDataSetChanged(); + } + + @Override + public AppListItemController onCreateViewHolder(ViewGroup parent, int viewType) { + return new AppListItemController(activity, activity.getLayoutInflater().inflate(R.layout.app_list_item, parent, false)); + } + + @Override + public void onBindViewHolder(AppListItemController holder, int position) { + cursor.moveToPosition(position); + holder.bindModel(new App(cursor)); + } + + @Override + public int getItemCount() { + return cursor == null ? 0 : cursor.getCount(); + } + + @Override + public void onAttachedToRecyclerView(RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + recyclerView.addItemDecoration(divider); + } + + @Override + public void onDetachedFromRecyclerView(RecyclerView recyclerView) { + recyclerView.removeItemDecoration(divider); + super.onDetachedFromRecyclerView(recyclerView); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java new file mode 100644 index 000000000..3f2692349 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java @@ -0,0 +1,149 @@ +package org.fdroid.fdroid.views.apps; + +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.ActivityOptionsCompat; +import android.support.v4.util.Pair; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.fdroid.fdroid.AppDetails; +import org.fdroid.fdroid.AppDetails2; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.ApkProvider; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.installer.InstallManagerService; + +public class AppListItemController extends RecyclerView.ViewHolder { + + private final Activity activity; + + private final Button installButton; + private final ImageView icon; + private final TextView name; + private final TextView status; + private final DisplayImageOptions displayImageOptions; + + private App currentApp; + + public AppListItemController(Activity activity, View itemView) { + super(itemView); + this.activity = activity; + + installButton = (Button) itemView.findViewById(R.id.install); + installButton.setOnClickListener(onInstallClicked); + + icon = (ImageView) itemView.findViewById(R.id.icon); + name = (TextView) itemView.findViewById(R.id.app_name); + status = (TextView) itemView.findViewById(R.id.status); + + displayImageOptions = Utils.getImageLoadingOptions().build(); + + itemView.setOnClickListener(onAppClicked); + } + + public void bindModel(@NonNull App app) { + currentApp = app; + name.setText(Utils.formatAppNameAndSummary(app.name, app.summary)); + + ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions); + + configureStatusText(app); + configureInstallButton(app); + } + + /** + * Sets the text/visibility of the {@link R.id#status} {@link TextView} based on whether the app: + * * Is compatible with the users device + * * Is installed + * * Can be updated + * + * TODO: This button also needs to be repurposed to support the "Downloaded but not installed" state. + */ + private void configureStatusText(@NonNull App app) { + if (status == null) { + return; + } + + if (!app.compatible) { + status.setText(activity.getString(R.string.app_incompatible)); + status.setVisibility(View.VISIBLE); + } else if (app.isInstalled()) { + if (app.canAndWantToUpdate(activity)) { + String upgradeFromTo = activity.getString(R.string.app_version_x_available, app.getSuggestedVersionName()); + status.setText(upgradeFromTo); + } else { + String installed = activity.getString(R.string.app_version_x_installed, app.installedVersionName); + status.setText(installed); + } + + status.setVisibility(View.VISIBLE); + } else { + status.setVisibility(View.INVISIBLE); + } + + } + + /** + * The install button is shown when an app: + * * Is compatible with the users device. + * * Has not been filtered due to anti-features/root/etc. + * * Is either not installed or installed but can be updated. + * + * TODO: This button also needs to be repurposed to support the "Downloaded but not installed" state. + */ + private void configureInstallButton(@NonNull App app) { + if (installButton == null) { + return; + } + + boolean installable = app.canAndWantToUpdate(activity) || !app.isInstalled(); + boolean shouldAllow = app.compatible && !app.isFiltered(); + + if (shouldAllow && installable) { + installButton.setVisibility(View.VISIBLE); + } else { + installButton.setVisibility(View.GONE); + } + } + + private final View.OnClickListener onAppClicked = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentApp == null) { + return; + } + + Intent intent = new Intent(activity, AppDetails2.class); + intent.putExtra(AppDetails.EXTRA_APPID, currentApp.packageName); + if (Build.VERSION.SDK_INT >= 21) { + Pair iconTransitionPair = Pair.create((View) icon, activity.getString(R.string.transition_app_item_icon)); + Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, iconTransitionPair).toBundle(); + activity.startActivity(intent, bundle); + } else { + activity.startActivity(intent); + } + } + }; + + private final View.OnClickListener onInstallClicked = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentApp == null) { + return; + } + + InstallManagerService.queue(activity, currentApp, ApkProvider.Helper.findApkFromAnyRepo(activity, currentApp.packageName, currentApp.suggestedVersionCode)); + } + }; +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemDivider.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemDivider.java new file mode 100644 index 000000000..565eaed10 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemDivider.java @@ -0,0 +1,33 @@ +package org.fdroid.fdroid.views.apps; + +import android.content.Context; +import android.graphics.Rect; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.DividerItemDecoration; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; + +/** + * Draws a faint line between items, to be used with the {@link AppListItemDivider}. + */ +public class AppListItemDivider extends DividerItemDecoration { + private final int itemSpacing; + + public AppListItemDivider(Context context) { + super(context, DividerItemDecoration.VERTICAL); + setDrawable(ContextCompat.getDrawable(context, R.drawable.app_list_item_divider)); + itemSpacing = Utils.dpToPx(8, context); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + super.getItemOffsets(outRect, view, parent, state); + int position = parent.getChildAdapterPosition(view); + if (position > 0) { + outRect.bottom = itemSpacing; + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java b/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java new file mode 100644 index 000000000..bbfbfe9a7 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java @@ -0,0 +1,133 @@ +package org.fdroid.fdroid.views.apps; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.text.style.ReplacementSpan; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.views.categories.CategoryController; + +/** + * This draws a category "chip" in the search text view according to the material design specs + * (https://material.google.com/components/chips.html#chips-specs). These contain a circle with an + * icon representing "category" on the left, and the name of the category on the right. It also has + * a background with curved corners behind the category text. + */ +public class CategorySpan extends ReplacementSpan { + + private static final int HEIGHT = 32; + private static final int CORNER_RADIUS = 16; + private static final int ICON_BACKGROUND_SIZE = 32; + private static final int ICON_SIZE = 16; + private static final int ICON_PADDING = (ICON_BACKGROUND_SIZE - ICON_SIZE) / 2; + private static final int TEXT_LEADING_PADDING = 8; + private static final int TEXT_TRAILING_PADDING = 12; + private static final int TEXT_BELOW_PADDING = 4; + private static final int WHITE_SPACE_PADDING_AT_END = 4; + private static final float DROP_SHADOW_HEIGHT = 1.5f; + + private final Context context; + + public CategorySpan(Context context) { + super(); + this.context = context; + } + + @Nullable + private static CharSequence getCategoryName(@Nullable CharSequence text, int start, int end) { + if (text == null) { + return null; + } + + if (start + 1 >= end - 1) { + // This can happen when the spell checker is trying to underline text within our category + // name. It sometimes will ask for sub-lengths of this span. + return null; + } + + return text.subSequence(start, end - 1); + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { + CharSequence categoryName = getCategoryName(text, start, end); + if (categoryName == null) { + return 0; + } + + float density = context.getResources().getDisplayMetrics().density; + + int iconBackgroundSize = (int) (ICON_BACKGROUND_SIZE * density); + int textLeadingPadding = (int) (TEXT_LEADING_PADDING * density); + int textWidth = (int) paint.measureText(categoryName.toString()); + int textTrailingPadding = (int) (TEXT_TRAILING_PADDING * density); + int whiteSpacePadding = (int) (WHITE_SPACE_PADDING_AT_END * density); + + return iconBackgroundSize + textLeadingPadding + textWidth + textTrailingPadding + whiteSpacePadding; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + CharSequence categoryName = getCategoryName(text, start, end); + if (categoryName == null) { + return; + } + + float density = context.getResources().getDisplayMetrics().density; + + int height = (int) (HEIGHT * density); + int iconBackgroundSize = (int) (ICON_BACKGROUND_SIZE * density); + int cornerRadius = (int) (CORNER_RADIUS * density); + int iconSize = (int) (ICON_SIZE * density); + int iconPadding = (int) (ICON_PADDING * density); + int textWidth = (int) paint.measureText(categoryName.toString()); + int textLeadingPadding = (int) (TEXT_LEADING_PADDING * density); + int textTrailingPadding = (int) (TEXT_TRAILING_PADDING * density); + + canvas.save(); + canvas.translate(x, bottom - height + TEXT_BELOW_PADDING * density); + + RectF backgroundRect = new RectF(0, 0, iconBackgroundSize + textLeadingPadding + textWidth + textTrailingPadding, height); + + // The shadow below the entire category chip. + canvas.save(); + canvas.translate(0, DROP_SHADOW_HEIGHT * density); + Paint shadowPaint = new Paint(); + shadowPaint.setColor(0x66000000); + shadowPaint.setAntiAlias(true); + canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, shadowPaint); + canvas.restore(); + + // The background which goes behind the text. + Paint backgroundPaint = new Paint(); + backgroundPaint.setColor(CategoryController.getBackgroundColour(categoryName.toString())); + backgroundPaint.setAntiAlias(true); + canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint); + + // The background behind the category icon. + Paint iconBackgroundPaint = new Paint(); + iconBackgroundPaint.setColor(0xffd8d8d8); + iconBackgroundPaint.setAntiAlias(true); + RectF iconBackgroundRect = new RectF(0, 0, iconBackgroundSize, height); + canvas.drawRoundRect(iconBackgroundRect, cornerRadius, cornerRadius, iconBackgroundPaint); + + // Category icon on top of the circular background which was just drawn. + Drawable icon = ContextCompat.getDrawable(context, R.drawable.ic_category); + icon.setBounds(iconPadding, iconPadding, iconPadding + iconSize, iconPadding + iconSize); + icon.draw(canvas); + + // The category name drawn to the right of the category name. + Paint textPaint = new Paint(paint); + textPaint.setColor(Color.WHITE); + canvas.drawText(categoryName.toString(), iconBackgroundSize + textLeadingPadding, bottom, textPaint); + + canvas.restore(); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java b/app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java new file mode 100644 index 000000000..daad4a208 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java @@ -0,0 +1,157 @@ +package org.fdroid.fdroid.views.apps; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; +import android.text.style.TtsSpan; +import android.widget.EditText; + +import org.fdroid.fdroid.R; + +/** + * The search input treats text before the first colon as a category name. Text after this colon + * (or all text if there is no colon) is the free text search terms. + * The behaviour of this search input is: + * * Replacing anything before the first colon with a {@link CategorySpan} that renders a "Chip" + * including an icon representing "category" and the name of the category. + * * Removing the trailing ":" from a category chip will cause it to remove the entire category + * from the input. + */ +public class CategoryTextWatcher implements TextWatcher { + + interface SearchTermsChangedListener { + void onSearchTermsChanged(@Nullable String category, @NonNull String searchTerms); + } + + private final Context context; + private final EditText widget; + private final SearchTermsChangedListener listener; + + private int removeTo = -1; + private boolean requiresSpanRecalculation = false; + + public CategoryTextWatcher(final Context context, final EditText widget, final SearchTermsChangedListener listener) { + this.context = context; + this.widget = widget; + this.listener = listener; + } + + /** + * If the user removed the first colon in the search text, then request for the entire + * block of text representing the category text to be removed when able. + */ + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + removeTo = -1; + + boolean removingOrReplacing = count > 0; + + // Don't bother working out if we need to recalculate spans if we are removing text + // right to the start. This could be if we are removing everything (in which case + // there is no text to span), or we are removing somewhere from after the category + // back to the start (in which case we've removed the category anyway and don't need + // to explicilty request it to be removed. + if (start == 0 && removingOrReplacing) { + return; + } + + String string = s.toString(); + boolean removingColon = removingOrReplacing && string.indexOf(':', start) < (start + count); + boolean removingFirstColon = removingColon && string.indexOf(':') >= start; + if (removingFirstColon) { + removeTo = start + count - 1; + } + } + + /** + * If the user added a colon, and there was not previously a colon before the newly added + * one, then request for a {@link CategorySpan} to be added when able. + */ + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + boolean addingOrReplacing = count > 0; + boolean addingColon = addingOrReplacing && s.subSequence(start, start + count).toString().indexOf(':') >= 0; + boolean addingFirstColon = addingColon && s.subSequence(0, start).toString().indexOf(':') == -1; + if (addingFirstColon) { + requiresSpanRecalculation = true; + } + } + + /** + * If it was decided that we were removing a category, then ensure that the relevant + * characters are removed. If it was deemed we were adding a new category, then ensure + * that the relevant {@link CategorySpan} is added to {@param searchText}. + */ + @Override + public void afterTextChanged(Editable searchText) { + if (removeTo >= 0) { + removeLeadingCharacters(searchText, removeTo); + removeTo = -1; + } else if (requiresSpanRecalculation) { + prepareSpans(searchText); + requiresSpanRecalculation = false; + } + + int colonIndex = searchText.toString().indexOf(':'); + String category = colonIndex == -1 ? null : searchText.subSequence(0, colonIndex).toString(); + String searchTerms = searchText.subSequence(colonIndex == -1 ? 0 : colonIndex + 1, searchText.length()).toString(); + listener.onSearchTermsChanged(category, searchTerms); + } + + /** + * Removes all characters from {@param searchText} up until {@param end}. + * Will do so without triggering a further set of callbacks on this {@link TextWatcher}, + * though if any other {@link TextWatcher}s have been added, they will be notified. + */ + private void removeLeadingCharacters(Editable searchText, int end) { + widget.removeTextChangedListener(this); + searchText.replace(0, end, ""); + widget.addTextChangedListener(this); + } + + /** + * Ensures that a {@link CategorySpan} is in {@param textToSpannify} if required. + * Will firstly remove all existing category spans, and then add back one if neccesary. + * In addition, also adds a {@link TtsSpan} to indicate to screen readers that the category + * span has semantic meaning representing a category. + */ + @TargetApi(21) + private void prepareSpans(Editable textToSpannify) { + if (textToSpannify == null) { + return; + } + + removeSpans(textToSpannify, CategorySpan.class); + if (Build.VERSION.SDK_INT >= 21) { + removeSpans(textToSpannify, TtsSpan.class); + } + + int colonIndex = textToSpannify.toString().indexOf(':'); + if (colonIndex > 0) { + CategorySpan span = new CategorySpan(context); + textToSpannify.setSpan(span, 0, colonIndex + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + if (Build.VERSION.SDK_INT >= 21) { + // For accessibility reasons, make this more clear to screen readers that the + // span we just added semantically represents a category. + TtsSpan ttsSpan = new TtsSpan.TextBuilder(context.getString(R.string.category)).build(); + textToSpannify.setSpan(ttsSpan, 0, 0, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + + /** + * Helper function to remove all spans of a certain type from an {@link Editable}. + */ + private void removeSpans(Editable text, Class clazz) { + T[] spans = text.getSpans(0, text.length(), clazz); + for (T span : spans) { + text.removeSpan(span); + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java new file mode 100644 index 000000000..b625ec1bd --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java @@ -0,0 +1,180 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.IdRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.ActivityOptionsCompat; +import android.support.v4.util.Pair; +import android.support.v7.graphics.Palette; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; + +import org.fdroid.fdroid.AppDetails; +import org.fdroid.fdroid.AppDetails2; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.App; + +import java.util.Date; + +/** + * The {@link AppCardController} can bind an app to several different layouts, as long as the layout + * contains the following elements: + * + {@link R.id#icon} ({@link ImageView}, required) + * + {@link R.id#summary} ({@link TextView}, required) + * + {@link R.id#featured_image} ({@link ImageView}, optional) + * + {@link R.id#status} ({@link TextView}, optional) + */ +public class AppCardController extends RecyclerView.ViewHolder implements ImageLoadingListener, View.OnClickListener { + @NonNull + private final ImageView icon; + + @NonNull + private final TextView summary; + + @Nullable + private final TextView status; + + @Nullable + private final ImageView featuredImage; + + @Nullable + private App currentApp; + + private final Activity activity; + private final int defaultFeaturedImageColour; + private final DisplayImageOptions displayImageOptions; + + private final Date recentCuttoffDate; + + public AppCardController(Activity activity, View itemView) { + super(itemView); + + this.activity = activity; + + recentCuttoffDate = Preferences.get().calcMaxHistory(); + + icon = (ImageView) findViewAndEnsureNonNull(itemView, R.id.icon); + summary = (TextView) findViewAndEnsureNonNull(itemView, R.id.summary); + + featuredImage = (ImageView) itemView.findViewById(R.id.featured_image); + status = (TextView) itemView.findViewById(R.id.status); + + defaultFeaturedImageColour = activity.getResources().getColor(R.color.cardview_light_background); + displayImageOptions = Utils.getImageLoadingOptions().build(); + + itemView.setOnClickListener(this); + } + + /** + * The contract that this controller has is that it will consume any layout resource, given + * it has some specific view types (with specific IDs) available. This helper function will + * throw an {@link IllegalArgumentException} if the view doesn't exist, + */ + @NonNull + private View findViewAndEnsureNonNull(View view, @IdRes int res) { + View found = view.findViewById(res); + if (found == null) { + String resName = activity.getResources().getResourceName(res); + throw new IllegalArgumentException("Layout for AppCardController requires " + resName); + } + + return found; + } + + public void bindApp(@NonNull App app) { + currentApp = app; + + summary.setText(Utils.formatAppNameAndSummary(app.name, app.summary)); + + if (status != null) { + if (app.added != null && app.added.after(recentCuttoffDate) && (app.lastUpdated == null || app.added.equals(app.lastUpdated))) { + status.setText(activity.getString(R.string.category_Whats_New)); + status.setVisibility(View.VISIBLE); + } else if (app.lastUpdated != null && app.lastUpdated.after(recentCuttoffDate)) { + status.setText(activity.getString(R.string.category_Recently_Updated)); + status.setVisibility(View.VISIBLE); + } else { + status.setVisibility(View.GONE); + } + } + + if (featuredImage != null) { + featuredImage.setBackgroundColor(defaultFeaturedImageColour); + } + + ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions, this); + } + + /** + * When the user clicks/touches an app card, we launch the {@link AppDetails2} activity in response. + */ + @Override + public void onClick(View v) { + if (currentApp == null) { + return; + } + + Intent intent = new Intent(activity, AppDetails2.class); + intent.putExtra(AppDetails.EXTRA_APPID, currentApp.packageName); + if (Build.VERSION.SDK_INT >= 21) { + Pair iconTransitionPair = Pair.create((View) icon, activity.getString(R.string.transition_app_item_icon)); + + @SuppressWarnings("unchecked") // We are passing the right type as the second varargs argument (i.e. a Pair). + Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, iconTransitionPair).toBundle(); + + activity.startActivity(intent, bundle); + } else { + activity.startActivity(intent); + } + } + + // ============================================================================================= + // Icon loader callbacks + // + // Most are unused, the main goal is to specify a background colour for the featured image if + // no featured image is specified in the apps metadata. + // ============================================================================================= + + @Override + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + final ImageView image = featuredImage; + if (image != null) { + new Palette.Builder(loadedImage).generate(new Palette.PaletteAsyncListener() { + @Override + public void onGenerated(Palette palette) { + image.setBackgroundColor(palette.getDominantColor(defaultFeaturedImageColour)); + } + }); + } + } + + @Override + public void onLoadingStarted(String imageUri, View view) { + // Do nothing + } + + @Override + public void onLoadingCancelled(String imageUri, View view) { + // Do nothing + } + + @Override + public void onLoadingFailed(String imageUri, View view, FailReason failReason) { + // Do nothing + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppPreviewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppPreviewAdapter.java new file mode 100644 index 000000000..a37e80117 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppPreviewAdapter.java @@ -0,0 +1,40 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; + +class AppPreviewAdapter extends RecyclerView.Adapter { + + private Cursor cursor; + private final Activity activity; + + AppPreviewAdapter(Activity activity) { + this.activity = activity; + } + + @Override + public AppCardController onCreateViewHolder(ViewGroup parent, int viewType) { + return new AppCardController(activity, activity.getLayoutInflater().inflate(R.layout.app_card_normal, parent, false)); + } + + @Override + public void onBindViewHolder(AppCardController holder, int position) { + cursor.moveToPosition(position); + holder.bindApp(new App(cursor)); + } + + @Override + public int getItemCount() { + return cursor == null ? 0 : cursor.getCount(); + } + + public void setAppCursor(Cursor cursor) { + this.cursor = cursor; + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryAdapter.java new file mode 100644 index 000000000..cf7f4b017 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryAdapter.java @@ -0,0 +1,44 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.database.Cursor; +import android.support.v4.app.LoaderManager; +import android.support.v7.widget.RecyclerView; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.Schema; + +public class CategoryAdapter extends RecyclerView.Adapter { + + private Cursor cursor; + private final Activity activity; + private final LoaderManager loaderManager; + + public CategoryAdapter(Activity activity, LoaderManager loaderManager) { + this.activity = activity; + this.loaderManager = loaderManager; + } + + @Override + public CategoryController onCreateViewHolder(ViewGroup parent, int viewType) { + return new CategoryController(activity, loaderManager, activity.getLayoutInflater().inflate(R.layout.category_item, parent, false)); + } + + @Override + public void onBindViewHolder(CategoryController holder, int position) { + cursor.moveToPosition(position); + holder.bindModel(cursor.getString(cursor.getColumnIndex(Schema.CategoryTable.Cols.NAME))); + } + + @Override + public int getItemCount() { + return cursor == null ? 0 : cursor.getCount(); + } + + public void setCategoriesCursor(Cursor cursor) { + this.cursor = cursor; + notifyDataSetChanged(); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java new file mode 100644 index 000000000..402239f0c --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java @@ -0,0 +1,177 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.content.Intent; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Rect; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.TextView; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.views.apps.AppListActivity; + +import java.util.Random; + +public class CategoryController extends RecyclerView.ViewHolder implements LoaderManager.LoaderCallbacks { + private final Button viewAll; + private final TextView heading; + private final AppPreviewAdapter appCardsAdapter; + private final FrameLayout background; + + private final Activity activity; + private final LoaderManager loaderManager; + + private String currentCategory; + + CategoryController(final Activity activity, LoaderManager loaderManager, View itemView) { + super(itemView); + + this.activity = activity; + this.loaderManager = loaderManager; + + appCardsAdapter = new AppPreviewAdapter(activity); + + viewAll = (Button) itemView.findViewById(R.id.button); + viewAll.setOnClickListener(onViewAll); + + heading = (TextView) itemView.findViewById(R.id.name); + + background = (FrameLayout) itemView.findViewById(R.id.category_background); + + RecyclerView appCards = (RecyclerView) itemView.findViewById(R.id.app_cards); + appCards.setAdapter(appCardsAdapter); + appCards.addItemDecoration(new ItemDecorator(activity)); + } + + void bindModel(@NonNull String categoryName) { + currentCategory = categoryName; + heading.setText(categoryName); + viewAll.setVisibility(View.INVISIBLE); + loaderManager.initLoader(currentCategory.hashCode(), null, this); + loaderManager.initLoader(currentCategory.hashCode() + 1, null, this); + + background.setBackgroundColor(getBackgroundColour(categoryName)); + } + + public static int getBackgroundColour(@NonNull String categoryName) { + // Seed based on the categoryName, so that each time we try to choose a colour for the same + // category it will look the same for each different user, and each different session. + Random random = new Random(categoryName.toLowerCase().hashCode()); + + float[] hsv = new float[3]; + hsv[0] = random.nextFloat() * 360; + hsv[1] = 0.4f; + hsv[2] = 0.5f; + return Color.HSVToColor(hsv); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + if (id == currentCategory.hashCode() + 1) { + return new CursorLoader( + activity, + AppProvider.getCategoryUri(currentCategory), + new String[]{Schema.AppMetadataTable.Cols._COUNT}, + null, + null, + null + ); + } else { + return new CursorLoader( + activity, + AppProvider.getTopFromCategoryUri(currentCategory, 20), + new String[]{ + Schema.AppMetadataTable.Cols.NAME, + Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME, + Schema.AppMetadataTable.Cols.SUMMARY, + Schema.AppMetadataTable.Cols.ICON_URL, + }, + null, + null, + null + ); + } + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + int topAppsId = currentCategory.hashCode(); + int countAllAppsId = topAppsId + 1; + + // Anything other than these IDs indicates that the loader which just finished finished + // is no longer the one this view holder is interested in, due to the user having + // scrolled away already during the asynchronous query being run. + if (loader.getId() == topAppsId) { + appCardsAdapter.setAppCursor(cursor); + } else if (loader.getId() == countAllAppsId) { + cursor.moveToFirst(); + int numAppsInCategory = cursor.getInt(0); + viewAll.setVisibility(View.VISIBLE); + viewAll.setText(activity.getResources().getQuantityString(R.plurals.button_view_all_apps_in_category, numAppsInCategory, numAppsInCategory)); + } + } + + @Override + public void onLoaderReset(Loader loader) { + appCardsAdapter.setAppCursor(null); + } + + private final View.OnClickListener onViewAll = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentCategory == null) { + return; + } + + Intent intent = new Intent(activity, AppListActivity.class); + intent.putExtra(AppListActivity.EXTRA_CATEGORY, currentCategory); + activity.startActivity(intent); + } + }; + + /** + * Applies excessive padding to the start of the first item. This is so that the category artwork + * can peek out and make itself visible. This is RTL friendly. + * @see org.fdroid.fdroid.R.dimen#category_preview__app_list__padding__horizontal + * @see org.fdroid.fdroid.R.dimen#category_preview__app_list__padding__horizontal__first + */ + private static class ItemDecorator extends RecyclerView.ItemDecoration { + private final Context context; + + ItemDecorator(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + int horizontalPadding = (int) context.getResources().getDimension(R.dimen.category_preview__app_list__padding__horizontal); + int horizontalPaddingFirst = (int) context.getResources().getDimension(R.dimen.category_preview__app_list__padding__horizontal__first); + boolean isLtr = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_LTR; + int itemPosition = parent.getChildLayoutPosition(view); + boolean first = itemPosition == 0; + + // Leave this "paddingEnd" local variable here for clarity when converting from + // left/right to start/end for RTL friendly layout. + // noinspection UnnecessaryLocalVariable + int paddingEnd = horizontalPadding; + int paddingStart = first ? horizontalPaddingFirst : horizontalPadding; + + int paddingLeft = isLtr ? paddingStart : paddingEnd; + int paddingRight = isLtr ? paddingEnd : paddingStart; + outRect.set(paddingLeft, 0, paddingRight, 0); + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java b/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java index 8cd21d398..ce495b2e8 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java @@ -178,7 +178,9 @@ public class AvailableAppsFragment extends AppListFragment implements return AppProvider.getRecentlyUpdatedUri(); } if (currentCategory.equals(CategoryProvider.Helper.getCategoryWhatsNew(getActivity()))) { - return AppProvider.getNewlyAddedUri(); + // Removed this feature in the new UI. this fragment will be gone soon so not implementing it again. + // return AppProvider.getNewlyAddedUri(); + return AppProvider.getRecentlyUpdatedUri(); } return AppProvider.getCategoryUri(currentCategory); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java new file mode 100644 index 000000000..3bcf6567c --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java @@ -0,0 +1,80 @@ +package org.fdroid.fdroid.views.main; + +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.CategoryProvider; +import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.views.categories.CategoryAdapter; + +/** + * Responsible for ensuring that the categories view is inflated and then populated correctly. + * Will start a loader to get the list of categories from the database and populate a recycler + * view with relevant info about each. + */ +class CategoriesViewBinder implements LoaderManager.LoaderCallbacks { + + private static final int LOADER_ID = 429820532; + + private final CategoryAdapter categoryAdapter; + private final AppCompatActivity activity; + + CategoriesViewBinder(AppCompatActivity activity, FrameLayout parent) { + this.activity = activity; + + View categoriesView = activity.getLayoutInflater().inflate(R.layout.main_tab_categories, parent, true); + + categoryAdapter = new CategoryAdapter(activity, activity.getSupportLoaderManager()); + + RecyclerView categoriesList = (RecyclerView) categoriesView.findViewById(R.id.category_list); + categoriesList.setHasFixedSize(true); + categoriesList.setLayoutManager(new LinearLayoutManager(activity)); + categoriesList.setAdapter(categoryAdapter); + + activity.getSupportLoaderManager().initLoader(LOADER_ID, null, this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + if (id != LOADER_ID) { + return null; + } + + return new CursorLoader( + activity, + CategoryProvider.getAllCategories(), + Schema.CategoryTable.Cols.ALL, + null, + null, + null + ); + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + if (loader.getId() != LOADER_ID) { + return; + } + + categoryAdapter.setCategoriesCursor(cursor); + } + + @Override + public void onLoaderReset(Loader loader) { + if (loader.getId() != LOADER_ID) { + return; + } + + categoryAdapter.setCategoriesCursor(null); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java new file mode 100644 index 000000000..649231f16 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java @@ -0,0 +1,69 @@ +package org.fdroid.fdroid.views.main; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.design.widget.BottomNavigationView; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.view.MenuItem; +import android.support.v7.widget.RecyclerView; + +import org.fdroid.fdroid.R; + +/** + * Main view shown to users upon starting F-Droid. + * + * Shows a bottom navigation bar, with the following entries: + * + Whats new + * + Categories list + * + App swap + * + My apps + * + Settings + * + * Users navigate between items by using the bottom navigation bar, or by swiping left and right. + * When switching from one screen to the next, we stay within this activity. The new screen will + * get inflated (if required) + */ +public class MainActivity extends AppCompatActivity implements BottomNavigationView.OnNavigationItemSelectedListener { + + private RecyclerView pager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_main); + + pager = (RecyclerView) findViewById(R.id.main_view_pager); + pager.setHasFixedSize(true); + pager.setLayoutManager(new NonScrollingHorizontalLayoutManager(this)); + pager.setAdapter(new MainViewAdapter(this)); + + BottomNavigationView bottomNavigation = (BottomNavigationView) findViewById(R.id.bottom_navigation); + bottomNavigation.setOnNavigationItemSelectedListener(this); + } + + @Override + public boolean onNavigationItemSelected(@NonNull MenuItem item) { + pager.scrollToPosition(((MainViewAdapter) pager.getAdapter()).adapterPositionFromItemId(item.getItemId())); + return true; + } + + private static class NonScrollingHorizontalLayoutManager extends LinearLayoutManager { + NonScrollingHorizontalLayoutManager(Context context) { + super(context, LinearLayoutManager.HORIZONTAL, false); + } + + @Override + public boolean canScrollHorizontally() { + return false; + } + + @Override + public boolean canScrollVertically() { + return false; + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java new file mode 100644 index 000000000..458bed5bc --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java @@ -0,0 +1,81 @@ +package org.fdroid.fdroid.views.main; + +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.RecyclerView; +import android.util.SparseIntArray; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; + +/** + * Represents the five main views that are accessible from the main view. These are: + * + Whats new + * + Categories + * + Nearby + * + My Apps + * + Settings + * + * It is responsible for understanding the relationship between each main view that is reachable + * from the bottom navigation, and its position. + * + * It doesn't need to do very much other than redirect requests from the {@link MainActivity}s + * {@link RecyclerView} to the relevant "bind*()" method + * of the {@link MainViewController}. + */ +class MainViewAdapter extends RecyclerView.Adapter { + + private final SparseIntArray positionToId = new SparseIntArray(); + + private final AppCompatActivity activity; + + MainViewAdapter(AppCompatActivity activity) { + this.activity = activity; + setHasStableIds(true); + positionToId.put(0, R.id.whats_new); + positionToId.put(1, R.id.categories); + positionToId.put(2, R.id.nearby); + positionToId.put(3, R.id.my_apps); + positionToId.put(4, R.id.settings); + } + + @Override + public MainViewController onCreateViewHolder(ViewGroup parent, int viewType) { + FrameLayout frame = new FrameLayout(activity); + frame.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + return new MainViewController(activity, frame); + } + + @Override + public void onBindViewHolder(MainViewController holder, int position) { + long menuId = getItemId(position); + if (menuId == R.id.whats_new) { + holder.bindWhatsNewView(); + } else if (menuId == R.id.categories) { + holder.bindCategoriesView(); + } else if (menuId == R.id.nearby) { + holder.bindSwapView(); + } else if (menuId == R.id.my_apps) { + holder.bindMyApps(); + } else if (menuId == R.id.settings) { + holder.bindSettingsView(); + } else { + holder.clearViews(); + } + } + + @Override + public int getItemCount() { + return positionToId.size(); + } + + // The RecyclerViewPager and the BottomNavigationView both use menu item IDs to identify pages. + @Override + public long getItemId(int position) { + return positionToId.get(position); + } + + public int adapterPositionFromItemId(int itemId) { + return positionToId.indexOfValue(itemId); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java new file mode 100644 index 000000000..d67ca6f2b --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java @@ -0,0 +1,93 @@ +package org.fdroid.fdroid.views.main; + +import android.content.Intent; +import android.support.v4.app.Fragment; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.views.fragments.PreferencesFragment; +import org.fdroid.fdroid.views.myapps.MyAppsViewBinder; +import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; + +/** + * Decides which view on the main screen to attach to a given {@link FrameLayout}. This class + * doesn't know which view it will be rendering at the time it is constructed. Rather, at some + * point in the future the {@link MainViewAdapter} will have information about which view we + * are required to render, and will invoke the relevant "bind*()" method on this class. + */ +class MainViewController extends RecyclerView.ViewHolder { + + private final AppCompatActivity activity; + private final FrameLayout frame; + + MainViewController(AppCompatActivity activity, FrameLayout frame) { + super(frame); + this.activity = activity; + this.frame = frame; + } + + public void clearViews() { + frame.removeAllViews(); + } + + /** + * @see WhatsNewViewBinder + */ + public void bindWhatsNewView() { + new WhatsNewViewBinder(activity, frame); + } + + /** + * @see MyAppsViewBinder + */ + public void bindMyApps() { + new MyAppsViewBinder(activity, frame); + } + + /** + * @see CategoriesViewBinder + */ + public void bindCategoriesView() { + new CategoriesViewBinder(activity, frame); + } + + /** + * A splash screen encouraging people to start the swap process. + * The swap process is quite heavy duty in that it fires up Bluetooth and/or WiFi in + * order to scan for peers. As such, it is quite convenient to have a more lightweight view to show + * in the main navigation that doesn't automatically start doing things when the user touches the + * navigation menu in the bottom navigation. + */ + public void bindSwapView() { + View swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_swap, frame, true); + + Button startButton = (Button) swapView.findViewById(R.id.button); + startButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + activity.startActivity(new Intent(activity, SwapWorkflowActivity.class)); + } + }); + } + + /** + * Attaches a {@link PreferencesFragment} to the view. Everything else is managed by the + * fragment itself, so no further work needs to be done by this view binder. + * + * Note: It is tricky to attach a {@link Fragment} to a view from this view holder. This is due + * to the way in which the {@link RecyclerView} will reuse existing views and ask us to + * put a settings fragment in there at arbitrary times. Usually it wont be the same view we + * attached the fragment to last time, which causes weirdness. The solution is to use code from + * the com.lsjwzh.widget.recyclerviewpager.FragmentStatePagerAdapter which manages this. + * The code has been ported to {@link SettingsView}. + * + * @see SettingsView + */ + public void bindSettingsView() { + activity.getLayoutInflater().inflate(R.layout.main_tab_settings, frame, true); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/SettingsView.java b/app/src/main/java/org/fdroid/fdroid/views/main/SettingsView.java new file mode 100644 index 000000000..afd8f2121 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/SettingsView.java @@ -0,0 +1,89 @@ +package org.fdroid.fdroid.views.main; + +import android.annotation.TargetApi; +import android.content.Context; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentTransaction; +import android.support.v7.app.AppCompatActivity; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.views.fragments.PreferencesFragment; + +/** + * When attached to the window, the {@link PreferencesFragment} will be added. When detached from + * the window, the fragment will be removed. + * + * Based on code from https://github.com/lsjwzh/RecyclerViewPager/blob/master/lib/src/main/java/com/lsjwzh/widget/recyclerviewpager/FragmentStatePagerAdapter.java + * licensed under the Apache 2.0 license (https://github.com/lsjwzh/RecyclerViewPager/blob/master/LICENSE). + * @see android.support.v4.app.FragmentStatePagerAdapter Much of the code here was ported from this class. + */ +public class SettingsView extends FrameLayout { + + private FragmentTransaction currentTransaction; + + public SettingsView(Context context) { + super(context); + setId(R.id.preference_fragment_parent); + } + + public SettingsView(Context context, AttributeSet attrs) { + super(context, attrs); + setId(R.id.preference_fragment_parent); + } + + public SettingsView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setId(R.id.preference_fragment_parent); + } + + @TargetApi(21) + public SettingsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + setId(R.id.preference_fragment_parent); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + AppCompatActivity activity = (AppCompatActivity) getContext(); + if (activity == null) { + throw new IllegalArgumentException("Cannot add a SettingsView to activities which are not an AppCompatActivity"); + } + + if (currentTransaction == null) { + currentTransaction = activity.getSupportFragmentManager().beginTransaction(); + } + + currentTransaction.replace(getId(), new PreferencesFragment(), "preferences-fragment"); + currentTransaction.commitAllowingStateLoss(); + currentTransaction = null; + activity.getSupportFragmentManager().executePendingTransactions(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + AppCompatActivity activity = (AppCompatActivity) getContext(); + if (activity == null) { + throw new IllegalArgumentException("Cannot add a SettingsView to activities which are not an AppCompatActivity"); + } + + Fragment existingFragment = activity.getSupportFragmentManager().findFragmentByTag("preferences-fragment"); + if (existingFragment == null) { + return; + } + + if (currentTransaction == null) { + currentTransaction = activity.getSupportFragmentManager().beginTransaction(); + } + currentTransaction.remove(existingFragment); + currentTransaction.commitAllowingStateLoss(); + currentTransaction = null; + activity.getSupportFragmentManager().executePendingTransactions(); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java new file mode 100644 index 000000000..6a01e1233 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java @@ -0,0 +1,91 @@ +package org.fdroid.fdroid.views.main; + +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.widget.SwipeRefreshLayout; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.UpdateService; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.views.whatsnew.WhatsNewAdapter; + +/** + * Loads a list of newly added or recently updated apps and displays them to the user. + */ +class WhatsNewViewBinder implements LoaderManager.LoaderCallbacks { + + private static final int LOADER_ID = 978015789; + + private final WhatsNewAdapter whatsNewAdapter; + private final AppCompatActivity activity; + + WhatsNewViewBinder(final AppCompatActivity activity, FrameLayout parent) { + this.activity = activity; + + View whatsNewView = activity.getLayoutInflater().inflate(R.layout.main_tab_whats_new, parent, true); + + whatsNewAdapter = new WhatsNewAdapter(activity); + + GridLayoutManager layoutManager = new GridLayoutManager(activity, 2); + layoutManager.setSpanSizeLookup(new WhatsNewAdapter.SpanSizeLookup()); + + RecyclerView appList = (RecyclerView) whatsNewView.findViewById(R.id.app_list); + appList.setHasFixedSize(true); + appList.setLayoutManager(layoutManager); + appList.setAdapter(whatsNewAdapter); + + final SwipeRefreshLayout swipeToRefresh = (SwipeRefreshLayout) whatsNewView.findViewById(R.id.swipe_to_refresh); + swipeToRefresh.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { + @Override + public void onRefresh() { + swipeToRefresh.setRefreshing(false); + UpdateService.updateNow(activity); + } + }); + + activity.getSupportLoaderManager().initLoader(LOADER_ID, null, this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + if (id != LOADER_ID) { + return null; + } + + return new CursorLoader( + activity, + AppProvider.getRecentlyUpdatedUri(), + Schema.AppMetadataTable.Cols.ALL, + null, + null, + null + ); + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + if (loader.getId() != LOADER_ID) { + return; + } + + whatsNewAdapter.setAppsCursor(cursor); + } + + @Override + public void onLoaderReset(Loader loader) { + if (loader.getId() != LOADER_ID) { + return; + } + + whatsNewAdapter.setAppsCursor(null); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java new file mode 100644 index 000000000..c8adba2ce --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java @@ -0,0 +1,10 @@ +package org.fdroid.fdroid.views.myapps; + +import android.support.v7.widget.RecyclerView; +import android.view.View; + +public class InstalledHeaderController extends RecyclerView.ViewHolder { + public InstalledHeaderController(View itemView) { + super(itemView); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java new file mode 100644 index 000000000..abfdfb7e6 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java @@ -0,0 +1,92 @@ +package org.fdroid.fdroid.views.myapps; + +import android.app.Activity; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.views.apps.AppListItemController; +import org.fdroid.fdroid.views.apps.AppListItemDivider; + +/** + * Wraps a cursor which should have a list of "apps which can be updated". Also includes a header + * as the first element which allows for all items to be updated. + */ +public class MyAppsAdapter extends RecyclerView.Adapter { + + private Cursor updatesCursor; + private final Activity activity; + private final AppListItemDivider divider; + + public MyAppsAdapter(Activity activity) { + this.activity = activity; + divider = new AppListItemDivider(activity); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LayoutInflater inflater = activity.getLayoutInflater(); + switch (viewType) { + case R.id.my_apps__header: + return new UpdatesHeaderController(activity, inflater.inflate(R.layout.my_apps_updates_header, parent, false)); + + case R.id.my_apps__app: + return new AppListItemController(activity, inflater.inflate(R.layout.app_list_item, parent, false)); + + default: + throw new IllegalArgumentException(); + } + } + + @Override + public int getItemCount() { + return updatesCursor == null ? 0 : updatesCursor.getCount() + 1; + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + switch (getItemViewType(position)) { + case R.id.my_apps__header: + ((UpdatesHeaderController) holder).bindModel(updatesCursor.getCount()); + break; + + case R.id.my_apps__app: + updatesCursor.moveToPosition(position - 1); // Subtract one to account for the header. + ((AppListItemController) holder).bindModel(new App(updatesCursor)); + break; + + default: + throw new IllegalArgumentException(); + } + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return R.id.my_apps__header; + } else { + return R.id.my_apps__app; + } + } + + public void setApps(Cursor cursor) { + updatesCursor = cursor; + notifyDataSetChanged(); + } + + @Override + public void onAttachedToRecyclerView(RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + recyclerView.addItemDecoration(divider); + } + + @Override + public void onDetachedFromRecyclerView(RecyclerView recyclerView) { + recyclerView.removeItemDecoration(divider); + super.onDetachedFromRecyclerView(recyclerView); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java new file mode 100644 index 000000000..8e3ec2c73 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java @@ -0,0 +1,77 @@ +package org.fdroid.fdroid.views.myapps; + +import android.app.Activity; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.Schema; + +public class MyAppsViewBinder implements LoaderManager.LoaderCallbacks { + + private final MyAppsAdapter adapter; + + private final Activity activity; + + public MyAppsViewBinder(AppCompatActivity activity, FrameLayout parent) { + this.activity = activity; + + View myAppsView = activity.getLayoutInflater().inflate(R.layout.main_tabs, parent, true); + + adapter = new MyAppsAdapter(activity); + + RecyclerView list = (RecyclerView) myAppsView.findViewById(R.id.list); + list.setHasFixedSize(true); + list.setLayoutManager(new LinearLayoutManager(activity)); + list.setAdapter(adapter); + + LoaderManager loaderManager = activity.getSupportLoaderManager(); + loaderManager.initLoader(0, null, this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new CursorLoader( + activity, + AppProvider.getCanUpdateUri(), + new String[]{ + Schema.AppMetadataTable.Cols._ID, // Required for cursor loader to work. + Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME, + Schema.AppMetadataTable.Cols.NAME, + Schema.AppMetadataTable.Cols.SUMMARY, + Schema.AppMetadataTable.Cols.IS_COMPATIBLE, + Schema.AppMetadataTable.Cols.LICENSE, + Schema.AppMetadataTable.Cols.ICON, + Schema.AppMetadataTable.Cols.ICON_URL, + Schema.AppMetadataTable.Cols.InstalledApp.VERSION_CODE, + Schema.AppMetadataTable.Cols.InstalledApp.VERSION_NAME, + Schema.AppMetadataTable.Cols.SuggestedApk.VERSION_NAME, + Schema.AppMetadataTable.Cols.SUGGESTED_VERSION_CODE, + Schema.AppMetadataTable.Cols.REQUIREMENTS, // Needed for filtering apps that require root. + Schema.AppMetadataTable.Cols.ANTI_FEATURES, // Needed for filtering apps that require anti-features. + }, + null, + null, + null + ); + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + adapter.setApps(cursor); + } + + @Override + public void onLoaderReset(Loader loader) { + adapter.setApps(null); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java new file mode 100644 index 000000000..1bbf61210 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java @@ -0,0 +1,38 @@ +package org.fdroid.fdroid.views.myapps; + +import android.app.Activity; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.UpdateService; + +public class UpdatesHeaderController extends RecyclerView.ViewHolder { + + private final Activity activity; + private final TextView updatesHeading; + + public UpdatesHeaderController(Activity activity, View itemView) { + super(itemView); + this.activity = activity; + + Button updateAll = (Button) itemView.findViewById(R.id.update_all_button); + updateAll.setOnClickListener(onUpdateAll); + + updatesHeading = (TextView) itemView.findViewById(R.id.updates_heading); + updatesHeading.setText(activity.getString(R.string.updates)); + } + + public void bindModel(int numAppsToUpdate) { + updatesHeading.setText(activity.getResources().getQuantityString(R.plurals.my_apps_header_number_of_updateable, numAppsToUpdate, numAppsToUpdate)); + } + + private final View.OnClickListener onUpdateAll = new View.OnClickListener() { + @Override + public void onClick(View v) { + UpdateService.autoDownloadUpdates(activity); + } + }; +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java new file mode 100644 index 000000000..d5e2bb2b7 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java @@ -0,0 +1,143 @@ +package org.fdroid.fdroid.views.whatsnew; + +import android.app.Activity; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.views.categories.AppCardController; + +public class WhatsNewAdapter extends RecyclerView.Adapter { + + private Cursor cursor; + private final Activity activity; + private final RecyclerView.ItemDecoration appListDecorator; + + public WhatsNewAdapter(Activity activity) { + this.activity = activity; + appListDecorator = new WhatsNewAdapter.ItemDecorator(activity); + } + + @Override + public void onAttachedToRecyclerView(RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + recyclerView.addItemDecoration(appListDecorator); + } + + @Override + public void onDetachedFromRecyclerView(RecyclerView recyclerView) { + recyclerView.removeItemDecoration(appListDecorator); + super.onDetachedFromRecyclerView(recyclerView); + } + + @Override + public AppCardController onCreateViewHolder(ViewGroup parent, int viewType) { + int layout; + if (viewType == R.id.whats_new_feature) { + layout = R.layout.app_card_featured; + } else if (viewType == R.id.whats_new_large_tile) { + layout = R.layout.app_card_large; + } else if (viewType == R.id.whats_new_small_tile) { + layout = R.layout.app_card_horizontal; + } else if (viewType == R.id.whats_new_regular_list) { + layout = R.layout.app_card_list_item; + } else { + throw new IllegalArgumentException("Unknown view type when rendering \"Whats New\": " + viewType); + } + + return new AppCardController(activity, activity.getLayoutInflater().inflate(layout, parent, false)); + + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return R.id.whats_new_feature; + } else if (position <= 2) { + return R.id.whats_new_large_tile; + } else if (position <= 4) { + return R.id.whats_new_small_tile; + } else { + return R.id.whats_new_regular_list; + } + } + + @Override + public void onBindViewHolder(AppCardController holder, int position) { + cursor.moveToPosition(position); + holder.bindApp(new App(cursor)); + } + + @Override + public int getItemCount() { + return cursor == null ? 0 : cursor.getCount(); + } + + public void setAppsCursor(Cursor cursor) { + this.cursor = cursor; + notifyDataSetChanged(); + } + + // TODO: Replace with https://github.com/lucasr/twoway-view which looks really really cool, but + // no longer under active development (despite heaps of forks/stars on github). + public static class SpanSizeLookup extends GridLayoutManager.SpanSizeLookup { + @Override + public int getSpanSize(int position) { + if (position == 0) { + return 2; + } else if (position <= 4) { + return 1; + } else { + return 2; + } + } + } + + /** + * Applies padding to items, ensuring that the spacing on the left, centre, and right all match. + * The vertical padding is slightly shorter than the horizontal padding also. + * @see org.fdroid.fdroid.R.dimen#whats_new__padding__app_card__horizontal + * @see org.fdroid.fdroid.R.dimen#whats_new__padding__app_card__vertical + */ + private class ItemDecorator extends RecyclerView.ItemDecoration { + private final Context context; + + ItemDecorator(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + int position = parent.getChildAdapterPosition(view); + int horizontalPadding = (int) context.getResources().getDimension(R.dimen.whats_new__padding__app_card__horizontal); + int verticalPadding = (int) context.getResources().getDimension(R.dimen.whats_new__padding__app_card__vertical); + + if (position == 0) { + // Don't set any padding for the first item as the FeatureImage behind it needs to butt right + // up against the left/top/right of the screen. + outRect.set(0, 0, 0, verticalPadding); + } else if (position <= 4) { + // Odd items are on the left, even on the right. + // The item on the left will have both left and right padding. The item on the right + // will only have padding on the right. This will allow the same amount of padding + // on the left, centre, and right of the grid, rather than double the padding in the + // middle (which would happen if both left+right paddings were set for both items). + boolean isLtr = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_LTR; + boolean isAtStart = (position % 2) == 1; + int paddingStart = isAtStart ? horizontalPadding : 0; + int paddingLeft = isLtr ? paddingStart : horizontalPadding; + int paddingRight = isLtr ? horizontalPadding : paddingStart; + outRect.set(paddingLeft, 0, paddingRight, verticalPadding); + } else { + outRect.set(horizontalPadding, 0, horizontalPadding, verticalPadding); + } + } + } +} diff --git a/app/src/main/res/drawable/app_list_item_divider.xml b/app/src/main/res/drawable/app_list_item_divider.xml new file mode 100644 index 000000000..4e5abfa49 --- /dev/null +++ b/app/src/main/res/drawable/app_list_item_divider.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/category_chip_background.xml b/app/src/main/res/drawable/category_chip_background.xml new file mode 100644 index 000000000..fd4ed2ac8 --- /dev/null +++ b/app/src/main/res/drawable/category_chip_background.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/category_preview_app_card_background.xml b/app/src/main/res/drawable/category_preview_app_card_background.xml new file mode 100644 index 000000000..b2eb1a1fa --- /dev/null +++ b/app/src/main/res/drawable/category_preview_app_card_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_button.xml b/app/src/main/res/drawable/download_button.xml new file mode 100644 index 000000000..4faebbd9f --- /dev/null +++ b/app/src/main/res/drawable/download_button.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_back_black_24dp.xml b/app/src/main/res/drawable/ic_back_black_24dp.xml new file mode 100644 index 000000000..a5b378a9c --- /dev/null +++ b/app/src/main/res/drawable/ic_back_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_category.xml b/app/src/main/res/drawable/ic_category.xml new file mode 100644 index 000000000..b71523ac0 --- /dev/null +++ b/app/src/main/res/drawable/ic_category.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_black_24dp.xml b/app/src/main/res/drawable/ic_close_black_24dp.xml new file mode 100644 index 000000000..ede4b7108 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_button.xml b/app/src/main/res/drawable/ic_download_button.xml new file mode 100644 index 000000000..562b5f20e --- /dev/null +++ b/app/src/main/res/drawable/ic_download_button.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_my_apps.xml b/app/src/main/res/drawable/ic_my_apps.xml new file mode 100644 index 000000000..8bb1ec68b --- /dev/null +++ b/app/src/main/res/drawable/ic_my_apps.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_nearby.xml b/app/src/main/res/drawable/ic_nearby.xml new file mode 100644 index 000000000..c05402310 --- /dev/null +++ b/app/src/main/res/drawable/ic_nearby.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_overview.xml b/app/src/main/res/drawable/ic_overview.xml new file mode 100644 index 000000000..8aec73afc --- /dev/null +++ b/app/src/main/res/drawable/ic_overview.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..7fb01b86a --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/layout/activity_app_list.xml b/app/src/main/res/layout/activity_app_list.xml new file mode 100644 index 000000000..f81d7ba9e --- /dev/null +++ b/app/src/main/res/layout/activity_app_list.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..60c98a6a3 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_card_featured.xml b/app/src/main/res/layout/app_card_featured.xml new file mode 100644 index 000000000..9f1da484d --- /dev/null +++ b/app/src/main/res/layout/app_card_featured.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_card_horizontal.xml b/app/src/main/res/layout/app_card_horizontal.xml new file mode 100644 index 000000000..9baf5f7f0 --- /dev/null +++ b/app/src/main/res/layout/app_card_horizontal.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_card_large.xml b/app/src/main/res/layout/app_card_large.xml new file mode 100644 index 000000000..df691a2ff --- /dev/null +++ b/app/src/main/res/layout/app_card_large.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_card_list_item.xml b/app/src/main/res/layout/app_card_list_item.xml new file mode 100644 index 000000000..5373d6cd4 --- /dev/null +++ b/app/src/main/res/layout/app_card_list_item.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_card_normal.xml b/app/src/main/res/layout/app_card_normal.xml new file mode 100644 index 000000000..e303d6196 --- /dev/null +++ b/app/src/main/res/layout/app_card_normal.xml @@ -0,0 +1,41 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_list_item.xml b/app/src/main/res/layout/app_list_item.xml new file mode 100644 index 000000000..e477a7912 --- /dev/null +++ b/app/src/main/res/layout/app_list_item.xml @@ -0,0 +1,67 @@ + + + + + + + + + +