Merge branch 'default-categories' into 'master'

Ensure existence of default categories

See merge request fdroid/fdroidclient!1241
This commit is contained in:
Hans-Christoph Steiner
2023-07-04 15:48:54 +00:00
6 changed files with 129 additions and 95 deletions

View File

@@ -1,6 +1,5 @@
package org.fdroid.fdroid.views.categories;
import android.util.Log;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
@@ -14,26 +13,24 @@ import org.fdroid.database.Category;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.fdroid.R;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
public class CategoryAdapter extends ListAdapter<Category, CategoryController> {
public class CategoryAdapter extends ListAdapter<CategoryItem, CategoryController> {
private final AppCompatActivity activity;
private final FDroidDatabase db;
private final HashMap<Category, LiveData<List<AppOverviewItem>>> liveData = new HashMap<>();
public CategoryAdapter(AppCompatActivity activity, FDroidDatabase db) {
super(new DiffUtil.ItemCallback<Category>() {
super(new DiffUtil.ItemCallback<CategoryItem>() {
@Override
public boolean areItemsTheSame(@NonNull Category oldItem, @NonNull Category newItem) {
return oldItem.equals(newItem);
public boolean areItemsTheSame(@NonNull CategoryItem oldItem, @NonNull CategoryItem newItem) {
return oldItem.category.equals(newItem.category);
}
@Override
public boolean areContentsTheSame(@NonNull Category oldItem, @NonNull Category newItem) {
public boolean areContentsTheSame(@NonNull CategoryItem oldItem, @NonNull CategoryItem newItem) {
return false;
}
});
@@ -51,31 +48,17 @@ public class CategoryAdapter extends ListAdapter<Category, CategoryController> {
@Override
public void onBindViewHolder(@NonNull CategoryController holder, int position) {
Category category = getItem(position);
holder.bindModel(category, liveData.get(category), this::onNoApps);
CategoryItem item = getItem(position);
holder.bindModel(item, liveData.get(item.category));
}
public void setCategories(@NonNull List<Category> categories) {
submitList(categories);
for (Category category : categories) {
public void setCategories(@NonNull List<CategoryItem> items) {
submitList(items);
for (CategoryItem item : items) {
int num = CategoryController.NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW;
// we are getting the LiveData here and not in the ViewHolder, so the data gets cached here
// this prevents reloads when scrolling
liveData.put(category, db.getAppDao().getAppOverviewItems(category.getId(), num));
liveData.put(item.category, db.getAppDao().getAppOverviewItems(item.category.getId(), num));
}
}
private void onNoApps(Category category) {
ArrayList<Category> categories = new ArrayList<>(getCurrentList());
Iterator<Category> itr = categories.iterator();
while (itr.hasNext()) {
Category c = itr.next();
if (c.getId().equals(category.getId())) {
Log.d("CategoryAdapter", "Removing " + category.getId() + " without apps.");
itr.remove();
break;
}
}
submitList(categories);
}
}

View File

@@ -13,11 +13,9 @@ import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.os.LocaleListCompat;
import androidx.core.util.Consumer;
import androidx.core.view.ViewCompat;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
@@ -27,12 +25,10 @@ import com.bumptech.glide.Glide;
import org.fdroid.database.AppOverviewItem;
import org.fdroid.database.Category;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.Repository;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.apps.FeatureImage;
import org.fdroid.index.v2.FileV2;
@@ -41,11 +37,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Random;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CategoryController extends RecyclerView.ViewHolder {
private static final String TAG = "CategoryController";
@@ -57,18 +48,14 @@ public class CategoryController extends RecyclerView.ViewHolder {
private final FrameLayout background;
private final AppCompatActivity activity;
private final FDroidDatabase db;
static final int NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW = 20;
private Category currentCategory;
@Nullable
private Disposable disposable;
CategoryController(final AppCompatActivity activity, View itemView) {
super(itemView);
this.activity = activity;
db = DBHelper.getDb(activity);
appCardsAdapter = new AppPreviewAdapter(activity);
@@ -89,36 +76,32 @@ public class CategoryController extends RecyclerView.ViewHolder {
return categoryNameId == 0 ? categoryName : context.getString(categoryNameId);
}
void bindModel(@NonNull Category category, LiveData<List<AppOverviewItem>> liveData,
Consumer<Category> onNoApps) {
void bindModel(@NonNull CategoryItem item, LiveData<List<AppOverviewItem>> liveData) {
loadAppItems(liveData);
currentCategory = category;
currentCategory = item.category;
String categoryName = category.getName(LocaleListCompat.getDefault());
if (categoryName == null) categoryName = translateCategory(activity, category.getId());
String categoryName = item.category.getName(LocaleListCompat.getDefault());
if (categoryName == null) categoryName = translateCategory(activity, item.category.getId());
heading.setText(categoryName);
heading.setContentDescription(activity.getString(R.string.tts_category_name, categoryName));
viewAll.setVisibility(View.INVISIBLE);
loadNumAppsInCategory(onNoApps);
@ColorInt int backgroundColour = getBackgroundColour(activity, category.getId());
@ColorInt int backgroundColour = getBackgroundColour(activity, item.category.getId());
background.setBackgroundColor(backgroundColour);
// try to load image from repo first
FileV2 iconFile = category.getIcon(LocaleListCompat.getDefault());
Repository repo = FDroidApp.getRepoManager(activity).getRepository(category.getRepoId());
FileV2 iconFile = item.category.getIcon(LocaleListCompat.getDefault());
Repository repo = FDroidApp.getRepoManager(activity).getRepository(item.category.getRepoId());
if (iconFile != null && repo != null) {
Log.i(TAG, "Loading remote image for: " + category.getId());
Log.i(TAG, "Loading remote image for: " + item.category.getId());
Glide.with(activity)
.load(Utils.getDownloadRequest(repo, iconFile))
.apply(Utils.getAlwaysShowIconRequestOptions())
.into(image);
} else {
// try to get local image resource
int categoryImageId = getCategoryResource(activity, category.getId(), "drawable", true);
int categoryImageId = getCategoryResource(activity, item.category.getId(), "drawable", true);
if (categoryImageId == 0) {
Log.w(TAG, "No image for: " + category.getId());
Log.w(TAG, "No image for: " + item.category.getId());
image.setColour(backgroundColour);
image.setImageDrawable(null);
Glide.with(activity).clear(image);
@@ -127,6 +110,10 @@ public class CategoryController extends RecyclerView.ViewHolder {
Glide.with(activity).load(categoryImageId).into(image);
}
}
Resources r = activity.getResources();
viewAll.setText(r.getQuantityString(R.plurals.button_view_all_apps_in_category, item.numApps, item.numApps));
viewAll.setContentDescription(r.getQuantityString(R.plurals.tts_view_all_in_category, item.numApps,
item.numApps, currentCategory));
}
private void loadAppItems(LiveData<List<AppOverviewItem>> liveData) {
@@ -141,14 +128,6 @@ public class CategoryController extends RecyclerView.ViewHolder {
});
}
private void loadNumAppsInCategory(Consumer<Category> onNoApps) {
if (disposable != null) disposable.dispose();
disposable = Single.fromCallable(() -> db.getAppDao().getNumberOfAppsInCategory(currentCategory.getId()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(numApps -> setNumAppsInCategory(numApps, onNoApps));
}
/**
* @param requiresLowerCaseId Previously categories were translated using strings such as "category_Reading"
* for the "Reading" category. Now we also need to have drawable resources such as
@@ -181,19 +160,6 @@ public class CategoryController extends RecyclerView.ViewHolder {
return Color.HSVToColor(hsv);
}
private void setNumAppsInCategory(int numAppsInCategory, Consumer<Category> onNoApps) {
if (numAppsInCategory == 0) {
onNoApps.accept(currentCategory);
} else {
viewAll.setVisibility(View.VISIBLE);
Resources r = activity.getResources();
viewAll.setText(r.getQuantityString(R.plurals.button_view_all_apps_in_category, numAppsInCategory,
numAppsInCategory));
viewAll.setContentDescription(r.getQuantityString(R.plurals.tts_view_all_in_category, numAppsInCategory,
numAppsInCategory, currentCategory));
}
}
@SuppressWarnings("FieldCanBeLocal")
private final View.OnClickListener onViewAll = new View.OnClickListener() {
@Override

View File

@@ -0,0 +1,14 @@
package org.fdroid.fdroid.views.categories;
import org.fdroid.database.Category;
public class CategoryItem {
public final Category category;
final int numApps;
public CategoryItem(Category category, int numApps) {
this.category = category;
this.numApps = numApps;
}
}

View File

@@ -1,10 +1,14 @@
package org.fdroid.fdroid.views.main;
import android.content.Intent;
import android.util.ArraySet;
import android.util.Log;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.os.LocaleListCompat;
import androidx.lifecycle.Observer;
@@ -25,11 +29,16 @@ import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.categories.CategoryAdapter;
import org.fdroid.fdroid.views.categories.CategoryItem;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Responsible for ensuring that the categories view is inflated and then populated correctly.
@@ -39,15 +48,18 @@ import java.util.Map;
class CategoriesViewBinder implements Observer<List<Category>> {
public static final String TAG = "CategoriesViewBinder";
private final FDroidDatabase db;
private final String[] defaultCategories;
private final CategoryAdapter categoryAdapter;
private final AppCompatActivity activity;
private final TextView emptyState;
private final RecyclerView categoriesList;
@Nullable
private Disposable disposable;
CategoriesViewBinder(final AppCompatActivity activity, FrameLayout parent) {
this.activity = activity;
FDroidDatabase db = DBHelper.getDb(activity);
db = DBHelper.getDb(activity);
Transformations.distinctUntilChanged(db.getRepositoryDao().getLiveCategories()).observe(activity, this);
defaultCategories = activity.getResources().getStringArray(R.array.defaultCategories);
View categoriesView = activity.getLayoutInflater().inflate(R.layout.main_tab_categories, parent, true);
@@ -85,27 +97,65 @@ class CategoriesViewBinder implements Observer<List<Category>> {
*/
@Override
public void onChanged(List<Category> categories) {
LocaleListCompat localeListCompat = LocaleListCompat.getDefault();
Collections.sort(categories, (o1, o2) -> {
String name1 = o1.getName(localeListCompat);
if (name1 == null) name1 = o1.getId();
String name2 = o2.getName(localeListCompat);
if (name2 == null) name2 = o2.getId();
return name1.compareToIgnoreCase(name2);
});
// TODO force-adding nightly category here can be removed once fdroidserver LTS supports defining categories
ArrayList<Category> c = new ArrayList<>(categories);
Map<String, String> name = Collections.singletonMap("en-US", activity.getString(R.string.category_Nightly));
c.add(new Category(42L, "nightly", Collections.emptyMap(), name, Collections.emptyMap()));
categoryAdapter.setCategories(c);
if (disposable != null) disposable.dispose();
disposable = Single.fromCallable(() -> loadCategoryItems(categories))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::onItemsLoaded);
}
if (categoryAdapter.getItemCount() == 0) {
private void onItemsLoaded(List<CategoryItem> items) {
if (items.size() == 0) {
emptyState.setVisibility(View.VISIBLE);
categoriesList.setVisibility(View.GONE);
} else {
categoryAdapter.setCategories(items);
emptyState.setVisibility(View.GONE);
categoriesList.setVisibility(View.VISIBLE);
}
}
@WorkerThread
private List<CategoryItem> loadCategoryItems(List<Category> categories) {
// get items
ArrayList<CategoryItem> items = new ArrayList<>();
ArraySet<String> ids = new ArraySet<>(categories.size());
for (Category c : categories) {
int numApps = db.getAppDao().getNumberOfAppsInCategory(c.getId());
if (numApps > 0) {
ids.add(c.getId());
CategoryItem item = new CategoryItem(c, numApps);
items.add(item);
} else {
Log.d(TAG, "Not adding " + c.getId() + " because it has no apps.");
}
}
// add default categories, if they are not in already
for (String id : defaultCategories) {
if (!ids.contains(id)) {
int numApps = db.getAppDao().getNumberOfAppsInCategory(id);
if (numApps > 0) {
// name and icon gets set in CategoryController, if not given here
Category c = new Category(2L, id, Collections.emptyMap(), Collections.emptyMap(),
Collections.emptyMap());
CategoryItem item = new CategoryItem(c, numApps);
items.add(item);
} else {
Log.d(TAG, "Not adding default " + id + " because it has no apps.");
}
}
}
// sort items
LocaleListCompat localeListCompat = LocaleListCompat.getDefault();
Collections.sort(items, (o1, o2) -> {
ids.add(o2.category.getId());
String name1 = o1.category.getName(localeListCompat);
if (name1 == null) name1 = o1.category.getId();
String name2 = o2.category.getName(localeListCompat);
if (name2 == null) name2 = o2.category.getId();
return name1.compareToIgnoreCase(name2);
});
return items;
}
}

View File

@@ -57,4 +57,25 @@
<item>@string/antinsfw</item>
<item>@string/antiothers</item>
</string-array>
<string-array name="defaultCategories">
<item>@string/category_Connectivity</item>
<item>@string/category_Development</item>
<item>@string/category_Games</item>
<item>@string/category_Graphics</item>
<item>@string/category_Internet</item>
<item>@string/category_Money</item>
<item>@string/category_Multimedia</item>
<item>@string/category_Navigation</item>
<item>@string/category_Phone_SMS</item>
<item>@string/category_Reading</item>
<item>@string/category_Science_Education</item>
<item>@string/category_Security</item>
<item>@string/category_Sports_Health</item>
<item>@string/category_System</item>
<item>@string/category_Theming</item>
<item>@string/category_Time</item>
<item>@string/category_Writing</item>
<item>@string/category_Nightly</item>
</string-array>
</resources>

View File

@@ -480,7 +480,7 @@ This often occurs with apps installed via Google Play or other sources, if they
<string tools:ignore="UnusedResources" name="category_Theming">Theming</string>
<string tools:ignore="UnusedResources" name="category_Time">Time</string>
<string tools:ignore="UnusedResources" name="category_Writing">Writing</string>
<string tools:ignore="UnusedResources" name="category_Nightly">Nightly Builds</string>
<string tools:ignore="UnusedResources" name="category_Nightly">nightly</string>
<plurals name="button_view_all_apps_in_category">
<item quantity="one">View %d</item>