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 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/category_item.xml b/app/src/main/res/layout/category_item.xml
new file mode 100644
index 000000000..392ee0889
--- /dev/null
+++ b/app/src/main/res/layout/category_item.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_tab_categories.xml b/app/src/main/res/layout/main_tab_categories.xml
new file mode 100644
index 000000000..2b4676585
--- /dev/null
+++ b/app/src/main/res/layout/main_tab_categories.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_tab_settings.xml b/app/src/main/res/layout/main_tab_settings.xml
new file mode 100644
index 000000000..97d338420
--- /dev/null
+++ b/app/src/main/res/layout/main_tab_settings.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_tab_swap.xml b/app/src/main/res/layout/main_tab_swap.xml
new file mode 100644
index 000000000..a7f010b06
--- /dev/null
+++ b/app/src/main/res/layout/main_tab_swap.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_tab_whats_new.xml b/app/src/main/res/layout/main_tab_whats_new.xml
new file mode 100644
index 000000000..d10730522
--- /dev/null
+++ b/app/src/main/res/layout/main_tab_whats_new.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_tabs.xml b/app/src/main/res/layout/main_tabs.xml
new file mode 100644
index 000000000..d47fb717d
--- /dev/null
+++ b/app/src/main/res/layout/main_tabs.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/my_apps_updates_header.xml b/app/src/main/res/layout/my_apps_updates_header.xml
new file mode 100644
index 000000000..f283b406e
--- /dev/null
+++ b/app/src/main/res/layout/my_apps_updates_header.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/repo_list_activity.xml b/app/src/main/res/layout/repo_list_activity.xml
new file mode 100644
index 000000000..472461097
--- /dev/null
+++ b/app/src/main/res/layout/repo_list_activity.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/repodetails.xml b/app/src/main/res/layout/repodetails.xml
index 7293f9c19..8c3711c81 100644
--- a/app/src/main/res/layout/repodetails.xml
+++ b/app/src/main/res/layout/repodetails.xml
@@ -1,102 +1,118 @@
-
+ android:layout_height="match_parent"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:orientation="vertical">
-
+ app:theme="?attr/actionBarTheme"
+ app:popupTheme="?attr/actionBarPopupTheme" />
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:paddingLeft="?attr/listPreferredItemPaddingLeft"
+ android:paddingStart="?attr/listPreferredItemPaddingLeft"
+ android:paddingRight="?attr/listPreferredItemPaddingRight"
+ android:paddingEnd="?attr/listPreferredItemPaddingRight"
+ android:orientation="vertical">
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/main_activity_screens.xml b/app/src/main/res/menu/main_activity_screens.xml
new file mode 100644
index 000000000..d2facc6ce
--- /dev/null
+++ b/app/src/main/res/menu/main_activity_screens.xml
@@ -0,0 +1,29 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 20d2d578d..2e70e8ac4 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -18,4 +18,13 @@
3dp
+ 12dp
+ 10dp
+
+ 4dp
+ 72dp
+ 18dp
+ 12dp
+ 3dp
+ 4dp
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
index 9b031b54e..46c7bf804 100644
--- a/app/src/main/res/values/ids.xml
+++ b/app/src/main/res/values/ids.xml
@@ -2,4 +2,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2976c7f7a..bd5c70f67 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -63,9 +63,17 @@
Not Installed
Installed (from %s)
Installed (from unknown source)
+ Version %1$s available
+ Version %1$s
Added on %s
+ Update all
+
+ - %1$d Update
+ - %1$d Updates
+
+
OK
Yes
@@ -82,6 +90,7 @@
Enable
Add Key
Overwrite
+ Clear search
Available
Installed
@@ -130,6 +139,11 @@
Litecoin
Flattr
+ Latest
+ Categories
+ Nearby
+ My Apps
+
Version %s installed
Not installed
@@ -264,6 +278,13 @@
Time
Writing
+
+
+ - View all %d
+ - View all %d
+
+
No apps installed.\n\nThere are apps on your device, but they are not available from F-Droid. This could be because you need to update your repositories, or the repositories genuinely don\'t have your apps available.
No apps in this category.\n\nTry selecting a different category or updating your repositories to get a fresh list of apps.
All apps up to date.\n\nCongratulations! All of your apps are up to date (or your repositories are out of date).
@@ -423,4 +444,5 @@
Cancel
Install
+ Category
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 5214daa6d..5db92e500 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,7 +1,7 @@
-
-
+
+
diff --git a/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java b/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java
index 844e81dd9..86c738c78 100644
--- a/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java
@@ -114,6 +114,9 @@ public class AppProviderTest extends FDroidProviderTest {
App notInstalled = AppProvider.Helper.findSpecificApp(r, "not installed", 1, Cols.ALL);
assertFalse(notInstalled.canAndWantToUpdate(context));
+ assertResultCount(contentResolver, 2, AppProvider.getCanUpdateUri(), PROJ);
+ assertResultCount(contentResolver, 7, AppProvider.getInstalledUri(), PROJ);
+
App installedOnlyOneVersionAvailable = AppProvider.Helper.findSpecificApp(r, "installed, only one version available", 1, Cols.ALL);
App installedAlreadyLatestNoIgnore = AppProvider.Helper.findSpecificApp(r, "installed, already latest, no ignore", 1, Cols.ALL);
App installedAlreadyLatestIgnoreAll = AppProvider.Helper.findSpecificApp(r, "installed, already latest, ignore all", 1, Cols.ALL);
@@ -206,12 +209,14 @@ public class AppProviderTest extends FDroidProviderTest {
insertApps(100);
assertResultCount(contentResolver, 100, AppProvider.getContentUri(), PROJ);
+ assertResultCount(contentResolver, 0, AppProvider.getCanUpdateUri(), PROJ);
assertResultCount(contentResolver, 0, AppProvider.getInstalledUri(), PROJ);
for (int i = 10; i < 20; i++) {
InstalledAppTestUtils.install(context, "com.example.test." + i, i, "v1");
}
+ assertResultCount(contentResolver, 0, AppProvider.getCanUpdateUri(), PROJ);
assertResultCount(contentResolver, 10, AppProvider.getInstalledUri(), PROJ);
}