Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String, Long> 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<PendingOrder> 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<PendingOrder> 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<PendingOrder> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<PendingOrder> list() {
List<PendingOrder> 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<PendingOrder> 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<PendingOrder> 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<PendingOrder> 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)));
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<PendingOrder> 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);
}
Original file line number Diff line number Diff line change
@@ -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 <em>where</em> the orders are stored.
*
* <p>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<PendingOrder> list();

/** Removes one queued order from its wishlist. The rest are untouched. */
void cancel(PendingOrder order);
}
Loading
Loading