diff --git a/controller/app/src/androidTest/java/org/iiab/controller/pending/data/PendingOrdersRepositoryImplTest.java b/controller/app/src/androidTest/java/org/iiab/controller/pending/data/PendingOrdersRepositoryImplTest.java new file mode 100644 index 000000000..960b62f84 --- /dev/null +++ b/controller/app/src/androidTest/java/org/iiab/controller/pending/data/PendingOrdersRepositoryImplTest.java @@ -0,0 +1,130 @@ +/* + * ============================================================================ + * Name : PendingOrdersRepositoryImplTest.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Instrumented test for ADFA-5169 (finding 6). Validates the banked- + * order mechanism end to end at the data layer with real + * SharedPreferences: the three content wishlists are listed as + * PendingOrders, and cancelling one removes only that order. This is + * the deterministic stand-in for a state that is not reachable through + * normal UX (see controller/docs/ADFA-5169-pending-downloads-design.md). + * ============================================================================ + */ +package org.iiab.controller.pending.data; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.iiab.controller.kolibri.data.KolibriWishlist; +import org.iiab.controller.pending.domain.PendingOrder; +import org.iiab.controller.redesign.BooksWishlist; +import org.iiab.controller.redesign.ZimWishlist; +import org.iiab.controller.system.domain.ContentType; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RunWith(AndroidJUnit4.class) +public class PendingOrdersRepositoryImplTest { + + /** A valid Kolibri channel id: 32 lowercase hex chars (ChannelId.normalise). */ + private static final String CHANNEL = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"; + + private Context ctx() { + return ApplicationProvider.getApplicationContext(); + } + + @Before + @After + public void clearWishlists() { + Context c = ctx(); + ZimWishlist.clear(c); + BooksWishlist.clear(c); + KolibriWishlist.clear(c); + } + + private void seedFour() { + Context c = ctx(); + Map zim = new LinkedHashMap<>(); + zim.put("wikipedia|en|maxi", 4_200L); + zim.put("wikipedia|es|maxi", 3_100L); + ZimWishlist.add(c, zim); + BooksWishlist.add(c, "b1", "Gutenberg", "https://example.invalid/x.epub"); + KolibriWishlist.add(c, CHANNEL, 1, "Khan Academy", 12_000L, null); + } + + @Test + public void emptyWhenNothingBanked() { + assertTrue(new PendingOrdersRepositoryImpl(ctx()).list().isEmpty()); + } + + @Test + public void listsEveryBankedOrderGroupedByType() { + seedFour(); + List orders = new PendingOrdersRepositoryImpl(ctx()).list(); + + assertEquals(4, orders.size()); + // Grouped by content type in enum order: ZIM (2), Books (1), Courses (1). + assertEquals(ContentType.ZIM, orders.get(0).type()); + assertEquals(ContentType.ZIM, orders.get(1).type()); + assertEquals(ContentType.BOOKS, orders.get(2).type()); + assertEquals(ContentType.COURSES, orders.get(3).type()); + // Names: ZIM uses its selector key; Books uses the title; Courses uses the name. + assertEquals("wikipedia|en|maxi", orders.get(0).name()); + assertEquals("Gutenberg", orders.get(2).name()); + assertEquals("Khan Academy", orders.get(3).name()); + } + + @Test + public void cancelRemovesOnlyThatOrder() { + seedFour(); + PendingOrdersRepositoryImpl repo = new PendingOrdersRepositoryImpl(ctx()); + + PendingOrder book = null; + for (PendingOrder o : repo.list()) { + if (o.type() == ContentType.BOOKS) book = o; + } + assertNotNull(book); + repo.cancel(book); + + List after = repo.list(); + assertEquals(3, after.size()); + for (PendingOrder o : after) { + assertNotEquals(ContentType.BOOKS, o.type()); + } + assertEquals(0, BooksWishlist.size(ctx())); // the Books order is gone + assertEquals(2, ZimWishlist.size(ctx())); // the rest are untouched + assertEquals(1, KolibriWishlist.size(ctx())); + } + + @Test + public void cancelOneZimLeavesTheOtherZim() { + seedFour(); + PendingOrdersRepositoryImpl repo = new PendingOrdersRepositoryImpl(ctx()); + + // Exercises the new ZimWishlist.remove(key): drop only the English collection. + repo.cancel(new PendingOrder(ContentType.ZIM, "wikipedia|en|maxi", "x", 0L)); + + List after = repo.list(); + assertEquals(3, after.size()); + assertEquals(1, ZimWishlist.size(ctx())); + boolean spanishRemains = false; + for (PendingOrder o : after) { + if ("wikipedia|es|maxi".equals(o.id())) spanishRemains = true; + } + assertTrue(spanishRemains); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/data/PendingOrdersRepositoryImpl.java b/controller/app/src/main/java/org/iiab/controller/pending/data/PendingOrdersRepositoryImpl.java new file mode 100644 index 000000000..ca7e3fcec --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/data/PendingOrdersRepositoryImpl.java @@ -0,0 +1,99 @@ +/* + * ============================================================================ + * Name : PendingOrdersRepositoryImpl.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Reads the three live content wishlists (ZIM, Books, Courses) into + * PendingOrders and cancels one by removing it from its wishlist. + * The single place that maps a content type to its wishlist for the + * pending list, alongside PendingContent's existing knowledge + * (ADFA-5169). Maps and modules are out of scope by design. + * ============================================================================ + */ +package org.iiab.controller.pending.data; + +import android.content.Context; + +import org.iiab.controller.kolibri.data.KolibriWishlist; +import org.iiab.controller.pending.domain.PendingOrder; +import org.iiab.controller.pending.domain.PendingOrdersRepository; +import org.iiab.controller.redesign.BooksWishlist; +import org.iiab.controller.redesign.ZimWishlist; +import org.iiab.controller.system.domain.ContentType; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class PendingOrdersRepositoryImpl implements PendingOrdersRepository { + + private final Context app; + + public PendingOrdersRepositoryImpl(Context ctx) { + this.app = ctx.getApplicationContext(); + } + + @Override + public List list() { + List out = new ArrayList<>(); + addZim(out); + addBooks(out); + addCourses(out); + Collections.sort(out, PendingOrder.DISPLAY_ORDER); + return out; + } + + @Override + public void cancel(PendingOrder order) { + if (order == null || order.type() == null || order.id() == null) { + return; + } + switch (order.type()) { + case ZIM: ZimWishlist.remove(app, order.id()); break; + case BOOKS: BooksWishlist.remove(app, order.id()); break; + case COURSES: KolibriWishlist.remove(app, order.id()); break; + default: break; // MAPS is out of scope: it keeps its own cancel path. + } + } + + /** ZIM order = {@code {key, bytes}}; the key ("project|lang|flavour") is both id + * and, absent a friendlier catalog name here, the display name. */ + private void addZim(List out) { + JSONArray a = ZimWishlist.all(app); + for (int i = 0; i < a.length(); i++) { + JSONObject o = a.optJSONObject(i); + if (o == null) continue; + String key = o.optString("key", ""); + if (key.isEmpty()) continue; + out.add(new PendingOrder(ContentType.ZIM, key, key, o.optLong("bytes", 0L))); + } + } + + /** Books order = {@code {id, title, url}}; no size is stored, so bytes is 0 (unknown). */ + private void addBooks(List out) { + JSONArray a = BooksWishlist.all(app); + for (int i = 0; i < a.length(); i++) { + JSONObject o = a.optJSONObject(i); + if (o == null) continue; + String id = o.optString("id", ""); + if (id.isEmpty()) continue; + String title = o.optString("title", ""); + out.add(new PendingOrder(ContentType.BOOKS, id, title.isEmpty() ? id : title, 0L)); + } + } + + /** Courses order = {@code {channelId, version, name, bytes}}. */ + private void addCourses(List out) { + JSONArray a = KolibriWishlist.all(app); + for (int i = 0; i < a.length(); i++) { + JSONObject o = a.optJSONObject(i); + if (o == null) continue; + String id = o.optString("channelId", ""); + if (id.isEmpty()) continue; + String name = o.optString("name", ""); + out.add(new PendingOrder(ContentType.COURSES, id, name.isEmpty() ? id : name, o.optLong("bytes", 0L))); + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/domain/PendingOrder.java b/controller/app/src/main/java/org/iiab/controller/pending/domain/PendingOrder.java new file mode 100644 index 000000000..74fd56c93 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/domain/PendingOrder.java @@ -0,0 +1,66 @@ +/* + * ============================================================================ + * Name : PendingOrder.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : One queued (banked) content order — a single ZIM collection, + * book, or course channel the user asked for that has not been + * drained yet. Pure JVM domain entity (ADFA-5169, finding 6). + * ============================================================================ + */ +package org.iiab.controller.pending.domain; + +import org.iiab.controller.system.domain.ContentType; + +import java.util.Comparator; + +/** + * A single queued content order. + * + *

Immutable value object. {@code id} is the wishlist key used to cancel this one + * order (a ZIM file id, a book id, a Kolibri channel id); {@code name} is what the + * user reads; {@code bytes} is its size, {@code <= 0} when unknown. + * + *

Pure domain type: no Android. What each type is and how it runs lives in + * {@link ContentType}; how the orders are stored lives in the data layer. + */ +public final class PendingOrder { + + private final ContentType type; + private final String id; + private final String name; + private final long bytes; + + public PendingOrder(ContentType type, String id, String name, long bytes) { + this.type = type; + this.id = id; + this.name = name; + this.bytes = bytes; + } + + public ContentType type() { + return type; + } + + public String id() { + return id; + } + + public String name() { + return name; + } + + public long bytes() { + return bytes; + } + + /** + * Stable display order for the pending list: grouped by content type (the enum's + * own order — ZIM, Books, Courses), then by name (case-insensitive), then by id + * so ties never reorder between reads. Null names sort as empty and never throw. + */ + public static final Comparator DISPLAY_ORDER = + Comparator.comparingInt((PendingOrder o) -> o.type == null ? Integer.MAX_VALUE : o.type.ordinal()) + .thenComparing(o -> o.name == null ? "" : o.name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(o -> o.id == null ? "" : o.id); +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/domain/PendingOrdersRepository.java b/controller/app/src/main/java/org/iiab/controller/pending/domain/PendingOrdersRepository.java new file mode 100644 index 000000000..7b9a37000 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/domain/PendingOrdersRepository.java @@ -0,0 +1,29 @@ +/* + * ============================================================================ + * Name : PendingOrdersRepository.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain port for listing and cancelling queued content orders. + * The data layer maps each type to its wishlist (ADFA-5169). + * ============================================================================ + */ +package org.iiab.controller.pending.domain; + +import java.util.List; + +/** + * The abstraction the domain owns for queued content orders; the data layer provides + * the implementation. The domain never learns where the orders are stored. + * + *

Implementations must never throw: {@link #list()} returns an empty list when + * nothing is queued (or a wishlist cannot be read), and {@link #cancel} is a no-op + * when the order is already gone. + */ +public interface PendingOrdersRepository { + + /** Every queued content order across the live content types, or empty. */ + List list(); + + /** Removes one queued order from its wishlist. The rest are untouched. */ + void cancel(PendingOrder order); +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersFragment.java b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersFragment.java new file mode 100644 index 000000000..432f8cacc --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersFragment.java @@ -0,0 +1,158 @@ +/* + * ============================================================================ + * Name : PendingOrdersFragment.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : The "Pending downloads" screen (ADFA-5169, finding 6). Lists each + * queued content order (ZIM / Books / Courses) by item with its own + * Cancel; shows an empty state; and, when a download is running, a + * link to the live index. Observes PendingOrdersViewModel — it does + * not read or format the wishlists itself. Reuses the settings + * sub-screen chrome (title + back + scroll list). + * ============================================================================ + */ +package org.iiab.controller.pending.presentation; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; + +import org.iiab.controller.R; +import org.iiab.controller.pending.domain.PendingOrder; +import org.iiab.controller.redesign.SetupProgressActivity; +import org.iiab.controller.util.ByteFormatter; + +public class PendingOrdersFragment extends Fragment { + + private PendingOrdersViewModel vm; + private LinearLayout list; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c, @Nullable Bundle s) { + View root = inflater.inflate(R.layout.fragment_k2go_settings_sub, c, false); + ((TextView) root.findViewById(R.id.k2go_sub_title)).setText(getString(R.string.k2go_pending_title)); + root.findViewById(R.id.k2go_sub_back).setOnClickListener(v -> + requireActivity().getSupportFragmentManager().popBackStack()); + list = root.findViewById(R.id.k2go_sub_list); + + vm = new ViewModelProvider(this, new PendingOrdersViewModelFactory(requireContext())) + .get(PendingOrdersViewModel.class); + vm.state().observe(getViewLifecycleOwner(), this::render); + return root; + } + + @Override + public void onResume() { + super.onResume(); + if (vm != null) vm.refresh(); // a cancel elsewhere, or a drain, may have changed the list + } + + private void render(PendingOrdersUiState st) { + if (list == null || st == null || st.loading) return; + Context ctx = requireContext(); + list.removeAllViews(); + + if (st.somethingRunning) { + list.addView(runningBanner(ctx)); + } + if (st.isEmpty()) { + list.addView(emptyText(ctx)); + return; + } + for (PendingOrder order : st.orders) { + list.addView(orderRow(ctx, order)); + } + } + + private TextView emptyText(Context ctx) { + TextView t = new TextView(ctx); + t.setText(getString(R.string.k2go_pending_empty)); + t.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); + t.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted)); + t.setGravity(Gravity.CENTER_HORIZONTAL); + int pad = dp(ctx, 24); + t.setPadding(pad, pad, pad, pad); + return t; + } + + private View runningBanner(Context ctx) { + LinearLayout row = new LinearLayout(ctx); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + int padH = dp(ctx, 16), padV = dp(ctx, 12); + row.setPadding(padH, padV, padH, padV); + + TextView label = new TextView(ctx); + label.setText(getString(R.string.k2go_pending_running)); + label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); + label.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_ink)); + label.setLayoutParams(new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(label); + + TextView link = new TextView(ctx); + link.setText(getString(R.string.k2go_pending_see_progress)); + link.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium); + link.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_ink)); + link.setOnClickListener(v -> startActivity(new Intent(ctx, SetupProgressActivity.class))); + row.addView(link); + return row; + } + + private View orderRow(Context ctx, PendingOrder order) { + LinearLayout row = new LinearLayout(ctx); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + int padH = dp(ctx, 16), padV = dp(ctx, 12); + row.setPadding(padH, padV, padH, padV); + + LinearLayout textCol = new LinearLayout(ctx); + textCol.setOrientation(LinearLayout.VERTICAL); + textCol.setLayoutParams(new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + TextView name = new TextView(ctx); + name.setText(order.name() == null ? order.id() : order.name()); + name.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium); + name.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_ink)); + textCol.addView(name); + + if (order.bytes() > 0L) { + TextView size = new TextView(ctx); + size.setText(ByteFormatter.toHuman(order.bytes())); + size.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall); + size.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted)); + textCol.addView(size); + } + row.addView(textCol); + + // ADFA-5027 homologation: the official M3 delete (trash) icon in the danger colour, as the + // maps (FqrController) and Kiwix (KiwixManageController) per-row deletes use — not a "✕" glyph. + ImageView cancel = new ImageView(ctx); + cancel.setImageResource(R.drawable.ic_delete_24); + cancel.setColorFilter(ContextCompat.getColor(ctx, R.color.k2go_clay)); + cancel.setContentDescription(getString(R.string.k2go_cancel)); + int pad = dp(ctx, 8), tap = dp(ctx, 44); + cancel.setPadding(pad, pad, pad, pad); + cancel.setLayoutParams(new LinearLayout.LayoutParams(tap, tap)); + cancel.setOnClickListener(v -> vm.cancel(order)); + row.addView(cancel); + return row; + } + + private static int dp(Context ctx, int v) { + return Math.round(v * ctx.getResources().getDisplayMetrics().density); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersUiState.java b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersUiState.java new file mode 100644 index 000000000..67c052af1 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersUiState.java @@ -0,0 +1,42 @@ +/* + * ============================================================================ + * Name : PendingOrdersUiState.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Immutable view state for the Pending downloads screen (ADFA-5169): + * the queued orders to show, and whether a download is running now + * (so the screen can offer a link to the live index). + * ============================================================================ + */ +package org.iiab.controller.pending.presentation; + +import org.iiab.controller.pending.domain.PendingOrder; + +import java.util.Collections; +import java.util.List; + +public final class PendingOrdersUiState { + + public final boolean loading; + public final List orders; + public final boolean somethingRunning; + + private PendingOrdersUiState(boolean loading, List orders, boolean somethingRunning) { + this.loading = loading; + this.orders = orders; + this.somethingRunning = somethingRunning; + } + + public static PendingOrdersUiState loading() { + return new PendingOrdersUiState(true, Collections.emptyList(), false); + } + + public static PendingOrdersUiState loaded(List orders, boolean somethingRunning) { + return new PendingOrdersUiState(false, + orders == null ? Collections.emptyList() : orders, somethingRunning); + } + + public boolean isEmpty() { + return orders.isEmpty(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersViewModel.java b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersViewModel.java new file mode 100644 index 000000000..94dce5e0b --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersViewModel.java @@ -0,0 +1,69 @@ +/* + * ============================================================================ + * Name : PendingOrdersViewModel.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Presentation ViewModel for the Pending downloads screen (ADFA-5169). + * Reads the queued orders off the main thread and exposes a + * PendingOrdersUiState stream; cancel() removes one order and reloads. + * Cancel is user-driven only — nothing is cancelled automatically. + * ============================================================================ + */ +package org.iiab.controller.pending.presentation; + +import android.content.Context; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.iiab.controller.pending.domain.PendingOrder; +import org.iiab.controller.pending.domain.PendingOrdersRepository; +import org.iiab.controller.system.data.PendingContent; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PendingOrdersViewModel extends ViewModel { + + private final PendingOrdersRepository repository; + private final Context app; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final MutableLiveData state = + new MutableLiveData<>(PendingOrdersUiState.loading()); + + public PendingOrdersViewModel(PendingOrdersRepository repository, Context appContext) { + this.repository = repository; + this.app = appContext.getApplicationContext(); + } + + public LiveData state() { + return state; + } + + /** Re-reads the queued orders (and whether a download is running now). */ + public void refresh() { + state.postValue(PendingOrdersUiState.loading()); + executor.execute(this::readAndPost); + } + + /** Removes one queued order, then reloads. No automatic cancellation anywhere. */ + public void cancel(PendingOrder order) { + executor.execute(() -> { + repository.cancel(order); + readAndPost(); + }); + } + + private void readAndPost() { + List orders = repository.list(); + boolean running = PendingContent.anyRunning(app); + state.postValue(PendingOrdersUiState.loaded(orders, running)); + } + + @Override + protected void onCleared() { + executor.shutdownNow(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersViewModelFactory.java b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersViewModelFactory.java new file mode 100644 index 000000000..7423be9a4 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/pending/presentation/PendingOrdersViewModelFactory.java @@ -0,0 +1,39 @@ +/* + * ============================================================================ + * Name : PendingOrdersViewModelFactory.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Manual dependency wiring for PendingOrdersViewModel (ADFA-5169): + * composes data -> domain -> presentation by hand, no DI framework. + * ============================================================================ + */ +package org.iiab.controller.pending.presentation; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.iiab.controller.pending.data.PendingOrdersRepositoryImpl; +import org.iiab.controller.pending.domain.PendingOrdersRepository; + +public class PendingOrdersViewModelFactory implements ViewModelProvider.Factory { + + private final Context ctx; + + public PendingOrdersViewModelFactory(Context ctx) { + this.ctx = ctx.getApplicationContext(); + } + + @NonNull + @Override + @SuppressWarnings("unchecked") + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(PendingOrdersViewModel.class)) { + PendingOrdersRepository repository = new PendingOrdersRepositoryImpl(ctx); + return (T) new PendingOrdersViewModel(repository, ctx); + } + throw new IllegalArgumentException("Unknown ViewModel class: " + modelClass.getName()); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SettingsFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/SettingsFragment.java index 60790bd45..ded450a5c 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SettingsFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SettingsFragment.java @@ -23,6 +23,9 @@ * open as sub-screens (bottom nav stays); Theme + Language + Send feedback are functional. */ public class SettingsFragment extends Fragment { + // ADFA-5169: kept so onResume can refresh its count when Settings becomes visible. + private View pendingRow; + @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c, @Nullable Bundle s) { @@ -43,6 +46,14 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c i.putExtra(SetupLibraryActivity.EXTRA_MODULE_MGMT, true); ctx.startActivity(i); }); + // ADFA-5169: always-visible entry to the queued content orders (finding 6). Shows a count, or + // "None" when empty. Opens as a settings sub-screen (bottom nav stays), like Language/About. + pendingRow = SettingsUi.row(ctx, list, getString(R.string.k2go_settings_pending), null, pendingValue(ctx), v -> { + if (getActivity() instanceof LibraryActivity) { + ((LibraryActivity) getActivity()).openSettingsSub( + new org.iiab.controller.pending.presentation.PendingOrdersFragment()); + } + }); SettingsUi.row(ctx, list, getString(R.string.k2go_settings_backups), getString(R.string.k2go_br_row_sub), null, v -> { android.content.Intent i = new android.content.Intent(ctx, SetupLibraryActivity.class); i.putExtra(SetupLibraryActivity.EXTRA_BACKUP_RESTORE, true); @@ -62,6 +73,28 @@ private void openSub(String screen) { } } + /** ADFA-5169: the right-aligned value for the Pending downloads row — a count, or "None". + * A cheap read (three SharedPreferences reads via the repository), safe on the main thread. */ + private String pendingValue(Context ctx) { + int n = new org.iiab.controller.pending.data.PendingOrdersRepositoryImpl(ctx).list().size(); + return n > 0 + ? getString(R.string.k2go_settings_pending_count, n) + : getString(R.string.k2go_settings_pending_none); + } + + @Override + public void onResume() { + super.onResume(); + refreshPendingValue(); // keep the count current on tab entry and on return from the screen + } + + /** ADFA-5169: update the pending row's value in place — its children are [column, value, chevron]. */ + private void refreshPendingValue() { + if (!(pendingRow instanceof ViewGroup) || getContext() == null) return; + View v = ((ViewGroup) pendingRow).getChildAt(1); + if (v instanceof TextView) ((TextView) v).setText(pendingValue(requireContext())); + } + private void openFeedback() { if (getActivity() instanceof LibraryActivity) { ((LibraryActivity) getActivity()).openSettingsSub(new FeedbackFragment()); diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java b/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java index 8ece64d24..bdb795f4c 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ZimWishlist.java @@ -63,6 +63,19 @@ public static void add(Context ctx, Map cart) { prefs(ctx).edit().putString(KEY, out.toString()).apply(); } + /** ADFA-5169: drop one order by its "project|lang|flavour" key; the rest stay. + * No-op if the key is absent. Mirrors BooksWishlist.remove / KolibriWishlist.remove. */ + public static void remove(Context ctx, String key) { + if (key == null) return; + JSONArray cur = all(ctx); + JSONArray out = new JSONArray(); + for (int i = 0; i < cur.length(); i++) { + JSONObject o = cur.optJSONObject(i); + if (o != null && !key.equals(o.optString("key"))) out.put(o); + } + prefs(ctx).edit().putString(KEY, out.toString()).apply(); + } + public static void clear(Context ctx) { prefs(ctx).edit().remove(KEY).apply(); } diff --git a/controller/app/src/main/res/values-ar/strings_k2go.xml b/controller/app/src/main/res/values-ar/strings_k2go.xml index 22484f705..1fbd9e59f 100644 --- a/controller/app/src/main/res/values-ar/strings_k2go.xml +++ b/controller/app/src/main/res/values-ar/strings_k2go.xml @@ -816,4 +816,11 @@ استعادة قيد التنفيذ. يُرجى الانتظار حتى تنتهي العملية. تثبيت قيد التنفيذ. يُرجى الانتظار حتى تنتهي العملية. تنزيل قيد التنفيذ. يُرجى الانتظار حتى تنتهي العملية. + التنزيلات المعلقة + لا توجد تنزيلات معلقة. + يجري تنزيل شيء الآن + عرض التقدم + التنزيلات المعلقة + %d في قائمة الانتظار + لا شيء diff --git a/controller/app/src/main/res/values-az/strings_k2go.xml b/controller/app/src/main/res/values-az/strings_k2go.xml index 380263552..cfac39ce1 100644 --- a/controller/app/src/main/res/values-az/strings_k2go.xml +++ b/controller/app/src/main/res/values-az/strings_k2go.xml @@ -816,4 +816,11 @@ Bərpa davam edir. Zəhmət olmasa, əməliyyatın bitməsini gözləyin. Quraşdırma davam edir. Zəhmət olmasa, əməliyyatın bitməsini gözləyin. Yükləmə davam edir. Zəhmət olmasa, əməliyyatın bitməsini gözləyin. + Gözləyən yükləmələr + Gözləyən yükləmə yoxdur. + İndi nəsə yüklənir + Gedişata bax + Gözləyən yükləmələr + %d növbədə + Yoxdur diff --git a/controller/app/src/main/res/values-bg/strings_k2go.xml b/controller/app/src/main/res/values-bg/strings_k2go.xml index 0e250882e..2368c7b00 100644 --- a/controller/app/src/main/res/values-bg/strings_k2go.xml +++ b/controller/app/src/main/res/values-bg/strings_k2go.xml @@ -816,4 +816,11 @@ Възстановяване е в ход. Изчакайте операцията да приключи. Инсталиране е в ход. Изчакайте операцията да приключи. Изтегляне е в ход. Изчакайте операцията да приключи. + Чакащи изтегляния + Няма чакащи изтегляния. + В момента се изтегля нещо + Преглед на напредъка + Чакащи изтегляния + %d в опашка + Няма diff --git a/controller/app/src/main/res/values-bn/strings_k2go.xml b/controller/app/src/main/res/values-bn/strings_k2go.xml index 5a3581e4b..5c51ffd96 100644 --- a/controller/app/src/main/res/values-bn/strings_k2go.xml +++ b/controller/app/src/main/res/values-bn/strings_k2go.xml @@ -816,4 +816,11 @@ পুনরুদ্ধার চলছে। অনুগ্রহ করে অপারেশন শেষ হওয়া পর্যন্ত অপেক্ষা করুন। ইনস্টল চলছে। অনুগ্রহ করে অপারেশন শেষ হওয়া পর্যন্ত অপেক্ষা করুন। ডাউনলোড চলছে। অনুগ্রহ করে অপারেশন শেষ হওয়া পর্যন্ত অপেক্ষা করুন। + মুলতুবি ডাউনলোড + কোনো মুলতুবি ডাউনলোড নেই। + এখন কিছু ডাউনলোড হচ্ছে + অগ্রগতি দেখুন + মুলতুবি ডাউনলোড + সারিতে %d + কোনোটিই নয় diff --git a/controller/app/src/main/res/values-cs/strings_k2go.xml b/controller/app/src/main/res/values-cs/strings_k2go.xml index f04a4786a..a277c8881 100644 --- a/controller/app/src/main/res/values-cs/strings_k2go.xml +++ b/controller/app/src/main/res/values-cs/strings_k2go.xml @@ -816,4 +816,11 @@ Obnovení probíhá. Počkejte na dokončení operace. Instalace probíhá. Počkejte na dokončení operace. Stahování probíhá. Počkejte na dokončení operace. + Čekající stahování + Žádná čekající stahování. + Něco se právě stahuje + Zobrazit průběh + Čekající stahování + %d ve frontě + Žádné diff --git a/controller/app/src/main/res/values-de/strings_k2go.xml b/controller/app/src/main/res/values-de/strings_k2go.xml index 740d1c5c0..9a49d5bed 100644 --- a/controller/app/src/main/res/values-de/strings_k2go.xml +++ b/controller/app/src/main/res/values-de/strings_k2go.xml @@ -817,4 +817,11 @@ Eine Wiederherstellung läuft gerade. Bitte warten, bis der Vorgang abgeschlossen ist. Eine Installation läuft gerade. Bitte warten, bis der Vorgang abgeschlossen ist. Ein Download läuft gerade. Bitte warten, bis der Vorgang abgeschlossen ist. + Ausstehende Downloads + Keine ausstehenden Downloads. + Es wird gerade etwas heruntergeladen + Fortschritt anzeigen + Ausstehende Downloads + %d in Warteschlange + Keine diff --git a/controller/app/src/main/res/values-el/strings_k2go.xml b/controller/app/src/main/res/values-el/strings_k2go.xml index 7d03eaa7c..d1e4898e2 100644 --- a/controller/app/src/main/res/values-el/strings_k2go.xml +++ b/controller/app/src/main/res/values-el/strings_k2go.xml @@ -817,4 +817,11 @@ Μια επαναφορά βρίσκεται σε εξέλιξη. Περιμένετε να ολοκληρωθεί η λειτουργία. Μια εγκατάσταση βρίσκεται σε εξέλιξη. Περιμένετε να ολοκληρωθεί η λειτουργία. Μια λήψη βρίσκεται σε εξέλιξη. Περιμένετε να ολοκληρωθεί η λειτουργία. + Εκκρεμείς λήψεις + Δεν υπάρχουν εκκρεμείς λήψεις. + Γίνεται λήψη τώρα + Προβολή προόδου + Εκκρεμείς λήψεις + %d σε αναμονή + Καμία diff --git a/controller/app/src/main/res/values-es/strings_k2go.xml b/controller/app/src/main/res/values-es/strings_k2go.xml index f17249e1e..384e3b33f 100644 --- a/controller/app/src/main/res/values-es/strings_k2go.xml +++ b/controller/app/src/main/res/values-es/strings_k2go.xml @@ -816,4 +816,11 @@ Una restauración está en curso. Espera a que termine la operación. Una instalación está en curso. Espera a que termine la operación. Una descarga está en curso. Espera a que termine la operación. + Descargas pendientes + No hay descargas pendientes. + Se está descargando algo ahora + Ver progreso + Descargas pendientes + %d en cola + Ninguna diff --git a/controller/app/src/main/res/values-fa/strings_k2go.xml b/controller/app/src/main/res/values-fa/strings_k2go.xml index d30e9fbbe..cf2826363 100644 --- a/controller/app/src/main/res/values-fa/strings_k2go.xml +++ b/controller/app/src/main/res/values-fa/strings_k2go.xml @@ -816,4 +816,11 @@ بازیابی در حال انجام است. لطفاً تا پایان عملیات صبر کنید. نصب در حال انجام است. لطفاً تا پایان عملیات صبر کنید. دانلود در حال انجام است. لطفاً تا پایان عملیات صبر کنید. + دانلودهای در انتظار + دانلودی در انتظار نیست. + چیزی در حال دانلود است + مشاهده پیشرفت + دانلودهای در انتظار + %d در صف + هیچ diff --git a/controller/app/src/main/res/values-fr/strings_k2go.xml b/controller/app/src/main/res/values-fr/strings_k2go.xml index 24497fa5e..e80345f3d 100644 --- a/controller/app/src/main/res/values-fr/strings_k2go.xml +++ b/controller/app/src/main/res/values-fr/strings_k2go.xml @@ -826,4 +826,11 @@ Une restauration est en cours. Veuillez attendre la fin de l\'opération. Une installation est en cours. Veuillez attendre la fin de l\'opération. Un téléchargement est en cours. Veuillez attendre la fin de l\'opération. + Téléchargements en attente + Aucun téléchargement en attente. + Un téléchargement est en cours + Voir la progression + Téléchargements en attente + %d en file d\'attente + Aucun diff --git a/controller/app/src/main/res/values-gu/strings_k2go.xml b/controller/app/src/main/res/values-gu/strings_k2go.xml index e7d31a2c0..c4a12c4a8 100644 --- a/controller/app/src/main/res/values-gu/strings_k2go.xml +++ b/controller/app/src/main/res/values-gu/strings_k2go.xml @@ -816,4 +816,11 @@ પુનઃસ્થાપન ચાલી રહ્યું છે. કૃપા કરીને ક્રિયા પૂરી થાય ત્યાં સુધી રાહ જુઓ. ઇન્સ્ટૉલ ચાલી રહ્યું છે. કૃપા કરીને ક્રિયા પૂરી થાય ત્યાં સુધી રાહ જુઓ. ડાઉનલોડ ચાલી રહ્યું છે. કૃપા કરીને ક્રિયા પૂરી થાય ત્યાં સુધી રાહ જુઓ. + બાકી ડાઉનલોડ્સ + કોઈ બાકી ડાઉનલોડ નથી. + અત્યારે કંઈક ડાઉનલોડ થઈ રહ્યું છે + પ્રગતિ જુઓ + બાકી ડાઉનલોડ્સ + %d કતારમાં + કોઈ નહીં diff --git a/controller/app/src/main/res/values-hi/strings_k2go.xml b/controller/app/src/main/res/values-hi/strings_k2go.xml index b4fd28c54..eed238944 100644 --- a/controller/app/src/main/res/values-hi/strings_k2go.xml +++ b/controller/app/src/main/res/values-hi/strings_k2go.xml @@ -816,4 +816,11 @@ रिस्टोर चल रहा है। कृपया कार्य पूरा होने तक प्रतीक्षा करें। इंस्टॉल चल रहा है। कृपया कार्य पूरा होने तक प्रतीक्षा करें। डाउनलोड चल रहा है। कृपया कार्य पूरा होने तक प्रतीक्षा करें। + लंबित डाउनलोड + कोई लंबित डाउनलोड नहीं। + अभी कुछ डाउनलोड हो रहा है + प्रगति देखें + लंबित डाउनलोड + %d कतार में + कोई नहीं diff --git a/controller/app/src/main/res/values-hu/strings_k2go.xml b/controller/app/src/main/res/values-hu/strings_k2go.xml index c9830ac7b..7528a7a02 100644 --- a/controller/app/src/main/res/values-hu/strings_k2go.xml +++ b/controller/app/src/main/res/values-hu/strings_k2go.xml @@ -816,4 +816,11 @@ Visszaállítás folyamatban van. Várja meg a művelet befejezését. Telepítés folyamatban van. Várja meg a művelet befejezését. Letöltés folyamatban van. Várja meg a művelet befejezését. + Függőben lévő letöltések + Nincs függőben lévő letöltés. + Most letöltődik valami + Folyamat megtekintése + Függőben lévő letöltések + %d a sorban + Nincs diff --git a/controller/app/src/main/res/values-in/strings_k2go.xml b/controller/app/src/main/res/values-in/strings_k2go.xml index 942b7c46e..70115b74c 100644 --- a/controller/app/src/main/res/values-in/strings_k2go.xml +++ b/controller/app/src/main/res/values-in/strings_k2go.xml @@ -816,4 +816,11 @@ Pemulihan sedang berlangsung. Harap tunggu hingga operasi selesai. Pemasangan sedang berlangsung. Harap tunggu hingga operasi selesai. Pengunduhan sedang berlangsung. Harap tunggu hingga operasi selesai. + Unduhan tertunda + Tidak ada unduhan tertunda. + Sesuatu sedang diunduh sekarang + Lihat progres + Unduhan tertunda + %d dalam antrean + Tidak ada diff --git a/controller/app/src/main/res/values-it/strings_k2go.xml b/controller/app/src/main/res/values-it/strings_k2go.xml index 281de6582..9446eb184 100644 --- a/controller/app/src/main/res/values-it/strings_k2go.xml +++ b/controller/app/src/main/res/values-it/strings_k2go.xml @@ -816,4 +816,11 @@ Un ripristino è in corso. Attendi il completamento dell\'operazione. Un\'installazione è in corso. Attendi il completamento dell\'operazione. Un download è in corso. Attendi il completamento dell\'operazione. + Download in sospeso + Nessun download in sospeso. + È in corso un download + Mostra avanzamento + Download in sospeso + %d in coda + Nessuno diff --git a/controller/app/src/main/res/values-ja/strings_k2go.xml b/controller/app/src/main/res/values-ja/strings_k2go.xml index 2fe913d9c..b8358c0a0 100644 --- a/controller/app/src/main/res/values-ja/strings_k2go.xml +++ b/controller/app/src/main/res/values-ja/strings_k2go.xml @@ -816,4 +816,11 @@ 復元が進行中です。操作が完了するまでお待ちください。 インストールが進行中です。操作が完了するまでお待ちください。 ダウンロードが進行中です。操作が完了するまでお待ちください。 + 保留中のダウンロード + 保留中のダウンロードはありません。 + 現在ダウンロード中です + 進行状況を表示 + 保留中のダウンロード + %d 件待機中 + なし diff --git a/controller/app/src/main/res/values-ko/strings_k2go.xml b/controller/app/src/main/res/values-ko/strings_k2go.xml index a354223f9..e795dc71a 100644 --- a/controller/app/src/main/res/values-ko/strings_k2go.xml +++ b/controller/app/src/main/res/values-ko/strings_k2go.xml @@ -816,4 +816,11 @@ 복원이(가) 진행 중입니다. 작업이 완료될 때까지 기다려 주세요. 설치이(가) 진행 중입니다. 작업이 완료될 때까지 기다려 주세요. 다운로드이(가) 진행 중입니다. 작업이 완료될 때까지 기다려 주세요. + 대기 중인 다운로드 + 대기 중인 다운로드가 없습니다. + 지금 다운로드 중입니다 + 진행 상황 보기 + 대기 중인 다운로드 + %d개 대기 중 + 없음 diff --git a/controller/app/src/main/res/values-lt/strings_k2go.xml b/controller/app/src/main/res/values-lt/strings_k2go.xml index 342ba3145..e5b6ad03a 100644 --- a/controller/app/src/main/res/values-lt/strings_k2go.xml +++ b/controller/app/src/main/res/values-lt/strings_k2go.xml @@ -826,4 +826,11 @@ Atkūrimas vyksta. Palaukite, kol operacija bus baigta. Diegimas vyksta. Palaukite, kol operacija bus baigta. Atsisiuntimas vyksta. Palaukite, kol operacija bus baigta. + Laukiantys atsisiuntimai + Nėra laukiančių atsisiuntimų. + Šiuo metu kažkas atsisiunčiama + Peržiūrėti eigą + Laukiantys atsisiuntimai + %d eilėje + Nėra diff --git a/controller/app/src/main/res/values-nl/strings_k2go.xml b/controller/app/src/main/res/values-nl/strings_k2go.xml index 7ec931306..9a27a5d96 100644 --- a/controller/app/src/main/res/values-nl/strings_k2go.xml +++ b/controller/app/src/main/res/values-nl/strings_k2go.xml @@ -816,4 +816,11 @@ Een herstel is bezig. Wacht tot de bewerking klaar is. Een installatie is bezig. Wacht tot de bewerking klaar is. Een download is bezig. Wacht tot de bewerking klaar is. + Downloads in wachtrij + Geen downloads in wachtrij. + Er wordt nu iets gedownload + Voortgang bekijken + Downloads in wachtrij + %d in wachtrij + Geen diff --git a/controller/app/src/main/res/values-no/strings_k2go.xml b/controller/app/src/main/res/values-no/strings_k2go.xml index e0f568a96..3b17513f7 100644 --- a/controller/app/src/main/res/values-no/strings_k2go.xml +++ b/controller/app/src/main/res/values-no/strings_k2go.xml @@ -826,4 +826,11 @@ En gjenoppretting pågår. Vent til operasjonen er fullført. En installasjon pågår. Vent til operasjonen er fullført. En nedlasting pågår. Vent til operasjonen er fullført. + Ventende nedlastinger + Ingen ventende nedlastinger. + Noe lastes ned nå + Se fremdrift + Ventende nedlastinger + %d i kø + Ingen diff --git a/controller/app/src/main/res/values-pl/strings_k2go.xml b/controller/app/src/main/res/values-pl/strings_k2go.xml index e2bfb3afc..caaeb6401 100644 --- a/controller/app/src/main/res/values-pl/strings_k2go.xml +++ b/controller/app/src/main/res/values-pl/strings_k2go.xml @@ -826,4 +826,11 @@ Przywracanie jest w toku. Poczekaj na zakończenie operacji. Instalacja jest w toku. Poczekaj na zakończenie operacji. Pobieranie jest w toku. Poczekaj na zakończenie operacji. + Oczekujące pobierania + Brak oczekujących pobierań. + Coś jest teraz pobierane + Zobacz postęp + Oczekujące pobierania + %d w kolejce + Brak diff --git a/controller/app/src/main/res/values-pt/strings_k2go.xml b/controller/app/src/main/res/values-pt/strings_k2go.xml index fd45bb32f..d63c7c088 100644 --- a/controller/app/src/main/res/values-pt/strings_k2go.xml +++ b/controller/app/src/main/res/values-pt/strings_k2go.xml @@ -816,4 +816,11 @@ Uma restauração está em andamento. Aguarde a conclusão da operação. Uma instalação está em andamento. Aguarde a conclusão da operação. Uma transferência está em andamento. Aguarde a conclusão da operação. + Downloads pendentes + Nenhum download pendente. + Algo está sendo baixado agora + Ver progresso + Downloads pendentes + %d na fila + Nenhum diff --git a/controller/app/src/main/res/values-ro/strings_k2go.xml b/controller/app/src/main/res/values-ro/strings_k2go.xml index b281fa2b7..7b83f16af 100644 --- a/controller/app/src/main/res/values-ro/strings_k2go.xml +++ b/controller/app/src/main/res/values-ro/strings_k2go.xml @@ -816,4 +816,11 @@ O restaurare este în curs. Așteptați finalizarea operațiunii. O instalare este în curs. Așteptați finalizarea operațiunii. O descărcare este în curs. Așteptați finalizarea operațiunii. + Descărcări în așteptare + Nicio descărcare în așteptare. + Se descarcă ceva acum + Vezi progresul + Descărcări în așteptare + %d în coadă + Niciuna diff --git a/controller/app/src/main/res/values-ru-rRU/strings_k2go.xml b/controller/app/src/main/res/values-ru-rRU/strings_k2go.xml index 59f7202eb..63c281c66 100644 --- a/controller/app/src/main/res/values-ru-rRU/strings_k2go.xml +++ b/controller/app/src/main/res/values-ru-rRU/strings_k2go.xml @@ -816,4 +816,11 @@ Восстановление выполняется. Дождитесь завершения операции. Установка выполняется. Дождитесь завершения операции. Загрузка выполняется. Дождитесь завершения операции. + Ожидающие загрузки + Нет ожидающих загрузок. + Сейчас что-то загружается + Показать ход + Ожидающие загрузки + %d в очереди + Нет diff --git a/controller/app/src/main/res/values-sk/strings_k2go.xml b/controller/app/src/main/res/values-sk/strings_k2go.xml index c6de7a9dd..6b4cf25e7 100644 --- a/controller/app/src/main/res/values-sk/strings_k2go.xml +++ b/controller/app/src/main/res/values-sk/strings_k2go.xml @@ -816,4 +816,11 @@ Obnovenie prebieha. Počkajte na dokončenie operácie. Inštalácia prebieha. Počkajte na dokončenie operácie. Sťahovanie prebieha. Počkajte na dokončenie operácie. + Čakajúce sťahovania + Žiadne čakajúce sťahovania. + Práve sa niečo sťahuje + Zobraziť priebeh + Čakajúce sťahovania + %d v poradí + Žiadne diff --git a/controller/app/src/main/res/values-sr/strings_k2go.xml b/controller/app/src/main/res/values-sr/strings_k2go.xml index c52f0a2f2..64e5f9ad2 100644 --- a/controller/app/src/main/res/values-sr/strings_k2go.xml +++ b/controller/app/src/main/res/values-sr/strings_k2go.xml @@ -816,4 +816,11 @@ Враћање је у току. Сачекајте да се операција заврши. Инсталација је у току. Сачекајте да се операција заврши. Преузимање је у току. Сачекајте да се операција заврши. + Преузимања на чекању + Нема преузимања на чекању. + Нешто се сада преузима + Прикажи напредак + Преузимања на чекању + %d у реду + Нема diff --git a/controller/app/src/main/res/values-sw/strings_k2go.xml b/controller/app/src/main/res/values-sw/strings_k2go.xml index 4cd29b945..58a15270f 100644 --- a/controller/app/src/main/res/values-sw/strings_k2go.xml +++ b/controller/app/src/main/res/values-sw/strings_k2go.xml @@ -816,4 +816,11 @@ Kurejesha inaendelea. Tafadhali subiri operesheni ikamilike. Usakinishaji inaendelea. Tafadhali subiri operesheni ikamilike. Upakuaji inaendelea. Tafadhali subiri operesheni ikamilike. + Vipakuliwa vinavyosubiri + Hakuna vipakuliwa vinavyosubiri. + Kitu kinapakuliwa sasa + Ona maendeleo + Vipakuliwa vinavyosubiri + %d kwenye foleni + Hakuna diff --git a/controller/app/src/main/res/values-ta/strings_k2go.xml b/controller/app/src/main/res/values-ta/strings_k2go.xml index 7aaa94673..18052391e 100644 --- a/controller/app/src/main/res/values-ta/strings_k2go.xml +++ b/controller/app/src/main/res/values-ta/strings_k2go.xml @@ -816,4 +816,11 @@ மீட்டமைப்பு நடைபெறுகிறது. செயல்பாடு முடியும் வரை காத்திருக்கவும். நிறுவல் நடைபெறுகிறது. செயல்பாடு முடியும் வரை காத்திருக்கவும். பதிவிறக்கம் நடைபெறுகிறது. செயல்பாடு முடியும் வரை காத்திருக்கவும். + நிலுவையிலுள்ள பதிவிறக்கங்கள் + நிலுவையில் பதிவிறக்கங்கள் இல்லை. + இப்போது ஏதோ பதிவிறக்கப்படுகிறது + முன்னேற்றத்தைக் காண்க + நிலுவையிலுள்ள பதிவிறக்கங்கள் + வரிசையில் %d + எதுவுமில்லை diff --git a/controller/app/src/main/res/values-tr/strings_k2go.xml b/controller/app/src/main/res/values-tr/strings_k2go.xml index 32ad8f709..c8d3b1c51 100644 --- a/controller/app/src/main/res/values-tr/strings_k2go.xml +++ b/controller/app/src/main/res/values-tr/strings_k2go.xml @@ -816,4 +816,11 @@ Geri yükleme sürüyor. Lütfen işlemin bitmesini bekleyin. Kurulum sürüyor. Lütfen işlemin bitmesini bekleyin. İndirme sürüyor. Lütfen işlemin bitmesini bekleyin. + Bekleyen indirmeler + Bekleyen indirme yok. + Şu anda bir şey indiriliyor + İlerlemeyi gör + Bekleyen indirmeler + Sırada %d + Yok diff --git a/controller/app/src/main/res/values-uk/strings_k2go.xml b/controller/app/src/main/res/values-uk/strings_k2go.xml index f3ee7a5b6..57e3573b9 100644 --- a/controller/app/src/main/res/values-uk/strings_k2go.xml +++ b/controller/app/src/main/res/values-uk/strings_k2go.xml @@ -816,4 +816,11 @@ Відновлення виконується. Зачекайте завершення операції. Встановлення виконується. Зачекайте завершення операції. Завантаження виконується. Зачекайте завершення операції. + Завантаження в черзі + Немає завантажень у черзі. + Зараз щось завантажується + Переглянути прогрес + Завантаження в черзі + %d у черзі + Немає diff --git a/controller/app/src/main/res/values-vi/strings_k2go.xml b/controller/app/src/main/res/values-vi/strings_k2go.xml index 54ca082c1..f551f416e 100644 --- a/controller/app/src/main/res/values-vi/strings_k2go.xml +++ b/controller/app/src/main/res/values-vi/strings_k2go.xml @@ -816,4 +816,11 @@ Một lần khôi phục đang diễn ra. Vui lòng đợi thao tác hoàn tất. Một lần cài đặt đang diễn ra. Vui lòng đợi thao tác hoàn tất. Một lượt tải xuống đang diễn ra. Vui lòng đợi thao tác hoàn tất. + Tải xuống đang chờ + Không có tải xuống đang chờ. + Đang tải xuống một mục + Xem tiến trình + Tải xuống đang chờ + %d trong hàng đợi + Không có diff --git a/controller/app/src/main/res/values-yo/strings_k2go.xml b/controller/app/src/main/res/values-yo/strings_k2go.xml index 4ab244e5a..aa0bcd00b 100644 --- a/controller/app/src/main/res/values-yo/strings_k2go.xml +++ b/controller/app/src/main/res/values-yo/strings_k2go.xml @@ -816,4 +816,11 @@ Ìmúpadàbọ̀ ń lọ lọ́wọ́. Jọ̀wọ́ dúró kí iṣẹ́ náà parí. Fifi-sórí-ẹrọ ń lọ lọ́wọ́. Jọ̀wọ́ dúró kí iṣẹ́ náà parí. Ìgbàsílẹ̀ ń lọ lọ́wọ́. Jọ̀wọ́ dúró kí iṣẹ́ náà parí. + Àwọn ìgbàsílẹ̀ tí ń dúró + Kò sí ìgbàsílẹ̀ tí ń dúró. + Ohun kan ń gbà sílẹ̀ báyìí + Wo ìtẹ̀síwájú + Àwọn ìgbàsílẹ̀ tí ń dúró + %d ní ìlà + Kò sí diff --git a/controller/app/src/main/res/values-zh-rCN/strings_k2go.xml b/controller/app/src/main/res/values-zh-rCN/strings_k2go.xml index 17fc6b9e9..455b6d753 100644 --- a/controller/app/src/main/res/values-zh-rCN/strings_k2go.xml +++ b/controller/app/src/main/res/values-zh-rCN/strings_k2go.xml @@ -816,4 +816,11 @@ 恢复正在进行中。请等待操作完成。 安装正在进行中。请等待操作完成。 下载正在进行中。请等待操作完成。 + 待处理的下载 + 没有待处理的下载。 + 正在下载 + 查看进度 + 待处理的下载 + %d 个排队中 + diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index 17aee4e06..cc0ed5473 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -899,4 +899,11 @@ Done Join Open + Pending downloads + No pending downloads. + Something is downloading now + See progress + Pending downloads + %d queued + None diff --git a/controller/app/src/test/java/org/iiab/controller/pending/domain/PendingOrderTest.java b/controller/app/src/test/java/org/iiab/controller/pending/domain/PendingOrderTest.java new file mode 100644 index 000000000..3d98a18a0 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/pending/domain/PendingOrderTest.java @@ -0,0 +1,59 @@ +/* + * ============================================================================ + * Name : PendingOrderTest.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : JVM unit tests for PendingOrder.DISPLAY_ORDER (ADFA-5169). + * ============================================================================ + */ +package org.iiab.controller.pending.domain; + +import static org.junit.Assert.assertEquals; + +import org.iiab.controller.system.domain.ContentType; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class PendingOrderTest { + + @Test + public void displayOrderGroupsByTypeThenName() { + PendingOrder zimEs = new PendingOrder(ContentType.ZIM, "z2", "Wikipedia (Spanish)", 3); + PendingOrder zimEn = new PendingOrder(ContentType.ZIM, "z1", "Wikipedia (English)", 4); + PendingOrder book = new PendingOrder(ContentType.BOOKS, "b1", "Gutenberg", 1); + PendingOrder course = new PendingOrder(ContentType.COURSES, "c1", "Khan Academy", 12); + + List orders = new ArrayList<>(Arrays.asList(course, book, zimEs, zimEn)); + orders.sort(PendingOrder.DISPLAY_ORDER); + + // ZIM group first (English before Spanish), then Books, then Courses. + assertEquals(Arrays.asList("z1", "z2", "b1", "c1"), + Arrays.asList(orders.get(0).id(), orders.get(1).id(), + orders.get(2).id(), orders.get(3).id())); + } + + @Test + public void nameIsCaseInsensitiveWithinAType() { + PendingOrder lower = new PendingOrder(ContentType.BOOKS, "b1", "atlas", 0); + PendingOrder upper = new PendingOrder(ContentType.BOOKS, "b2", "Beowulf", 0); + + List orders = new ArrayList<>(Arrays.asList(upper, lower)); + orders.sort(PendingOrder.DISPLAY_ORDER); + + assertEquals("b1", orders.get(0).id()); // "atlas" before "Beowulf" regardless of case + } + + @Test + public void nullNameSortsAsEmptyAndDoesNotThrow() { + PendingOrder noName = new PendingOrder(ContentType.ZIM, "z1", null, 0); + PendingOrder named = new PendingOrder(ContentType.ZIM, "z2", "B", 0); + + List orders = new ArrayList<>(Arrays.asList(named, noName)); + orders.sort(PendingOrder.DISPLAY_ORDER); // must not throw + + assertEquals("z1", orders.get(0).id()); // empty name sorts first + } +} diff --git a/controller/docs/ADFA-5169-pending-downloads-design.md b/controller/docs/ADFA-5169-pending-downloads-design.md new file mode 100644 index 000000000..52d28d599 --- /dev/null +++ b/controller/docs/ADFA-5169-pending-downloads-design.md @@ -0,0 +1,175 @@ +# ADFA-5169 — Pending downloads: see and cancel queued content orders + +State-spine finding 6 (DEAD CONTROL). Design doc. 2026-08-17. + +## Problem + +A content order (ZIM, Books, Courses) is *banked* when it has been placed but not +yet drained — the drain is deferred while the system is not ready to run it. In that +state two things fail today: + +- It is invisible. The Home header shows "See progress" only while something is + actually running (`PendingContent.anyRunning()`, sessions only). The per-card + action sheet's `isScheduled()` reads only the maps and module wishlists, never the + three content ones. +- It cannot be cancelled. The only cancel in the app is the module sheet's, and it + clears only the maps and module wishlists. + +So a queued ZIM/Books/Courses order sits with no way to see it and no way to back +out of it. + +## What a banked order actually is + +All banked orders are legitimate user requests. The real axis is not "banked vs +legitimate" but "can the current system still drain it?": + +- Healthy system: the Home pump drains on the next poll (~3s), so the order is + transient — or it waits legitimately behind another live download (streams + serialize). This waiting window is where visibility and manual cancel earn their + keep. +- No / damaged system: the order can't drain; the real problem is the system + (recover / install), the order is secondary. +- System replaced or lost: the order is orphaned and is already purged in bulk by + `PendingWork.clearAll` on setup / replacement / invalidation. + +Consequence for this design: never auto-cancel a legitimate order, and never purge +by age. Cancel is a manual "I changed my mind." Auto-purge stays limited to the +system-gone case, which already exists. + +## Scope (decided in brainstorming) + +In scope: + +- A **Settings row**, always visible ("Pending downloads"), showing a count when + there is something queued and "No pending downloads" when empty. It never pushes; + the user goes when they want. +- A minimal **Pending downloads** screen that lists each queued order **by item** + (this Wikipedia collection, this book, this course channel) with its size and its + own Cancel. Manual only. +- Cancel removes that one order; the rest stay. +- If a download is actually running, a link to the live index + (`SetupProgressActivity`). The pending screen itself stays about what is queued. +- Only the three live content types: **ZIM, Books, Courses**. + +Out of scope (explicit): + +- Maps and module orders — they already have a cancel path; leaving them avoids a + second place that cancels the same thing. +- Controls for *running* tasks (retry / pause / resume / cancel) — that is the + download-contract family (ADFA-4894 / 4896 / 4899, and the surface built with + ADFA-5119). +- The durable task registry and history — the larger, deliberately deferred design + this is a first slice of. +- Any change to auto-purge. + +## North star (not built here) + +The eventual model is two surfaces: an **index** = the live, self-cleaning view of +what is happening now (never shows anything stuck); and a **registry** = the durable +record of everything the index ever showed, plus the pending orders that need a +decision. This screen is the seed of the registry's "pending decision" bucket. The +invariant that keeps the two from blurring: nothing stuck ever lives in the index; +the moment a task can't proceed it belongs to the pending surface. Building the full +registry (a first-class Task entity, durable across process death — overlapping +ADFA-4897 / 4874) is a separate effort. + +## Design (layered, its own feature package) + +`org.iiab.controller.pending`, following the reference slice pattern. + +### domain/ (pure JVM, no Android) + +- `PendingOrder` — an entity: `ContentType type`, `String id`, `String name`, + `long bytes`. One queued order. +- `PendingOrdersRepository` — port: `List list()`, + `void cancel(PendingOrder order)`. + +### data/ + +- `PendingOrdersRepositoryImpl` — reads the three content wishlists (ZIM, Books, + Courses) into `PendingOrder`s and cancels via per-item removal. It is the single + place that maps a type to its wishlist for listing and cancel, sitting alongside + the knowledge `PendingContent` / `ContentType` already own — no second copy. +- One new wishlist method: `ZimWishlist.remove(id)`. Books (`remove(id)`) and Kolibri + (`remove(channelId)`) already have per-item removal; ZIM has only `clear` today. + +### presentation/ + +- `PendingOrdersViewModel` + `PendingOrdersUiState` + `PendingOrdersViewModelFactory`, + wired by hand (no DI). +- The screen (`PendingOrdersFragment` or Activity), Material 3: semantic colour + tokens, type scale, 4dp grid, `MaterialAlertDialog` / snackbar via + `SnackbarDuration`. It observes the ViewModel; it does not read or format the + wishlists itself. +- The Settings row — an additive edit to `SettingsFragment` / `SettingsUi`, the one + touch to a shared hotspot, kept minimal. The link to the live index reuses + `SetupProgressActivity`. + +### Data flow + +Open → ViewModel loads `list()` from the wishlists → render, or the empty state → +Cancel on a row → `cancel(order)` removes that item → reload → the row disappears, +with a snackbar. No automatic action anywhere. + +## Testing + +- Domain: `PendingOrder` and any pure rule (ordering / grouping) get JVM unit tests. +- Data: the wishlist read / per-item cancel is Android (SharedPreferences), verified + on device. +- New user-facing strings ship in all 33 locales in the same PR (a missing + translation fails the build). + +## Validation (and why the "natural" repro does not exist) + +The durably-banked state is not reachable through normal UX, which shapes how this is +validated. Confirmed in code: + +- Live mode (Get More, with a system): `SetupLibraryActivity.startZimDownload()` / + `startBooksDownload()` drain immediately (`…Provisioner.drain`), so content never + stays banked there — it starts at once (or moves into the download service). +- Wizard mode (pre-install): `zimWizardConfirm()` / `booksWizardConfirm()` bank the + selection without draining, but the wizard has no route to Settings, and completing + the install lets the Home pump drain the banked content. +- A systemless device opens the wizard, not the tabs, so Settings is unreachable there. + +So the states that keep an order banked (no system, or the in-server REST engine down +while nginx still answers) are incompatible with reaching the Settings screen that +shows it. The only durable case — rootfs present but the REST engine down, so the Home +pump's `apiReady()` gate never opens — requires stopping just that engine inside the +container and is not a user path. + +Validation is therefore: + +1. Automated, deterministic — an instrumented test (`PendingOrdersRepositoryImplTest`, + src/androidTest) seeds the three wishlists with real SharedPreferences and asserts + the banked mechanism: the orders are listed grouped by type with the right names, + the empty state, and per-item cancel (cancelling one removes only that order — + including a ZIM cancel leaving the other ZIM, which exercises the new + `ZimWishlist.remove`). Run with `./gradlew :app:connectedDebugAndroidTest`. +2. The UI end to end — the list, per-item cancel, nothing cancelled automatically, and + the Settings count refreshing on entry and on return — via a debug seed that writes + the same banked state (`DebugSeedPendingReceiver`, src/debug); stay off Home so the + pump does not drain it. Same kind of stand-in as the ADFA-5146 stale flag. +3. That real banking occurs through the UI is confirmed by selecting content in the + wizard and inspecting the wishlist: + `adb shell run-as org.iiab.controller cat shared_prefs/k2go_zim_wishlist.xml`. +4. The "See progress" link is validated with a real running download; the maps/module + cancel paths are checked directly for regressions. + +Not left pending: a continuous natural flow to a durably-banked order reached from +Settings does not exist by construction; it is covered by (1) + (2). + +## Seams / reuse + +- Reuse `ContentType` + `PendingContent`; no duplicated wishlist knowledge. +- One new low-level method: `ZimWishlist.remove(id)`. +- Settings row is additive. +- The live-index link reuses `SetupProgressActivity`. + +## Acceptance + +- A queued content order is visible when nothing is running. +- A queued ZIM, Books or Courses order can be cancelled from the UI, and cancelling + leaves the rest untouched and removes nothing it should not. +- Maps and module orders keep their current behavior. +- Nothing is cancelled or purged automatically by this feature. diff --git a/controller/docs/operation-model-roadmap.svg b/controller/docs/operation-model-roadmap.svg index 89221e88c..649f80ecd 100644 --- a/controller/docs/operation-model-roadmap.svg +++ b/controller/docs/operation-model-roadmap.svg @@ -432,7 +432,7 @@ The receive/clone dead-ends closed — and the debt the fixes exposed Landed and merged: ADFA-5143 (a clone in flight has a name and a way back), 5147 (a damaged system reads as damaged), 5150 (systemless Connect/Send route to Recover), 5151 (confine the receive loop to the recover hub), 5152 (a reopen lands on progress, not the fork), 5154 (Connect + Clone Send redesigned to stacked / 2-page QR), 5157 (shared QrSection), -5155 (receive success waits for services before Home, never a fixed timer). In flight: ADFA-5158 (get-app QR waits for the hotspot AP IP by polling, not a one-shot; broadened detection). +5155 (receive success waits for services before Home, never a fixed timer). Also landed: ADFA-5158 (get-app QR waits for the hotspot AP IP by polling, not a one-shot; broadened detection). FOLLOW-UPS WORTH A TICKET Shared "poll until a resource is ready" utility (a ServiceReadyGate). The same Handler-reschedule + in-flight guard + teardown now lives in three places — SetupProgressActivity.readyPoll, diff --git a/controller/docs/state-spine.svg b/controller/docs/state-spine.svg index a974ec7fe..29baedb06 100644 --- a/controller/docs/state-spine.svg +++ b/controller/docs/state-spine.svg @@ -40,7 +40,7 @@ System lifecycle — where a user gets stuck -A state is a combination of facts, never a screen. Verified against the working tree, 15 Aug — findings 4 and 7 closed since (ADFA-5147, ADFA-5150). Every edge cites the guard that allows it. +A state is a combination of facts, never a screen. Verified against the working tree, 17 Aug — all eight findings below are now closed (the last, 6, by ADFA-5169). Every edge cites the guard that allows it. LEGEND @@ -107,13 +107,13 @@ the header keeps its Install button. Gate is 900 ms here, not 25 s · :317 - -Install → refused + +Install → "needs a system" - -Dead control · module sheet -Install routes to the module hub, which -then refuses · ModuleHubFragment:233 + +Module sheet · now honest +ADFA-5104: no system → "needs a system", +not a silent refuse · residue: S1's Install Get more @@ -164,16 +164,16 @@ drain refused or deferred - -S12 · Order banked, nothing running + +S12 · Order banked — now surfaced anyBanked && !anyRunning -Looks exactly like S9. No "See -progress", no route to the index, -no list of what was ordered. -LibraryHomeFragment:518-529 -A banked ZIM, Books or Courses order -has no cancel anywhere: isScheduled() -only looks at maps and modules · :337 +Was: looked exactly like S9 — no +"See progress", no list, no cancel +for a ZIM/Books/Courses order. +org.iiab.controller.pending · PendingOrdersFragment +ADFA-5169: a persistent Settings › +Pending downloads lists every order +and cancels each by item, never auto. @@ -222,7 +222,7 @@ DEAD ENDS, WORST FIRST — traps, then one-way doors, then exits nobody can find, then controls that do nothing -Ranked by how hard the state is to escape, not by how ugly it looks. As of 17 Aug: findings 1, 2, 3, 4, 5, 7 and 8 are closed. One remains: 6, a banked order with no home and no owner yet. +Ranked by how hard the state is to escape, not by how ugly it looks. As of 17 Aug all eight findings are closed — 6, the last, by ADFA-5169. None remain. Findings 1–4 were one sentence four times: a fact nobody clears. 1, 3 and 4 were closed first; 2 — the release blocker, a row in the standing directive's ledger at the top of operation-model-roadmap.svg — is now closed too (ADFA-5146, 17 Aug). @@ -272,12 +272,12 @@ was already alive. A state with no exit is a bug whoever reaches it, including by a route that does not exist yet. LibraryHomeFragment:111 (the action), :252-275 (openGetMore — re-read + ownerHeld guard) · LibraryActivity:112-127 (the launch check) · ModuleHubFragment:232-243. - -6 · DEAD CONTROL — a banked order that is not running is invisible and, for three types, uncancellable -"See progress" appears only while something is actually running, and a refused or deferred drain leaves the order banked without running. Nothing lists it. The module -sheet's Cancel is the only cancel in the app, and it only inspects the maps and module wishlists — a banked ZIM, Books or Courses order cannot be cancelled from -anywhere in the UI. -LibraryHomeFragment.java:518-529 (anyRunning gates the button), :337-342 (isScheduled ignores three of the five wishlists). + +6 · CLOSED (17 Aug, ADFA-5169) — a queued order is now visible and cancellable from Settings +Was: "See progress" appeared only while something ran, and a refused or deferred drain left the order banked without running, listed nowhere; the module sheet's Cancel +inspected only the maps and module wishlists, so a banked ZIM, Books or Courses order could not be cancelled from anywhere in the UI. +Now a persistent Settings › Pending downloads screen lists every queued order by item and cancels each one; nothing is cancelled or purged automatically. +org.iiab.controller.pending (domain/data/presentation) · Settings row → PendingOrdersFragment · ZimWishlist.remove · reuses ContentType/PendingContent · instrumented test PendingOrdersRepositoryImplTest. 7 · CLOSED (ADFA-5150) — Connect and Clone Send read the system fact and offer Recover, not a dead QR