From ece4797007c80a2307acdff19744346bab40806b Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 03:14:51 +0100 Subject: [PATCH 1/2] Show the activity feed natively and open friends' notifications there Friends' wins, cheers, messages, replies, deck completions and challenge news are read from /api/activity, with filters, read state, cheers, replies, paging and a Sent tab. Share, reaction, completion, conquest, message and reply notifications, reply receipts and Today's Activity button open the new screen; a page for another account opens this account's feed. --- AnkiDroid/src/main/AndroidManifest.xml | 5 + .../ichi2/anki/ankiquest/AnkiquestActivity.kt | 10 +- .../com/ichi2/anki/ankiquest/AnkiquestFeed.kt | 275 ++++++ .../anki/ankiquest/AnkiquestFeedActivity.kt | 813 ++++++++++++++++++ .../anki/ankiquest/AnkiquestHomeActivity.kt | 8 +- .../anki/ankiquest/AnkiquestNavigation.kt | 7 +- .../ichi2/anki/ankiquest/AnkiquestNotifier.kt | 5 +- .../res/layout/activity_ankiquest_feed.xml | 72 ++ .../src/main/res/values-de/ankiquest_feed.xml | 57 ++ .../src/main/res/values-es/ankiquest_feed.xml | 57 ++ .../src/main/res/values-fr/ankiquest_feed.xml | 57 ++ .../src/main/res/values-pt/ankiquest_feed.xml | 57 ++ .../src/main/res/values/ankiquest_feed.xml | 57 ++ .../ankiquest/AnkiquestFeedActivityTest.kt | 257 ++++++ .../ankiquest/AnkiquestFeedScreenshotTest.kt | 56 ++ .../ichi2/anki/ankiquest/AnkiquestFeedTest.kt | 298 +++++++ .../ankiquest/AnkiquestHomeActivityTest.kt | 6 +- .../AnkiquestMobileNavigationTest.kt | 9 +- .../anki/ankiquest/AnkiquestNotifierTest.kt | 5 +- 19 files changed, 2090 insertions(+), 21 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeed.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivity.kt create mode 100644 AnkiDroid/src/main/res/layout/activity_ankiquest_feed.xml create mode 100644 AnkiDroid/src/main/res/values-de/ankiquest_feed.xml create mode 100644 AnkiDroid/src/main/res/values-es/ankiquest_feed.xml create mode 100644 AnkiDroid/src/main/res/values-fr/ankiquest_feed.xml create mode 100644 AnkiDroid/src/main/res/values-pt/ankiquest_feed.xml create mode 100644 AnkiDroid/src/main/res/values/ankiquest_feed.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivityTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedScreenshotTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedTest.kt diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index 7f80c26de14e..d34b551b6777 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -422,6 +422,11 @@ android:label="@string/ankiquest_screen_title" android:exported="false" /> + (R.id.content).showSnackbar(R.string.aq_home_account_route_changed) - } } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeed.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeed.kt new file mode 100644 index 000000000000..b168b1c10965 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeed.kt @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONArray +import org.json.JSONObject + +/** + * The activity feed: friends' wins, cheers, messages, replies, deck completions and challenge news + * from `GET /api/activity/{user}` (docs/mobile-api.md, "durable activity"), with the read state, + * cheers and replies that go with it. + */ +internal object AnkiquestFeed { + /** The filters the server knows, in the order the web feed shows them; [ALL] sends none. */ + enum class Category( + val query: String?, + ) { + ALL(null), + NEEDS_ACTION("needs_action"), + MESSAGES("messages"), + CHALLENGES("challenges"), + DECK_COMPLETIONS("deck_completions"), + STUDY_UPDATES("study_updates"), + } + + data class Item( + val id: Long, + val title: String, + val body: String, + /** Unix seconds. */ + val createdAt: Long, + /** Who the row is from, and so who a reply or a cheer goes to; empty for system rows. */ + val sender: String, + val replied: Boolean, + val kind: String, + /** Unix seconds, or null while unread. */ + val readAt: Long?, + val challengeId: Long?, + val actionRequired: Boolean, + /** The title and body of the message this row answers. */ + val replyTo: Pair?, + val image: String?, + val reacted: Boolean, + ) { + val unread: Boolean get() = readAt == null + + /** A friend's win or deck completion, which can be cheered once. */ + val cheerable: Boolean get() = sender.isNotEmpty() && (kind == "share" || kind == "completion") + + /** A row from a person that has no reply yet; challenge news is answered on the goal itself. */ + val answerable: Boolean get() = sender.isNotEmpty() && !replied && challengeId == null + + /** The heading: a cheer names what was cheered, everything else its title. */ + val heading: String get() = if (kind == "reaction" && body.isNotEmpty()) "$title: $body" else title + + /** The body under the heading; a cheer has already said it there. */ + val text: String? get() = body.takeIf { it.isNotEmpty() && kind != "reaction" } + } + + data class Page( + val items: List, + /** Invitations still waiting for an answer, shown first. */ + val attention: List, + val unread: Int, + val actionCount: Int, + val latestId: Long?, + val nextBefore: Long?, + /** From a server without the activity feed: the last seven days, with no read state or filters. */ + val legacy: Boolean = false, + /** The server ignored the filters asked for and answered the whole feed. */ + val unfiltered: Boolean = false, + ) + + data class Sent( + val recipient: String, + val item: Item, + ) + + data class SentPage( + val items: List, + val nextBefore: Long?, + ) + + const val PAGE_SIZE = 50 + + /** The most ids `POST /api/activity/{user}/read` accepts at once. */ + private const val READ_BATCH = 200 + + /** + * One page of [account]'s feed, newest first, starting before [before]. A server without the feed + * (`404`) answers the older notification list instead, and one that does not know a filter (`400`) + * the unfiltered feed, as the website does. + */ + suspend fun page( + account: HomeAccount, + category: Category = Category.ALL, + unreadOnly: Boolean = false, + before: Long? = null, + ): Page = + withContext(Dispatchers.IO) { + val filtered = category != Category.ALL || unreadOnly + try { + activity(account, category, unreadOnly, before) + } catch (e: AnkiquestHttpException) { + when { + e.code == 400 && filtered -> activity(account, Category.ALL, false, before).copy(unfiltered = true) + e.code == 404 && before == null -> legacy(account) + else -> throw e + } + } + } + + private fun activity( + account: HomeAccount, + category: Category, + unreadOnly: Boolean, + before: Long?, + ): Page { + val url = + account + .url("api/activity/${account.encodedUser}") + .newBuilder() + .addQueryParameter("limit", PAGE_SIZE.toString()) + .apply { + category.query?.let { addQueryParameter("category", it) } + if (unreadOnly) addQueryParameter("unread_only", "true") + before?.let { addQueryParameter("before", it.toString()) } + }.build() + val request = AnkiquestApi.request(url, account.token, account.language).build() + return parsePage(AnkiquestApi.json(account, request)) + } + + private fun legacy(account: HomeAccount): Page { + val request = AnkiquestApi.request(account, "api/notifications/${account.encodedUser}").build() + return parseLegacy(AnkiquestApi.call(account, request) { AnkiquestApi.jsonArray(it) }) + } + + /** What [account] sent: replies and nudges, with when they were read. */ + suspend fun sent( + account: HomeAccount, + before: Long? = null, + ): SentPage = + withContext(Dispatchers.IO) { + val url = + account + .url("api/activity/${account.encodedUser}/sent") + .newBuilder() + .addQueryParameter("limit", PAGE_SIZE.toString()) + .apply { before?.let { addQueryParameter("before", it.toString()) } } + .build() + parseSent(AnkiquestApi.json(account, AnkiquestApi.request(url, account.token, account.language).build())) + } + + /** Marks [ids] read. Repeats are harmless; the server keeps the first read time. */ + suspend fun markRead( + account: HomeAccount, + ids: List, + ) = withContext(Dispatchers.IO) { + for (batch in ids.filter { it > 0 }.distinct().chunked(READ_BATCH)) { + read(account, JSONObject().put("ids", JSONArray(batch))) + } + } + + /** Marks everything up to [through], the newest id the feed showed, read. */ + suspend fun markAllRead( + account: HomeAccount, + through: Long, + ) = withContext(Dispatchers.IO) { read(account, JSONObject().put("through", through)) } + + private fun read( + account: HomeAccount, + body: JSONObject, + ) { + AnkiquestApi.call( + account, + AnkiquestApi.request(account, "api/activity/${account.encodedUser}/read").post(AnkiquestApi.body(body)).build(), + ) {} + } + + /** Cheers 🎉 on [item]; answers whether it had been cheered already. */ + suspend fun cheer( + account: HomeAccount, + item: Item, + ): Boolean = Ankiquest.react(item.id, account.notificationAccount, account.scope) + + /** Answers [item] with [message], cleaned as the server accepts it; answers who it went to. */ + suspend fun reply( + account: HomeAccount, + item: Item, + message: String, + ): String { + val text = AnkiquestReply.clean(message) + require(text.isNotEmpty()) + return Ankiquest.reply(item.id, text, account.notificationAccount, account.scope) + } + + /** + * A card image of a friend's win is shown only from [account]'s own server, so a row can never + * make the app fetch from anywhere else. + */ + fun image( + account: HomeAccount, + item: Item, + ): String? { + if (item.kind != "share") return null + val url = item.image?.toHttpUrlOrNull() ?: return null + val server = account.server.toHttpUrlOrNull() ?: return null + val same = url.scheme == server.scheme && url.host == server.host && url.port == server.port + return item.image.takeIf { same && url.encodedPath.endsWith(".png") } + } + + fun parsePage(json: JSONObject): Page = + Page( + items = items(json.optJSONArray("items")), + attention = items(json.optJSONArray("attention")), + unread = json.optInt("unread_count"), + actionCount = json.optInt("action_count"), + latestId = json.positive("latest_id"), + nextBefore = json.positive("next_before"), + ) + + /** The older oldest-first notification array, newest first like the feed. */ + fun parseLegacy(array: JSONArray): Page = + Page( + items = items(array).sortedByDescending { it.id }, + attention = emptyList(), + unread = 0, + actionCount = 0, + latestId = null, + nextBefore = null, + legacy = true, + ) + + fun parseSent(json: JSONObject): SentPage = + SentPage( + items = + json.optJSONArray("items")?.objects().orEmpty().mapNotNull { row -> + item(row)?.let { Sent(row.optString("recipient"), it) } + }, + nextBefore = json.positive("next_before"), + ) + + private fun items(array: JSONArray?): List = array?.objects().orEmpty().mapNotNull(::item) + + /** One row, or null for a row without a usable id. */ + fun item(json: JSONObject): Item? { + val id = json.positive("id") ?: return null + val replyTo = + json.optJSONObject("reply_to")?.let { original -> + original.text("title") to original.text("body") + } + return Item( + id = id, + title = json.text("title"), + body = json.text("body"), + createdAt = json.optLong("created_at"), + sender = json.text("sender"), + replied = json.optBoolean("replied"), + kind = json.text("kind"), + readAt = json.positive("read_at"), + challengeId = json.positive("challenge_id"), + actionRequired = json.optBoolean("action_required"), + replyTo = replyTo?.takeIf { (title, body) -> title.isNotEmpty() || body.isNotEmpty() }, + image = json.text("image").ifEmpty { null }, + reacted = json.optBoolean("reacted"), + ) + } + + private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key) + + private fun JSONObject.positive(key: String): Long? = if (isNull(key)) null else optLong(key).takeIf { it > 0 } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivity.kt new file mode 100644 index 000000000000..b0000174026f --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivity.kt @@ -0,0 +1,813 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import android.text.format.DateUtils +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isEmpty +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.core.widget.NestedScrollView +import androidx.lifecycle.lifecycleScope +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.ichi2.anki.AnkiActivity +import com.ichi2.anki.R +import com.ichi2.anki.common.time.Time +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.preferences.AnkiquestSettingsFragment +import com.ichi2.anki.preferences.PreferencesActivity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONException +import timber.log.Timber +import java.io.IOException +import java.util.Calendar + +/** + * The activity feed, natively: friends' wins and deck completions to cheer, messages and replies to + * answer, challenge news, and what this player sent. Notifications about any of these open here. + * Challenges themselves are still the website's ([AnkiquestActivity]). + */ +class AnkiquestFeedActivity : AnkiActivity(R.layout.activity_ankiquest_feed) { + private enum class Box { INBOX, SENT } + + private lateinit var content: LinearLayout + private lateinit var scroll: NestedScrollView + private var account: HomeAccount? = null + private var box = Box.INBOX + private var category = AnkiquestFeed.Category.ALL + private var unreadOnly = false + private var page: AnkiquestFeed.Page? = null + private var sent: AnkiquestFeed.SentPage? = null + private var loading = false + + @StringRes private var failure: Int? = null + private var status: String? = null + + /** Rows with a cheer, reply or read request on its way, whose buttons wait for it. */ + private val busy = mutableSetOf() + private var markingAll = false + private val pictures = mutableMapOf() + private var generation = 0 + private var loadJob: Job? = null + + override fun onCreate(savedInstanceState: Bundle?) { + if (showedActivityFailedScreen(savedInstanceState)) return + super.onCreate(savedInstanceState) + content = findViewById(R.id.aq_feed_content) + scroll = findViewById(R.id.aq_feed_scroll) + ViewCompat.setAccessibilityHeading(findViewById(R.id.aq_feed_title), true) + findViewById(R.id.aq_feed_back).setOnClickListener { onBackPressedDispatcher.onBackPressed() } + findViewById(R.id.aq_feed_refresh).setOnClickListener { + status = null + reload() + } + val root = findViewById(R.id.aq_feed_root) + ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + root.updatePadding(left = bars.left, top = bars.top, right = bars.right, bottom = bars.bottom) + insets + } + account = AnkiquestHomeData.account() + if (savedInstanceState == null) { + val addressed = intent.getStringExtra(EXTRA_ACCOUNT) + if (addressed != null && addressed != account?.notificationAccount) status = getString(R.string.aq_home_account_route_changed) + } + reload() + } + + override fun onResume() { + super.onResume() + if (!::content.isInitialized) return + // Signing out or into another account in settings must never leave the old account's messages here. + if (AnkiquestHomeData.account()?.scope != account?.scope) reload() + } + + /** Starts over from the newest rows, for the current account. */ + private fun reload() { + val current = AnkiquestHomeData.account() + if (current?.scope != account?.scope) { + account = current + pictures.clear() + status = null + } + page = null + sent = null + busy.clear() + markingAll = false + load(more = false) + } + + private fun load(more: Boolean) { + val current = account + loadJob?.cancel() + val turn = ++generation + failure = null + if (current == null) { + loading = false + render() + return + } + loading = true + render() + loadJob = + lifecycleScope.launch { + val problem = + attempt { + when (box) { + Box.INBOX -> { + val before = if (more) page?.nextBefore else null + val next = AnkiquestFeed.page(current, category, unreadOnly, before) + if (turn == generation) { + page = if (before == null) next else page?.let { append(it, next) } ?: next + if (next.unfiltered || next.legacy) { + category = AnkiquestFeed.Category.ALL + unreadOnly = false + } + } + } + Box.SENT -> { + val before = if (more) sent?.nextBefore else null + val next = AnkiquestFeed.sent(current, before) + if (turn == generation) { + sent = + sent?.takeIf { before != null }?.let { old -> + AnkiquestFeed.SentPage( + old.items + + next.items.filter { new -> + old.items.none { it.item.id == new.item.id } + }, + next.nextBefore, + ) + } ?: next + } + } + } + } + if (turn != generation) return@launch + if (problem == R.string.aq_feed_account_changed) { + reload() + return@launch + } + failure = problem + loading = false + render() + if (box == Box.INBOX) loadPictures(current) + } + } + + /** Runs [work] and answers the message for its failure, or null when it worked. */ + private suspend fun attempt(work: suspend () -> Unit): Int? = + try { + work() + null + } catch (e: CancellationException) { + throw e + } catch (_: HomeAccountChanged) { + R.string.aq_feed_account_changed + } catch (e: AnkiquestHttpException) { + Timber.w(e, "AnkiQuest activity request refused") + if (e.code == 401 || e.code == 403) R.string.aq_home_auth else R.string.aq_feed_server_error + } catch (e: IOException) { + Timber.w(e, "AnkiQuest activity unreachable") + R.string.aq_feed_offline + } catch (e: JSONException) { + Timber.w(e, "AnkiQuest activity unreadable") + R.string.aq_feed_server_error + } + + private fun append( + old: AnkiquestFeed.Page, + next: AnkiquestFeed.Page, + ): AnkiquestFeed.Page = + next.copy( + items = + old.items + + next.items.filter { new -> + old.items.none { it.id == new.id } + }, + attention = old.attention, + ) + + /** Fetches each friend's win card once, from the account's own server. */ + private fun loadPictures(current: HomeAccount) { + val wanted = + page + ?.items + .orEmpty() + .mapNotNull { AnkiquestFeed.image(current, it) } + .distinct() + .filter { it !in pictures } + if (wanted.isEmpty()) return + wanted.forEach { pictures[it] = null } + lifecycleScope.launch { + for (url in wanted) { + val picture = withContext(Dispatchers.IO) { AnkiquestShare.picture(url) } ?: continue + if (account?.scope != current.scope) return@launch + pictures[url] = picture + render() + } + } + } + + private fun render() { + if (!::content.isInitialized) return + val position = scroll.scrollY + content.removeAllViews() + findViewById(R.id.aq_feed_loading).isVisible = loading + status?.let { line -> + text(content, line, small = true).apply { accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE } + } + if (account == null) { + val panel = card(content) + text(panel, getString(R.string.aq_feed_connect)) + button(panel, getString(R.string.aq_home_connect), primary = true) { settings() } + return + } + choices( + listOf(Box.INBOX to getString(R.string.aq_feed_inbox), Box.SENT to getString(R.string.aq_feed_sent)), + selected = box, + enabled = true, + ) { choice -> + box = choice + if ((choice == Box.SENT && sent == null) || (choice == Box.INBOX && page == null)) load(more = false) else render() + } + when (box) { + Box.INBOX -> renderInbox() + Box.SENT -> renderSent() + } + failure?.let { problem -> + text(content, getString(problem), small = true) + if (problem == R.string.aq_home_auth) button(content, getString(R.string.aq_home_settings)) { settings() } + } + scroll.post { scroll.scrollTo(0, position) } + } + + private fun renderInbox() { + val current = page + if (current?.legacy != true) { + choices( + AnkiquestFeed.Category.entries.map { it to categoryLabel(it, current) }, + selected = category, + enabled = !loading, + ) { choice -> + if (choice != category) { + category = choice + page = null + load(more = false) + } else { + render() + } + } + chip(content, getString(R.string.aq_feed_unread_only), checked = unreadOnly, enabled = !loading) { + unreadOnly = !unreadOnly + page = null + load(more = false) + } + } + if (current == null) { + if (loading) text(content, getString(R.string.aq_feed_loading), small = true) + return + } + if (current.unread > 0 && !current.legacy) { + button(content, getString(R.string.aq_feed_mark_all_read), enabled = !markingAll) { markAllRead() } + } + if (current.legacy) text(content, getString(R.string.aq_feed_legacy), small = true) + val attention = current.attention.takeIf { category == AnkiquestFeed.Category.ALL }.orEmpty() + if (attention.isNotEmpty() && current.actionCount > 0) { + val panel = card(content, tinted = true) + text(panel, getString(R.string.aq_feed_needs_action_count, current.actionCount), heading = true) + text(panel, getString(R.string.aq_feed_needs_action_hint), small = true) + for (item in attention) { + text(panel, item.title, heading = true) + if (item.body.isNotEmpty()) text(panel, item.body) + item.challengeId?.let { goal -> + button(panel, getString(R.string.aq_feed_view_invitation), primary = true) { openGoal(item, goal) } + } + } + if (current.actionCount > attention.size) { + button(panel, getString(R.string.aq_feed_all_invitations)) { + category = AnkiquestFeed.Category.NEEDS_ACTION + page = null + load(more = false) + } + } + } + val shown = current.items.filter { item -> attention.none { it.id == item.id } } + if (shown.isEmpty()) { + if (!loading) { + content.addView(AnkiquestAki.image(this, R.drawable.aki_celebrate, 72)) + text(content, getString(emptyMessage(attention.isNotEmpty())), heading = true) + text(content, getString(R.string.aq_feed_empty_hint), small = true) + } + } else { + var day: Long? = null + for (item in shown) { + val itemDay = dayOf(item.createdAt) + if (itemDay != day) { + day = itemDay + text(content, dayLabel(item.createdAt), heading = true).setPadding(0, dp(16), 0, 0) + } + renderItem(item, current.legacy) + } + } + if (current.nextBefore != null) { + button( + content, + getString(if (loading) R.string.aq_feed_loading else R.string.aq_feed_more), + enabled = !loading, + ) { load(more = true) } + } + } + + private fun emptyMessage(attention: Boolean): Int = + when { + unreadOnly -> R.string.aq_feed_empty_unread + category == AnkiquestFeed.Category.NEEDS_ACTION -> R.string.aq_feed_empty_invitations + category != AnkiquestFeed.Category.ALL -> R.string.aq_feed_empty_category + attention -> R.string.aq_feed_empty_rest + else -> R.string.aq_feed_empty + } + + private fun renderItem( + item: AnkiquestFeed.Item, + legacy: Boolean, + ) { + val unread = !legacy && item.unread + val panel = card(content, tinted = unread) + text(panel, item.heading, heading = true) + val time = DateUtils.formatDateTime(this, item.createdAt * 1000, DateUtils.FORMAT_SHOW_TIME) + text(panel, if (unread) getString(R.string.aq_feed_time_unread, time) else time, small = true) + item.replyTo?.let { (title, body) -> quote(panel, title, body) } + item.text?.let { text(panel, it) } + account?.let { AnkiquestFeed.image(it, item) }?.let { url -> pictures[url] }?.let { picture -> + panel.addView( + ImageView(this).apply { + setImageBitmap(picture) + adjustViewBounds = true + contentDescription = getString(R.string.aq_feed_card, item.title) + }, + LinearLayout.LayoutParams(dp(160), ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(6) }, + ) + } + val actions = actions(panel) + val waiting = item.id in busy + if (item.cheerable) { + if (item.reacted) { + chip(actions, getString(R.string.aq_feed_cheered), checked = true, enabled = false) {} + } else { + chip(actions, getString(R.string.ankiquest_cheer), enabled = !waiting) { cheer(item) } + } + } + item.challengeId?.let { goal -> + val label = if (item.actionRequired) R.string.aq_feed_view_invitation else R.string.aq_feed_open_goal + chip(actions, getString(label), primary = item.actionRequired) { openGoal(item, goal) } + } + if (item.answerable) { + quickReply(item.kind)?.let { words -> chip(actions, getString(words), enabled = !waiting) { reply(item, getString(words)) } } + chip(actions, getString(R.string.aq_feed_reply), enabled = !waiting) { askReply(item) } + } + if (unread) chip(actions, getString(R.string.aq_feed_mark_read), enabled = !waiting) { markRead(item) } + if (actions.isEmpty()) panel.removeView(actions) + if (item.replied) text(panel, getString(R.string.aq_feed_reply_sent), small = true) + } + + private fun renderSent() { + val current = sent + if (current == null) { + if (loading) text(content, getString(R.string.aq_feed_loading), small = true) + return + } + if (current.items.isEmpty() && !loading) { + text(content, getString(R.string.aq_feed_sent_empty), heading = true) + text(content, getString(R.string.aq_feed_sent_empty_hint), small = true) + } + for ((recipient, item) in current.items) { + val panel = card(content) + text(panel, getString(R.string.aq_feed_sent_to, recipient), heading = true) + val time = DateUtils.formatDateTime(this, item.createdAt * 1000, DATE_AND_TIME) + val seen = + item.readAt?.let { + getString(R.string.aq_feed_seen, DateUtils.formatDateTime(this, it * 1000, DATE_AND_TIME)) + } ?: getString(R.string.aq_feed_not_seen) + text(panel, "$time · $seen", small = true) + item.replyTo?.let { (title, body) -> quote(panel, title, body) } + if (item.body.isNotEmpty()) text(panel, item.body) + } + if (current.nextBefore != null) { + button( + content, + getString(if (loading) R.string.aq_feed_loading else R.string.aq_feed_more_sent), + enabled = !loading, + ) { load(more = true) } + } + } + + private fun categoryLabel( + category: AnkiquestFeed.Category, + page: AnkiquestFeed.Page?, + ): String = + when (category) { + AnkiquestFeed.Category.ALL -> getString(R.string.aq_feed_all) + AnkiquestFeed.Category.NEEDS_ACTION -> { + val count = page?.actionCount ?: 0 + if (count > 0) getString(R.string.aq_feed_needs_action_count, count) else getString(R.string.aq_feed_needs_action) + } + AnkiquestFeed.Category.MESSAGES -> getString(R.string.aq_feed_messages) + AnkiquestFeed.Category.CHALLENGES -> getString(R.string.aq_feed_challenges) + AnkiquestFeed.Category.DECK_COMPLETIONS -> getString(R.string.aq_feed_deck_completions) + AnkiquestFeed.Category.STUDY_UPDATES -> getString(R.string.aq_feed_study_updates) + } + + /** Sends a 🎉 for a friend's win or finished deck. */ + private fun cheer(item: AnkiquestFeed.Item) = + act(item, R.string.aq_feed_cheer_failed) { current -> + AnkiquestFeed.cheer(current, item) + change(item.id) { it.copy(reacted = true) } + getString(R.string.aq_feed_cheer_sent) + } + + private fun askReply(item: AnkiquestFeed.Item) { + val field = + EditText(this).apply { + hint = getString(R.string.ankiquest_reply_hint) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + filters = arrayOf(InputFilter.LengthFilter(AnkiquestReply.MAX_MESSAGE)) + isSingleLine = true + } + val frame = + FrameLayout(this).apply { + setPadding(dp(24), dp(8), dp(24), 0) + addView(field) + } + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.aq_feed_reply_to, item.sender)) + .setView(frame) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.aq_feed_send) { _, _ -> + val message = AnkiquestReply.clean(field.text.toString()) + if (message.isNotEmpty()) reply(item, message) + }.show() + field.requestFocus() + } + + /** Answers [item]; a reply also marks it read, which may fail without undoing the reply. */ + private fun reply( + item: AnkiquestFeed.Item, + message: String, + ) = act(item, R.string.aq_feed_reply_failed) { current -> + AnkiquestFeed.reply(current, item, message) + change(item.id) { it.copy(replied = true) } + if (item.unread && page?.legacy != true) { + try { + AnkiquestFeed.markRead(current, listOf(item.id)) + read(listOf(item.id)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "AnkiQuest reply sent, read state not saved") + } + } + getString(R.string.aq_feed_reply_done) + } + + private fun markRead(item: AnkiquestFeed.Item) = + act(item, R.string.aq_feed_read_failed) { current -> + AnkiquestFeed.markRead(current, listOf(item.id)) + read(listOf(item.id)) + null + } + + private fun markAllRead() { + val current = account ?: return + val shown = page ?: return + val through = shown.latestId ?: (shown.items + shown.attention).maxOfOrNull { it.id } ?: return + markingAll = true + render() + lifecycleScope.launch { + val problem = attempt { AnkiquestFeed.markAllRead(current, through) } + if (account?.scope != current.scope) return@launch + markingAll = false + if (problem == null) { + val now = TimeManager.time.intTimeMS() / 1000 + page = + page?.let { latest -> + fun AnkiquestFeed.Item.marked() = if (unread && id <= through) copy(readAt = now) else this + latest.copy( + items = latest.items.map { it.marked() }.filter { !unreadOnly || it.unread }, + attention = latest.attention.map { it.marked() }, + unread = 0, + ) + } + status = getString(R.string.aq_feed_all_read) + } else { + status = getString(R.string.aq_feed_read_failed) + } + render() + } + } + + /** Opens the goal a row is about, on the website until challenges are native, and marks the row read. */ + private fun openGoal( + item: AnkiquestFeed.Item, + goal: Long, + ) { + startActivity(AnkiquestActivity.intent(this, AnkiquestNavigation.challengePath(goal))) + val current = account ?: return + if (!item.unread || page?.legacy == true) return + lifecycleScope.launch { + val problem = attempt { AnkiquestFeed.markRead(current, listOf(item.id)) } + if (problem == null && account?.scope == current.scope) { + read(listOf(item.id)) + render() + } + } + } + + /** + * Runs [work] for [item] while its buttons wait. [work] answers the line to show, or null for none; + * a failure shows [failed] with why. Nothing is kept once the account changed. + */ + private fun act( + item: AnkiquestFeed.Item, + @StringRes failed: Int, + work: suspend (HomeAccount) -> String?, + ) { + val current = account ?: return + if (!busy.add(item.id)) return + render() + lifecycleScope.launch { + var line: String? = null + val problem = attempt { line = work(current) } + if (account?.scope != current.scope) return@launch + busy.remove(item.id) + if (problem == R.string.aq_feed_account_changed) { + reload() + return@launch + } + status = if (problem == null) line else getString(R.string.aq_feed_failed_because, getString(failed), getString(problem)) + render() + } + } + + private fun change( + id: Long, + update: (AnkiquestFeed.Item) -> AnkiquestFeed.Item, + ) { + page = + page?.let { latest -> + latest.copy( + items = latest.items.map { if (it.id == id) update(it) else it }, + attention = latest.attention.map { if (it.id == id) update(it) else it }, + ) + } + } + + private fun read(ids: List) { + val latest = page ?: return + val newlyRead = (latest.items + latest.attention).filter { it.id in ids && it.unread }.map { it.id }.distinct() + val now = TimeManager.time.intTimeMS() / 1000 + ids.forEach { id -> change(id) { if (it.unread) it.copy(readAt = now) else it } } + page = + page?.let { changed -> + changed.copy( + items = if (unreadOnly) changed.items.filter { it.id !in ids } else changed.items, + unread = (changed.unread - newlyRead.size).coerceAtLeast(0), + ) + } + } + + private fun settings() = startActivity(PreferencesActivity.getIntent(this, AnkiquestSettingsFragment::class)) + + private fun dayOf(seconds: Long): Long = + Time + .gregorianCalendar(seconds * 1000) + .apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + + private fun dayLabel(seconds: Long): String { + val day = dayOf(seconds) + val today = dayOf(TimeManager.time.intTimeMS() / 1000) + val yesterday = + Time + .gregorianCalendar(today) + .apply { add(Calendar.DAY_OF_YEAR, -1) } + .timeInMillis + return when (day) { + today -> getString(R.string.ankiquest_nav_today) + yesterday -> getString(R.string.aq_feed_yesterday) + else -> DateUtils.formatDateTime(this, seconds * 1000, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR) + } + } + + private fun choices( + options: List>, + selected: T, + enabled: Boolean, + pick: (T) -> Unit, + ) { + val group = + ChipGroup(this).apply { + isSingleLine = true + isSingleSelection = true + } + for ((value, label) in options) chip(group, label, checked = value == selected, enabled = enabled) { pick(value) } + content.addView( + HorizontalScrollView(this).apply { + isHorizontalScrollBarEnabled = false + addView(group) + }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) }, + ) + } + + private fun actions(parent: LinearLayout): ChipGroup = + ChipGroup(this).also { + parent.addView(it, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + + private fun chip( + parent: ViewGroup, + label: String, + checked: Boolean = false, + primary: Boolean = false, + enabled: Boolean = true, + click: () -> Unit, + ): Chip = + Chip(this).apply { + text = label + textSize = 15f + // Grows with the text size, so large text is never clipped. + chipMinHeight = maxOf(dp(32).toFloat(), paint.fontMetrics.run { descent - ascent } + dp(14)) + isCheckable = checked + isChecked = checked + isCheckedIconVisible = false + isEnabled = enabled + val filled = checked || primary + setTextColor(getColor(if (filled) R.color.aq_home_primary_text else R.color.aq_home_text)) + chipBackgroundColor = ColorStateList.valueOf(getColor(if (filled) R.color.aq_home_primary else R.color.aq_home_surface)) + chipStrokeWidth = if (filled) 0f else dp(1).toFloat() + chipStrokeColor = ColorStateList.valueOf(getColor(R.color.aq_home_outline)) + // The state is the selection drawn above; clicking always asks [click], which re-renders. + setOnClickListener { click() } + if (parent is LinearLayout) { + parent.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } else { + parent.addView(this) + } + } + + private fun quote( + parent: LinearLayout, + title: String, + body: String, + ) { + val box = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(12), dp(6), dp(12), dp(6)) + background = shape(R.color.aq_home_background, 10) + } + parent.addView( + box, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) }, + ) + text(box, getString(R.string.aq_feed_in_reply_to), small = true) + if (title.isNotEmpty()) text(box, title).typeface = Typeface.DEFAULT_BOLD + if (body.isNotEmpty()) text(box, body, small = true) + } + + private fun card( + parent: LinearLayout, + tinted: Boolean = false, + ): LinearLayout = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(12), dp(16), dp(12)) + background = shape(if (tinted) R.color.aq_home_tint else R.color.aq_home_surface) + parent.addView( + this, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(10) + }, + ) + } + + private fun text( + parent: LinearLayout, + value: String, + heading: Boolean = false, + small: Boolean = false, + ): TextView = + TextView(this).apply { + text = value + textSize = + when { + heading -> 17f + small -> 14f + else -> 16f + } + setTextColor(getColor(if (small) R.color.aq_home_muted else R.color.aq_home_text)) + if (heading) { + typeface = Typeface.DEFAULT_BOLD + ViewCompat.setAccessibilityHeading(this, true) + } + setPadding(0, dp(3), 0, dp(3)) + parent.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + + private fun button( + parent: LinearLayout, + label: String, + primary: Boolean = false, + enabled: Boolean = true, + click: () -> Unit, + ): MaterialButton = + MaterialButton(this).apply { + text = label + isAllCaps = false + textSize = 16f + minHeight = dp(48) + minimumHeight = dp(48) + cornerRadius = dp(12) + isEnabled = enabled + setTextColor(getColor(if (primary) R.color.aq_home_primary_text else R.color.aq_home_text)) + backgroundTintList = ColorStateList.valueOf(getColor(if (primary) R.color.aq_home_primary else R.color.aq_home_surface)) + if (!primary) { + strokeWidth = dp(1) + strokeColor = ColorStateList.valueOf(getColor(R.color.aq_home_outline)) + } + setOnClickListener { click() } + parent.addView( + this, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + topMargin = + dp(8) + }, + ) + } + + private fun shape( + @ColorRes color: Int, + radius: Int = 18, + ): GradientDrawable = + GradientDrawable().apply { + cornerRadius = dp(radius).toFloat() + setColor(getColor(color)) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + companion object { + /** The account a notification was for, as [HomeAccount.notificationAccount]; another account's feed says so. */ + const val EXTRA_ACCOUNT = "ankiquest.feed_account" + + private const val DATE_AND_TIME = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH + + fun intent( + context: Context, + account: String? = null, + ): Intent = + Intent(context, AnkiquestFeedActivity::class.java) + .apply { account?.let { putExtra(EXTRA_ACCOUNT, it) } } + + /** The suggested one-tap answer to a row of [kind], as the website and notifications suggest it. */ + internal fun quickReply(kind: String): Int? = + when (kind) { + "completion", "conquest", "share" -> R.string.ankiquest_reply_cheer + "reply", "reaction" -> R.string.ankiquest_reply_thanks + "nudge" -> R.string.ankiquest_reply_on_it + else -> null + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt index 324c7792921e..10dd8854a3d0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt @@ -48,8 +48,8 @@ import java.io.File import java.io.IOException /** - * The daily study surface is native and local. Friends, activity and progress are the - * website's community pages, opened in [AnkiquestActivity]. + * The daily study surface is native and local. Activity is native too ([AnkiquestFeedActivity]); + * friends and progress are the website's community pages, opened in [AnkiquestActivity]. */ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { private lateinit var content: LinearLayout @@ -85,7 +85,7 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { scroll = findViewById(R.id.aq_home_scroll) findViewById(R.id.aq_home_activity).apply { setTextColor(getColor(R.color.aq_home_text)) - setOnClickListener { web(AnkiquestNavigation.ACTIVITY_PATH) } + setOnClickListener { startActivity(AnkiquestFeedActivity.intent(this@AnkiquestHomeActivity)) } } findViewById(R.id.aq_home_navigation).apply { selectedItemId = R.id.ankiquest_nav_today @@ -545,8 +545,6 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { private fun settings() = startActivity(PreferencesActivity.getIntent(this, AnkiquestSettingsFragment::class)) - private fun web(path: String) = startActivity(AnkiquestActivity.intent(this, path)) - private fun connectionCard() { val panel = card() text(panel, getString(R.string.aq_home_connected)) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNavigation.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNavigation.kt index 0eafa6bb3c9f..159ea23ca01b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNavigation.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNavigation.kt @@ -15,12 +15,15 @@ object AnkiquestNavigation { const val ACTIVITY_PATH = "/community#activity" const val WEEK_PATH = "/week" - /** Where an inbox notification leads: the native Today screen, or one known website route. */ + /** Where an inbox notification leads: a native screen, or one known website route. */ sealed interface Destination { data class Today( val focus: String? = null, ) : Destination + /** The native activity feed, [AnkiquestFeedActivity]. */ + data object Activity : Destination + data class Website( val path: String, ) : Destination @@ -43,7 +46,7 @@ object AnkiquestNavigation { challenge > 0 -> Destination.Website(challengePath(challenge)) kind.startsWith("challenge_") -> Destination.Website(FRIENDS_PATH) kind in weekly -> Destination.Website(WEEK_PATH) - kind in feed -> Destination.Website(ACTIVITY_PATH) + kind in feed -> Destination.Activity kind == "reminder_freeze_refill" || topic?.substringBefore(':') == "quest" -> Destination.Today(AnkiquestHomeActivity.FOCUS_QUESTS) else -> Destination.Today() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt index 061428132f7a..e21d2631e0f3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt @@ -270,8 +270,8 @@ object AnkiquestNotifier { context: Context, data: Data, ): Intent = - AnkiquestActivity - .intent(context, AnkiquestNavigation.ACTIVITY_PATH, data.getString(AnkiquestReply.EXTRA_REPLY_ACCOUNT)) + AnkiquestFeedActivity + .intent(context, data.getString(AnkiquestReply.EXTRA_REPLY_ACCOUNT)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) /** IDs and kinds become known screens; server-supplied URLs never become arbitrary app destinations. */ @@ -292,6 +292,7 @@ object AnkiquestNotifier { AnkiquestHomeActivity .intent(context) .apply { destination.focus?.let { putExtra(AnkiquestHomeActivity.EXTRA_FOCUS, it) } } + AnkiquestNavigation.Destination.Activity -> AnkiquestFeedActivity.intent(context, account) is AnkiquestNavigation.Destination.Website -> AnkiquestActivity.intent(context, destination.path, account) } return open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/AnkiDroid/src/main/res/layout/activity_ankiquest_feed.xml b/AnkiDroid/src/main/res/layout/activity_ankiquest_feed.xml new file mode 100644 index 000000000000..57638c52f731 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/activity_ankiquest_feed.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/values-de/ankiquest_feed.xml b/AnkiDroid/src/main/res/values-de/ankiquest_feed.xml new file mode 100644 index 000000000000..28b7e23c5e8c --- /dev/null +++ b/AnkiDroid/src/main/res/values-de/ankiquest_feed.xml @@ -0,0 +1,57 @@ + + + + Zurück + Aktivität aktualisieren + Deine Aktivität wird geladen… + Verbinde AnkiQuest, um Erfolge, Jubel und Nachrichten deiner Freunde zu sehen. + Eingang + Gesendet + Alle + Antwort nötig + Antwort nötig · %1$d + Nachrichten + Herausforderungen + Abgeschlossene Stapel + Lern-Updates + Nur ungelesene + Alle als gelesen markieren + Als gelesen markieren + Alles ist als gelesen markiert. + Einladungen bleiben hier, bis du antwortest, auch wenn du sie gelesen hast. + Einladung ansehen + Ziel öffnen + Alle Einladungen ansehen + Dieser Server zeigt die letzten 7 Tage. Aktualisiere den Server für gespeicherten Lesestatus und einen längeren Verlauf. + Gestern + %1$s · Ungelesen + Karte: %1$s + 🎉 Gejubelt + Dein Jubel wurde gesendet. + Dein Jubel wurde nicht gesendet. + Antworten… + Antwort an %1$s + Senden + Deine Antwort wurde gesendet. + Deine Antwort wurde nicht gesendet. + Antwort gesendet + Der Lesestatus wurde nicht gespeichert. + %1$s %2$s + Antwort auf + Du bist auf dem neuesten Stand. + Alles andere ist auf dem neuesten Stand. + Hier gibt es keine ungelesenen Benachrichtigungen. + Keine Einladung wartet auf eine Antwort. + In dieser Kategorie gibt es noch nichts. + Neue Aktivität erscheint hier. + Frühere Aktivität laden + Frühere Nachrichten laden + An %1$s + Gesehen %1$s + Noch nicht gesehen + Noch nichts gesendet. + Deine Antworten und Stupser erscheinen hier, mit dem Zeitpunkt, zu dem sie gelesen wurden. + Keine Verbindung. Prüfe deine Verbindung; nichts wurde als gelesen markiert. + Die Aktivität konnte nicht geladen werden. Versuche es erneut. + Dein AnkiQuest-Konto hat sich geändert. + diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_feed.xml b/AnkiDroid/src/main/res/values-es/ankiquest_feed.xml new file mode 100644 index 000000000000..1c1a1151d498 --- /dev/null +++ b/AnkiDroid/src/main/res/values-es/ankiquest_feed.xml @@ -0,0 +1,57 @@ + + + + Atrás + Actualizar actividad + Cargando tu actividad… + Conecta AnkiQuest para ver los logros, ánimos y mensajes de tus amigos. + Recibidos + Enviados + Todo + Requiere respuesta + Requiere respuesta · %1$d + Mensajes + Desafíos + Mazos completados + Novedades de estudio + Solo no leídas + Marcar todo como leído + Marcar como leída + Todo está marcado como leído. + Las invitaciones se quedan aquí hasta que respondas, aunque ya las hayas leído. + Ver invitación + Abrir meta + Ver todas las invitaciones + Este servidor muestra los últimos 7 días. Actualiza el servidor para guardar el estado de lectura y tener un historial más largo. + Ayer + %1$s · No leída + Tarjeta: %1$s + 🎉 Animado + Tu ánimo se envió. + Tu ánimo no se envió. + Responder… + Responder a %1$s + Enviar + Tu respuesta se envió. + Tu respuesta no se envió. + Respuesta enviada + No se guardó el estado de lectura. + %1$s %2$s + En respuesta a + Estás al día. + Todo lo demás está al día. + No hay notificaciones sin leer aquí. + Ninguna invitación espera respuesta. + Todavía no hay nada en esta categoría. + La nueva actividad aparecerá aquí. + Cargar actividad anterior + Cargar mensajes anteriores + Para %1$s + Visto %1$s + Aún no visto + Todavía no has enviado nada. + Las respuestas y los empujoncitos que envíes aparecen aquí, con cuándo se leyeron. + No se pudo conectar. Revisa tu conexión; no se marcó nada como leído. + No se pudo cargar la actividad. Vuelve a intentarlo. + Tu cuenta de AnkiQuest cambió. + diff --git a/AnkiDroid/src/main/res/values-fr/ankiquest_feed.xml b/AnkiDroid/src/main/res/values-fr/ankiquest_feed.xml new file mode 100644 index 000000000000..baad7645bfce --- /dev/null +++ b/AnkiDroid/src/main/res/values-fr/ankiquest_feed.xml @@ -0,0 +1,57 @@ + + + + Retour + Actualiser l’activité + Chargement de ton activité… + Connecte AnkiQuest pour voir les réussites, encouragements et messages de tes amis. + Reçus + Envoyés + Tout + Réponse attendue + Réponse attendue · %1$d + Messages + Défis + Paquets terminés + Nouvelles d’étude + Non lues seulement + Tout marquer comme lu + Marquer comme lu + Tout est marqué comme lu. + Les invitations restent ici jusqu’à ta réponse, même après les avoir lues. + Voir l’invitation + Ouvrir l’objectif + Voir toutes les invitations + Ce serveur affiche les 7 derniers jours. Mets le serveur à jour pour enregistrer l’état de lecture et garder un historique plus long. + Hier + %1$s · Non lu + Carte : %1$s + 🎉 Encouragé + Ton encouragement a été envoyé. + Ton encouragement n’a pas été envoyé. + Répondre… + Répondre à %1$s + Envoyer + Ta réponse a été envoyée. + Ta réponse n’a pas été envoyée. + Réponse envoyée + L’état de lecture n’a pas été enregistré. + %1$s %2$s + En réponse à + Tu es à jour. + Tout le reste est à jour. + Aucune notification non lue ici. + Aucune invitation n’attend de réponse. + Rien dans cette catégorie pour l’instant. + Les nouvelles activités apparaîtront ici. + Charger l’activité précédente + Charger les messages précédents + À %1$s + Vu %1$s + Pas encore vu + Rien envoyé pour l’instant. + Les réponses et petits rappels que tu envoies apparaissent ici, avec le moment où ils ont été lus. + Connexion impossible. Vérifie ta connexion ; rien n’a été marqué comme lu. + L’activité n’a pas pu être chargée. Réessaie. + Ton compte AnkiQuest a changé. + diff --git a/AnkiDroid/src/main/res/values-pt/ankiquest_feed.xml b/AnkiDroid/src/main/res/values-pt/ankiquest_feed.xml new file mode 100644 index 000000000000..daa695bded38 --- /dev/null +++ b/AnkiDroid/src/main/res/values-pt/ankiquest_feed.xml @@ -0,0 +1,57 @@ + + + + Voltar + Atualizar atividade + Carregando sua atividade… + Conecte o AnkiQuest para ver as conquistas, torcidas e mensagens dos seus amigos. + Recebidas + Enviadas + Tudo + Precisa de resposta + Precisa de resposta · %1$d + Mensagens + Desafios + Baralhos concluídos + Novidades de estudo + Só não lidas + Marcar tudo como lido + Marcar como lida + Tudo está marcado como lido. + Os convites ficam aqui até você responder, mesmo depois de lidos. + Ver convite + Abrir meta + Ver todos os convites + Este servidor mostra os últimos 7 dias. Atualize o servidor para salvar o status de leitura e ter um histórico maior. + Ontem + %1$s · Não lida + Cartão: %1$s + 🎉 Torcida enviada + Sua torcida foi enviada. + Sua torcida não foi enviada. + Responder… + Responder a %1$s + Enviar + Sua resposta foi enviada. + Sua resposta não foi enviada. + Resposta enviada + O status de leitura não foi salvo. + %1$s %2$s + Em resposta a + Você está em dia. + Todo o resto está em dia. + Não há notificações não lidas aqui. + Nenhum convite espera resposta. + Ainda não há nada nesta categoria. + As novas atividades aparecerão aqui. + Carregar atividades anteriores + Carregar mensagens anteriores + Para %1$s + Vista %1$s + Ainda não vista + Nada enviado ainda. + As respostas e cutucadas que você enviar aparecem aqui, com quando foram lidas. + Não foi possível conectar. Verifique sua conexão; nada foi marcado como lido. + Não foi possível carregar a atividade. Tente de novo. + Sua conta do AnkiQuest mudou. + diff --git a/AnkiDroid/src/main/res/values/ankiquest_feed.xml b/AnkiDroid/src/main/res/values/ankiquest_feed.xml new file mode 100644 index 000000000000..1f2b7c4cd291 --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_feed.xml @@ -0,0 +1,57 @@ + + + + Back + Refresh activity + Loading your activity… + Connect AnkiQuest to see your friends’ wins, cheers and messages. + Inbox + Sent + All + Needs action + Needs action · %1$d + Messages + Challenges + Deck completions + Study updates + Unread only + Mark all as read + Mark read + Everything is marked read. + Invitations stay here until you respond, even after you read them. + View invitation + Open goal + View all invitations + This server shows the last 7 days. Update the server for saved read status and longer history. + Yesterday + %1$s · Unread + Card: %1$s + 🎉 Cheered + Your cheer was sent. + Your cheer was not sent. + Reply… + Reply to %1$s + Send + Your reply was sent. + Your reply was not sent. + Reply sent + Read status was not saved. + %1$s %2$s + In reply to + You’re all caught up. + Everything else is up to date. + No unread notifications here. + No invitations need a response. + Nothing in this category yet. + New activity will appear here. + Load earlier activity + Load earlier messages + To %1$s + Seen %1$s + Not seen yet + Nothing sent yet. + Replies and nudges you send appear here, with when they were read. + Could not connect. Check your connection; nothing has been marked read. + Activity could not be loaded. Try refreshing. + Your AnkiQuest account changed. + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivityTest.kt new file mode 100644 index 000000000000..005d8ca3d784 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedActivityTest.kt @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.graphics.createBitmap +import androidx.core.view.allViews +import androidx.core.view.isVisible +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.preferences.PreferencesActivity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33], qualifiers = "w320dp-h640dp") +class AnkiquestFeedActivityTest : RobolectricTest() { + private val identity = HomeAccount("https://anki.example.test", "member", "secret") + private val page = AnkiquestFeed.parsePage(JSONObject(AnkiquestFeedTest.PAGE)) + + @Before + fun prepareFeed() { + mockkObject(AnkiquestHomeData, AnkiquestFeed, AnkiquestShare) + every { AnkiquestHomeData.account(any()) } returns identity + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } returns page + coEvery { AnkiquestFeed.markRead(identity, any()) } returns Unit + coEvery { AnkiquestFeed.markAllRead(identity, any()) } returns Unit + coEvery { AnkiquestFeed.cheer(identity, any()) } returns false + coEvery { AnkiquestFeed.reply(identity, any(), any()) } returns "Cerro" + every { AnkiquestShare.picture(any()) } returns createBitmap(4, 4) + } + + @After + fun clearFeedMocks() { + unmockkObject(AnkiquestHomeData, AnkiquestFeed, AnkiquestShare) + } + + @Test + fun `the feed shows friends' wins, cheers, replies and invitations`() { + val feed = launch() + assertTrue(feed.hasText("🎉 Cerro shared a win")) + assertTrue(feed.hasText("7 day streak!: “A week in a row.”")) + assertTrue(feed.hasText("🎉 Cerro cheered: “Daily goal reached!”")) + assertFalse(feed.hasText("“Daily goal reached!”"), "a cheer's body is in its heading") + assertTrue(feed.hasText("Cerro replied")) + assertTrue(feed.hasText("Spanish is done for today."), "the message a reply answers is quoted") + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_reply_sent))) + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_needs_action_count, 1))) + assertEquals( + 1, + feed.views().count { it.text.toString() == "Challenge invitation" }, + "the invitation is shown once, first", + ) + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_cheered)), "an already cheered win says so") + advanceRobolectricLooperUntil { + feed.views().any { + it.contentDescription == + feed.getString(R.string.aq_feed_card, "🎉 Cerro shared a win") + } + } + } + + @Test + fun `cheering a friend's deck completion sends a cheer and shows it`() { + val completion = + AnkiquestFeed.item( + JSONObject() + .put("id", 31) + .put("title", "Cerro finished Spanish") + .put("body", "Spanish is done for today.") + .put("created_at", 1793183400) + .put("sender", "cerro") + .put("kind", "completion"), + )!! + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } returns page.copy(items = listOf(completion), attention = emptyList()) + val feed = launch() + feed.click(feed.getString(R.string.ankiquest_cheer)) + coVerify { AnkiquestFeed.cheer(identity, completion) } + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_cheer_sent))) + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_cheered))) + assertFalse(feed.hasText(feed.getString(R.string.ankiquest_cheer))) + } + + @Test + fun `a quick reply answers the row and marks it read`() { + val share = page.items[1] + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } returns + page.copy(items = listOf(share), attention = emptyList(), unread = 1) + val feed = launch() + feed.click(feed.getString(R.string.ankiquest_reply_cheer)) + coVerify { AnkiquestFeed.reply(identity, share, "Good job!") } + coVerify { AnkiquestFeed.markRead(identity, listOf(6L)) } + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_reply_done))) + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_reply_sent))) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_mark_read))) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_mark_all_read)), "nothing unread is left") + } + + @Test + fun `a failed reply says why and keeps the reply buttons`() { + coEvery { AnkiquestFeed.reply(identity, any(), any()) } throws IOException("offline") + val feed = launch() + feed.click(feed.getString(R.string.ankiquest_reply_cheer)) + assertTrue( + feed.hasText( + feed.getString( + R.string.aq_feed_failed_because, + feed.getString(R.string.aq_feed_reply_failed), + feed.getString(R.string.aq_feed_offline), + ), + ), + ) + assertTrue(feed.views().any { it.text.toString() == feed.getString(R.string.ankiquest_reply_cheer) && it.isEnabled }) + } + + @Test + fun `mark all as read marks through the newest row`() { + val feed = launch() + feed.click(feed.getString(R.string.aq_feed_mark_all_read)) + coVerify { AnkiquestFeed.markAllRead(identity, 42) } + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_all_read))) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_mark_all_read))) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_mark_read))) + } + + @Test + fun `filters ask the server for that category or unread rows`() { + val feed = launch() + feed.click(feed.getString(R.string.aq_feed_messages)) + coVerify { AnkiquestFeed.page(identity, AnkiquestFeed.Category.MESSAGES, false, null) } + feed.click(feed.getString(R.string.aq_feed_unread_only)) + coVerify { AnkiquestFeed.page(identity, AnkiquestFeed.Category.MESSAGES, true, null) } + } + + @Test + fun `earlier activity is appended`() { + val older = page.items[3].copy(id = 4, title = "An older message") + coEvery { AnkiquestFeed.page(identity, any(), any(), 5) } returns page.copy(items = listOf(older), nextBefore = null) + val feed = launch() + feed.click(feed.getString(R.string.aq_feed_more)) + assertTrue(feed.hasText("An older message")) + assertTrue(feed.hasText("🎉 Cerro shared a win")) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_more))) + } + + @Test + fun `an invitation opens its goal and is marked read`() { + val feed = launch() + feed.click(feed.getString(R.string.aq_feed_view_invitation)) + val opened = assertNotNull(shadowOf(feed).nextStartedActivity) + assertEquals(AnkiquestActivity::class.java.name, opened.component?.className) + assertEquals(AnkiquestNavigation.challengePath(8), opened.getStringExtra(AnkiquestActivity.EXTRA_PATH)) + coVerify { AnkiquestFeed.markRead(identity, listOf(42L)) } + } + + @Test + fun `sent messages are a tab of their own`() { + coEvery { AnkiquestFeed.sent(identity, any()) } returns + AnkiquestFeed.SentPage(listOf(AnkiquestFeed.Sent("cerro", page.items[3].copy(body = "Well done!", readAt = null))), null) + val feed = launch() + feed.click(feed.getString(R.string.aq_feed_sent)) + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_sent_to, "cerro"))) + assertTrue(feed.hasText("Well done!")) + assertTrue(feed.hasTextContaining(feed.getString(R.string.aq_feed_not_seen))) + assertFalse(feed.hasText("🎉 Cerro shared a win")) + } + + @Test + fun `an older server's list has no filters or read state`() { + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } returns + AnkiquestFeed.parseLegacy(org.json.JSONArray(AnkiquestFeedTest.LEGACY)) + val feed = launch() + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_legacy))) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_messages))) + assertFalse(feed.hasText(feed.getString(R.string.aq_feed_mark_read))) + assertTrue(feed.hasText("Daily goal reached!")) + } + + @Test + fun `offline and refused feeds say what to do`() { + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } throws IOException("offline") + val offline = launch() + assertTrue(offline.hasText(offline.getString(R.string.aq_feed_offline))) + + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } throws AnkiquestHttpException(401) + val refused = launch() + assertTrue(refused.hasText(refused.getString(R.string.aq_home_auth))) + refused.click(refused.getString(R.string.aq_home_settings)) + assertEquals(PreferencesActivity::class.java.name, shadowOf(refused).nextStartedActivity.component?.className) + } + + @Test + fun `without an account the feed asks to connect`() { + every { AnkiquestHomeData.account(any()) } returns null + val feed = launch() + assertTrue(feed.hasText(feed.getString(R.string.aq_feed_connect))) + coVerify(exactly = 0) { AnkiquestFeed.page(any(), any(), any(), any()) } + } + + @Test + fun `a notification for another account shows this account's feed and says so`() { + val feed = launch("https://other.example.test/member") + assertTrue(feed.hasText(feed.getString(R.string.aq_home_account_route_changed))) + assertTrue(feed.hasText("🎉 Cerro shared a win")) + assertFalse(launch(identity.notificationAccount).hasText(feed.getString(R.string.aq_home_account_route_changed))) + } + + @Test + fun `a web page for another account opens this account's native feed instead`() { + val web = + startRegularActivity( + AnkiquestActivity.intent(targetContext, AnkiquestNavigation.challengePath(42), "https://other.example.test/member"), + ) + val opened = assertNotNull(shadowOf(web).nextStartedActivity) + assertEquals(AnkiquestFeedActivity::class.java.name, opened.component?.className) + assertEquals("https://other.example.test/member", opened.getStringExtra(AnkiquestFeedActivity.EXTRA_ACCOUNT)) + assertTrue(web.isFinishing) + } + + private fun launch(account: String? = null): AnkiquestFeedActivity = + startRegularActivity(AnkiquestFeedActivity.intent(targetContext, account)) + .also { feed -> advanceRobolectricLooperUntil { !feed.findViewById(R.id.aq_feed_loading).isVisible } } + + private inline fun AnkiquestFeedActivity.views(): List = + findViewById(R.id.aq_feed_content).allViews.filterIsInstance().toList() + + private fun AnkiquestFeedActivity.hasText(value: String): Boolean = views().any { it.text.toString() == value } + + private fun AnkiquestFeedActivity.hasTextContaining(value: String): Boolean = + views().any { it.text.toString().contains(value) } + + private fun AnkiquestFeedActivity.click(label: String) { + val control = views().first { it.text.toString() == label && it.isClickable } + assertTrue(control.isEnabled, label) + control.performClick() + advanceRobolectricLooperUntil { !findViewById(R.id.aq_feed_loading).isVisible } + advanceRobolectricLooper() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedScreenshotTest.kt new file mode 100644 index 000000000000..24fb7f1ee142 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedScreenshotTest.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.graphics.Color +import androidx.core.graphics.createBitmap +import com.ichi2.anki.ScreenshotTest +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.robolectric.RuntimeEnvironment + +/** Opt-in visual QA; the normal unit-test task excludes ScreenshotTestCategory. */ +class AnkiquestFeedScreenshotTest : ScreenshotTest() { + override fun applyDeviceConfig() { + super.applyDeviceConfig() + if (device == DeviceConfig.PHONE) { + RuntimeEnvironment.setQualifiers("w320dp-h740dp-port-mdpi") + } + } + + @Before + fun prepareFeed() { + val identity = HomeAccount("https://anki.example.test", "sam", "visual-test-token") + mockkObject(AnkiquestHomeData, AnkiquestFeed, AnkiquestShare) + every { AnkiquestHomeData.account(any()) } returns identity + coEvery { AnkiquestFeed.page(identity, any(), any(), any()) } returns AnkiquestFeed.parsePage(JSONObject(AnkiquestFeedTest.PAGE)) + every { AnkiquestShare.picture(any()) } returns + createBitmap(120, 120).apply { eraseColor(Color.rgb(36, 95, 171)) } + } + + @After + fun clearFeedMocks() { + unmockkObject(AnkiquestHomeData, AnkiquestFeed, AnkiquestShare) + RuntimeEnvironment.setFontScale(1f) + } + + @Test + fun activity() = captureFeed() + + @Test + fun activityLargeText() = captureFeed(largeText = true) + + private fun captureFeed(largeText: Boolean = false) { + RuntimeEnvironment.setFontScale(if (largeText) 2f else 1f) + startActivityNormallyOpenCollectionWithIntent(AnkiquestFeedActivity::class.java, AnkiquestFeedActivity.intent(targetContext)) + advanceRobolectricLooper() + val size = if (device == DeviceConfig.PHONE) "320dp" else "default" + captureScreen("activity_feed_${size}${if (largeText) "_200percent" else ""}") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedTest.kt new file mode 100644 index 000000000000..ff7828171a0e --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestFeedTest.kt @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import androidx.core.content.edit +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.testutils.EmptyApplication +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class, sdk = [32]) +@Category(EmptyApplicationCategory::class) +class AnkiquestFeedTest : RobolectricTest() { + private class Answer( + val status: Int, + val body: String, + ) + + private data class Seen( + val method: String, + val path: String, + val query: String?, + val authorization: String?, + val body: String, + ) + + private lateinit var server: HttpServer + private lateinit var account: HomeAccount + private val requests = CopyOnWriteArrayList() + + /** Answers by path; anything else is 404. */ + @Volatile private var answer: (Seen) -> Answer = { Answer(404, "") } + + @Before + fun prepare() { + AnkiDroidApp.sharedPreferencesTestingOverride = targetContext.sharedPrefs() + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + val seen = + Seen( + exchange.requestMethod, + exchange.requestURI.rawPath, + exchange.requestURI.rawQuery, + exchange.requestHeaders.getFirst("Authorization"), + exchange.requestBody.use { it.readBytes().decodeToString() }, + ) + requests += seen + val reply = answer(seen) + val bytes = reply.body.toByteArray() + exchange.sendResponseHeaders(reply.status, if (bytes.isEmpty()) -1 else bytes.size.toLong()) + if (bytes.isNotEmpty()) exchange.responseBody.use { it.write(bytes) } + exchange.close() + } + server.start() + AnkiDroidApp.sharedPrefs().edit { + putString(Ankiquest.PREF_URL, "http://127.0.0.1:${server.address.port}") + putString(Ankiquest.PREF_USER, "member name") + putString(Ankiquest.PREF_TOKEN, "member-test-token") + } + account = AnkiquestHomeData.account()!! + } + + @After + fun stop() { + server.stop(0) + AnkiDroidApp.sharedPreferencesTestingOverride = null + } + + @Test + fun `a page reads every row the website shows`() { + val page = AnkiquestFeed.parsePage(JSONObject(PAGE)) + assertEquals(listOf(42L, 6L, 8L, 9L), page.items.map { it.id }) + assertEquals(listOf(42L), page.attention.map { it.id }) + assertEquals(3, page.unread) + assertEquals(1, page.actionCount) + assertEquals(42L, page.latestId) + assertEquals(5L, page.nextBefore) + assertFalse(page.legacy) + + val (invite, share, cheer, reply) = page.items + assertEquals(8L, invite.challengeId) + assertTrue(invite.actionRequired) + assertFalse(invite.answerable, "challenge news is answered on the goal") + assertFalse(invite.cheerable) + + assertEquals("https://anki.example.test/w/example-link-2.png", share.image) + assertTrue(share.reacted) + assertTrue(share.cheerable) + assertTrue(share.answerable) + assertTrue(share.unread) + assertNull(share.challengeId, "null challenge ids stay null") + + assertEquals("🎉 Cerro cheered: “Daily goal reached!”", cheer.heading) + assertNull(cheer.text, "a cheer says what it cheered in its heading") + assertFalse(cheer.cheerable) + assertTrue(cheer.answerable) + + assertEquals("Deck complete" to "Spanish is done for today.", reply.replyTo) + assertEquals(1793183500L, reply.readAt) + assertFalse(reply.unread) + assertTrue(reply.replied) + assertFalse(reply.answerable) + } + + @Test + fun `rows without a usable id are skipped`() { + val page = + AnkiquestFeed.parsePage( + JSONObject().put("items", JSONArray().put(JSONObject().put("title", "x")).put(JSONObject().put("id", 3))), + ) + assertEquals(listOf(3L), page.items.map { it.id }) + assertNull(page.latestId) + assertNull(page.nextBefore) + } + + @Test + fun `the feed asks for the owner's page with their token and the chosen filters`() = + runBlocking { + answer = { Answer(200, PAGE) } + val page = AnkiquestFeed.page(account, AnkiquestFeed.Category.DECK_COMPLETIONS, unreadOnly = true, before = 40) + assertEquals(4, page.items.size) + val seen = requests.single() + assertEquals("GET", seen.method) + assertEquals("/api/activity/member%20name", seen.path) + assertEquals("limit=50&category=deck_completions&unread_only=true&before=40", seen.query) + assertEquals("Bearer member-test-token", seen.authorization) + + requests.clear() + assertFalse(AnkiquestFeed.page(account).unfiltered) + assertEquals("limit=50", requests.single().query) + } + + @Test + fun `a server without the feed answers the last week of notifications, newest first`() = + runBlocking { + answer = { seen -> + if (seen.path == "/api/notifications/member%20name") Answer(200, LEGACY) else Answer(404, "") + } + val page = AnkiquestFeed.page(account, AnkiquestFeed.Category.MESSAGES, unreadOnly = true) + assertTrue(page.legacy) + assertEquals(listOf(8L, 6L, 1L), page.items.map { it.id }) + assertEquals(0, page.unread) + assertNull(page.nextBefore) + assertEquals(listOf("/api/activity/member%20name", "/api/notifications/member%20name"), requests.map { it.path }) + } + + @Test + fun `an older page that is gone is an error, not the legacy list`() { + answer = { Answer(404, "") } + val error = assertFailsWith { runBlocking { AnkiquestFeed.page(account, before = 7) } } + assertEquals(404, error.code) + assertEquals(1, requests.size) + } + + @Test + fun `a server that does not know a filter answers the whole feed`() = + runBlocking { + answer = { seen -> if (seen.query.orEmpty().contains("category")) Answer(400, "") else Answer(200, PAGE) } + val page = AnkiquestFeed.page(account, AnkiquestFeed.Category.STUDY_UPDATES) + assertEquals(4, page.items.size) + assertTrue(page.unfiltered) + assertEquals(listOf("limit=50&category=study_updates", "limit=50"), requests.map { it.query }) + } + + @Test + fun `refusals keep the server's explanation`() { + answer = { Answer(401, """{"error":"Sign in again."}""") } + val error = assertFailsWith { runBlocking { AnkiquestFeed.page(account) } } + assertEquals(401, error.code) + assertEquals("Sign in again.", error.explanation) + } + + @Test + fun `read state is sent in batches the server accepts`() = + runBlocking { + answer = { Answer(204, "") } + AnkiquestFeed.markRead(account, (1L..450L).toList() + listOf(0L, -3L, 7L)) + assertEquals(listOf(200, 200, 50), requests.map { JSONObject(it.body).getJSONArray("ids").length() }) + assertTrue(requests.all { it.method == "POST" && it.path == "/api/activity/member%20name/read" }) + + requests.clear() + AnkiquestFeed.markAllRead(account, 42) + assertEquals("""{"through":42}""", requests.single().body) + } + + @Test + fun `a reply and a cheer go to the owner's endpoints`() = + runBlocking { + answer = { seen -> + when (seen.path) { + "/api/reply/member%20name" -> Answer(200, """{"sent_to":"Cerro"}""") + "/api/react/member%20name" -> Answer(200, """{"already":false}""") + else -> Answer(404, "") + } + } + val share = AnkiquestFeed.parsePage(JSONObject(PAGE)).items[1] + assertEquals("Cerro", AnkiquestFeed.reply(account, share, " Well\ndone ")) + assertFalse(AnkiquestFeed.cheer(account, share)) + assertEquals(JSONObject().put("notification", 6).put("message", "Well done").toString(), requests[0].body) + assertEquals("""{"notification":6}""", requests[1].body) + } + + @Test + fun `sent messages say who they went to and when they were seen`() { + val sent = + AnkiquestFeed.parseSent( + JSONObject( + """{"items":[{"recipient":"cerro","id":12,"title":"Reply","body":"Thanks!","day":20754,"created_at":1793183400, + "sender":"member name","replied":false,"kind":"reply","read_at":1793183600,"challenge_id":null,"route":null, + "action_required":false}],"next_before":12}""", + ), + ) + val (recipient, item) = sent.items.single() + assertEquals("cerro", recipient) + assertEquals("Thanks!", item.body) + assertEquals(1793183600L, item.readAt) + assertEquals(12L, sent.nextBefore) + } + + @Test + fun `card images are shown only from the account's own server`() { + val share = AnkiquestFeed.parsePage(JSONObject(PAGE)).items[1] + val own = HomeAccount("https://anki.example.test", "member", "token") + assertEquals("https://anki.example.test/w/example-link-2.png", AnkiquestFeed.image(own, share)) + assertNull(AnkiquestFeed.image(HomeAccount("https://other.example.test", "member", "token"), share)) + assertNull(AnkiquestFeed.image(own, share.copy(image = "https://anki.example.test/w/example-link-2"))) + assertNull(AnkiquestFeed.image(own, share.copy(image = "http://anki.example.test/w/example-link-2.png"))) + assertNull(AnkiquestFeed.image(own, share.copy(kind = "completion"))) + } + + companion object { + /** The documented page shape (docs/mobile-api.md), with rows as the server's examples record them. */ + val PAGE = + """ + { + "items": [ + {"id": 42, "title": "Challenge invitation", "body": "alice invited you to Study together.", "day": 20718, + "created_at": 1793183600, "sender": "alice", "replied": false, "kind": "challenge_invite", "read_at": null, + "challenge_id": 8, "route": "/community#challenge-8", "action_required": true}, + {"action_required": false, "body": "7 day streak!: “A week in a row.”", "challenge_id": null, "created_at": 1793183400, + "day": 20754, "id": 6, "image": "https://anki.example.test/w/example-link-2.png", "kind": "share", "reacted": true, + "read_at": null, "replied": false, "route": null, "sender": "cerro", "title": "🎉 Cerro shared a win", + "topic": "streak", "win": 2}, + {"action_required": false, "body": "“Daily goal reached!”", "challenge_id": null, "created_at": 1793183400, + "day": 20754, "id": 8, "kind": "reaction", "read_at": null, "replied": false, "route": null, "sender": "cerro", + "title": "🎉 Cerro cheered", "win": 1}, + {"id": 9, "title": "Cerro replied", "body": "Thanks!", "day": 20754, "created_at": 1793180000, "sender": "cerro", + "replied": true, "kind": "reply", "read_at": 1793183500, "challenge_id": null, "route": null, + "action_required": false, "reply_to": {"title": "Deck complete", "body": "Spanish is done for today."}} + ], + "attention": [ + {"id": 42, "title": "Challenge invitation", "body": "alice invited you to Study together.", "day": 20718, + "created_at": 1793183600, "sender": "alice", "replied": false, "kind": "challenge_invite", "read_at": null, + "challenge_id": 8, "route": "/community#challenge-8", "action_required": true} + ], + "unread_count": 3, + "action_count": 1, + "latest_id": 42, + "retention_days": 90, + "window_days": 90, + "next_before": 5 + } + """.trimIndent() + + /** The older oldest-first notification array. */ + val LEGACY = + """ + [ + {"id": 1, "title": "Daily goal reached!", "body": "5 cards today.", "created_at": 1793183400, "sender": "", + "replied": false, "kind": "celebration", "win": 1}, + {"id": 6, "title": "🎉 Cerro shared a win", "body": "7 day streak!", "created_at": 1793183400, "sender": "cerro", + "replied": false, "kind": "share"}, + {"id": 8, "title": "🎉 Cerro cheered", "body": "“Daily goal reached!”", "created_at": 1793183400, "sender": "cerro", + "replied": false, "kind": "reaction"} + ] + """.trimIndent() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt index 24d6eed558b5..99f0d0b4dfe1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt @@ -120,7 +120,7 @@ class AnkiquestHomeActivityTest : RobolectricTest() { } @Test - fun `navigation keeps Today native and opens community pages on the website`() { + fun `navigation keeps Today and Activity native and opens community pages on the website`() { val home = launch() val nav = home.findViewById(R.id.aq_home_navigation) assertEquals(4, nav.menu.size) @@ -133,7 +133,9 @@ class AnkiquestHomeActivityTest : RobolectricTest() { assertWebsite(home, null) home.findViewById(R.id.aq_home_activity).performClick() - assertWebsite(home, AnkiquestNavigation.ACTIVITY_PATH) + val feed = assertNotNull(shadowOf(home).nextStartedActivity) + assertEquals(AnkiquestFeedActivity::class.java.name, feed.component?.className) + assertNull(feed.getStringExtra(AnkiquestFeedActivity.EXTRA_ACCOUNT)) nav.selectedItemId = R.id.ankiquest_nav_decks val decks = assertNotNull(shadowOf(home).nextStartedActivity) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestMobileNavigationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestMobileNavigationTest.kt index abcff74c7491..278b84ff632a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestMobileNavigationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestMobileNavigationTest.kt @@ -114,7 +114,7 @@ class AnkiquestMobileNavigationTest : RobolectricTest() { } @Test - fun `notifications open the goal or activity page for their owning account`() { + fun `notifications open the goal page or the native activity feed for their owning account`() { val owner = "https://anki.example.test/member%20name" val goal = JSONObject().put("id", 71).put("challenge_id", 42).put("route", "https://untrusted.test/") val intent = AnkiquestNotifier.notificationIntent(targetContext, goal, owner) @@ -129,7 +129,10 @@ class AnkiquestMobileNavigationTest : RobolectricTest() { ) val message = AnkiquestNotifier.notificationIntent(targetContext, JSONObject().put("id", 72), owner) - assertEquals("/community#activity", message.getStringExtra(AnkiquestActivity.EXTRA_PATH)) + assertEquals(ComponentName(targetContext, AnkiquestFeedActivity::class.java), message.component) + assertEquals(owner, message.getStringExtra(AnkiquestFeedActivity.EXTRA_ACCOUNT)) + assertTrue((message.flags and Intent.FLAG_ACTIVITY_NEW_TASK) != 0) + assertEquals(null, message.data) } @Test @@ -137,7 +140,7 @@ class AnkiquestMobileNavigationTest : RobolectricTest() { val today = AnkiquestNavigation.Destination.Today() val quests = AnkiquestNavigation.Destination.Today(AnkiquestHomeActivity.FOCUS_QUESTS) val week = AnkiquestNavigation.Destination.Website(AnkiquestNavigation.WEEK_PATH) - val feed = AnkiquestNavigation.Destination.Website(AnkiquestNavigation.ACTIVITY_PATH) + val feed = AnkiquestNavigation.Destination.Activity val challenges = AnkiquestNavigation.Destination.Website(AnkiquestNavigation.FRIENDS_PATH) val goal = AnkiquestNavigation.Destination.Website(AnkiquestNavigation.challengePath(42)) val cases = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestNotifierTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestNotifierTest.kt index df19359c6dce..9fe5e2528adf 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestNotifierTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestNotifierTest.kt @@ -333,9 +333,8 @@ class AnkiquestNotifierTest : RobolectricTest() { assertEquals("Sent to Cerro: Good job!", sent.extras.getString(Notification.EXTRA_TEXT)) assertNull(sent.actions) val open = shadowOf(sent.contentIntent).savedIntent - assertEquals(AnkiquestActivity::class.java.name, open.component?.className) - assertEquals("/community#activity", open.getStringExtra(AnkiquestActivity.EXTRA_PATH)) - assertEquals(account.notificationAccount, open.getStringExtra(AnkiquestActivity.EXTRA_ACCOUNT)) + assertEquals(AnkiquestFeedActivity::class.java.name, open.component?.className) + assertEquals(account.notificationAccount, open.getStringExtra(AnkiquestFeedActivity.EXTRA_ACCOUNT)) AnkiquestNotifier.onReplyFailed(targetContext, data) val failed = shadowOf(manager).getNotification(5_140_007) From 57772d358d2ed014bf077411a2c605277f014519 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 09:48:18 +0100 Subject: [PATCH 2/2] Register AnkiquestFeedActivity in the test activity list --- AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt index 8c8d6565e422..959b1ee1c19f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt @@ -30,6 +30,7 @@ import com.ichi2.anki.StoragePermissionSet import com.ichi2.anki.StudyOptionsActivity import com.ichi2.anki.account.AccountActivity import com.ichi2.anki.ankiquest.AnkiquestActivity +import com.ichi2.anki.ankiquest.AnkiquestFeedActivity import com.ichi2.anki.ankiquest.AnkiquestHomeActivity import com.ichi2.anki.ankiquest.AnkiquestShare import com.ichi2.anki.ankiquest.AnkiquestShareActivity @@ -132,6 +133,7 @@ object ActivityList { get(AccountActivity::class.java, normalIntent = { AccountActivity.getIntent(targetContext) }), get(AnkiquestActivity::class.java), get(AnkiquestHomeActivity::class.java), + get(AnkiquestFeedActivity::class.java), get(AnkiquestShareActivity::class.java) { Intent(it, AnkiquestShareActivity::class.java) .putExtra(AnkiquestShare.EXTRA_TITLE, "Level 54")