From 01698b38dc7ee36630d45d934703e902bed4f667 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 03:00:58 +0100 Subject: [PATCH 1/3] Share a conquered card with what it was, from Android's share sheet A conquered card's Share first asks whether to show the card: unticked by default, prefilled with its first field (or the label saved before), editable, and saved as the conquest's label on the server only when ticked; sharing without it removes an earlier label. On Android 14 and later the share sheet itself offers AnkiQuest friends next to the other apps; older versions keep asking first. --- .../ichi2/anki/ankiquest/AnkiquestNotifier.kt | 14 +- .../ichi2/anki/ankiquest/AnkiquestShare.kt | 117 ++++++++++++- .../anki/ankiquest/AnkiquestShareActivity.kt | 157 +++++++++++++++--- .../ichi2/anki/ankiquest/AnkiquestSharing.kt | 53 ++++++ .../res/drawable/ic_ankiquest_friends.xml | 15 ++ .../main/res/values-de/ankiquest_share.xml | 16 ++ .../main/res/values-es/ankiquest_share.xml | 16 ++ .../main/res/values-fr/ankiquest_share.xml | 16 ++ .../main/res/values-pt/ankiquest_share.xml | 16 ++ .../src/main/res/values/ankiquest_share.xml | 17 ++ .../anki/ankiquest/AnkiquestShareTest.kt | 95 +++++++++++ 11 files changed, 506 insertions(+), 26 deletions(-) create mode 100644 AnkiDroid/src/main/res/drawable/ic_ankiquest_friends.xml 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..38f08fcf22f1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt @@ -110,7 +110,19 @@ object AnkiquestNotifier { reply = answerable, ) } else if (AnkiquestShare.shareable(kind)) { - listOfNotNull(AnkiquestShare.action(context, tag, title, body, win, id, null, scope)) + listOfNotNull( + AnkiquestShare.action( + context, + tag, + title, + body, + win, + id, + null, + scope, + if (entry.isNull("topic")) null else entry.optString("topic"), + ), + ) } else { emptyList() }, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShare.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShare.kt index e17d23ec1654..65a0bda0bc68 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShare.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShare.kt @@ -7,9 +7,15 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import android.os.Build +import android.service.chooser.ChooserAction import androidx.core.app.NotificationCompat import androidx.core.content.FileProvider +import androidx.core.text.HtmlCompat +import com.ichi2.anki.CollectionManager import com.ichi2.anki.R +import kotlinx.coroutines.CancellationException import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Request import timber.log.Timber @@ -25,6 +31,14 @@ internal object AnkiquestShare { const val EXTRA_NOTIFICATION = "ankiquestShareNotification" const val EXTRA_PLACE = "ankiquestSharePlace" const val EXTRA_SCOPE = "ankiquestShareScope" + const val EXTRA_TOPIC = "ankiquestShareTopic" + + /** Set on the share sheet's own "AnkiQuest friends" entry, which sends the win without asking again. */ + const val EXTRA_DESTINATION = "ankiquestShareDestination" + const val DESTINATION_FRIENDS = "friends" + + /** The server keeps labels of conquered cards to one line of this many characters. */ + const val MAX_LABEL = 120 /** Cards are 1080×1080 PNGs; anything far larger is not a card. */ private const val MAX_IMAGE_BYTES = 4L * 1024 * 1024 @@ -62,6 +76,7 @@ internal object AnkiquestShare { notification: Long?, place: Int?, scope: String?, + topic: String? = null, ): NotificationCompat.Action? { val current = AnkiquestHomeData.account() ?: return null if (current.token.isEmpty() || (scope != null && current.scope != scope)) return null @@ -74,6 +89,7 @@ internal object AnkiquestShare { win?.let { intent.putExtra(EXTRA_WIN, it) } notification?.let { intent.putExtra(EXTRA_NOTIFICATION, it) } place?.let { intent.putExtra(EXTRA_PLACE, it) } + topic?.let { intent.putExtra(EXTRA_TOPIC, it) } return NotificationCompat.Action .Builder( R.drawable.ic_share_notify, @@ -90,6 +106,7 @@ internal object AnkiquestShare { fun linkChooser( context: Context, link: Link, + friends: PendingIntent? = null, ): Intent { val send = Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, link.text) val card = @@ -108,7 +125,7 @@ internal object AnkiquestShare { .putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(context, "${context.packageName}.apkgfileprovider", card)) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - return chooser(context, send) + return chooser(context, send, friends) } /** The share sheet with a line about the win and an invitation to the server, for servers that keep no wins. */ @@ -116,21 +133,109 @@ internal object AnkiquestShare { context: Context, title: String, body: String, + friends: PendingIntent? = null, ): Intent { val language = AnkiquestLanguage.context(context) val text = Ankiquest.serverUrl()?.let { language.getString(R.string.ankiquest_share_text, title, body, it) } ?: language.getString(R.string.ankiquest_share_text_offline, title, body) - return chooser(context, Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text)) + return chooser(context, Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text), friends) } + /** + * Android's share sheet for [send]. With [friends], and on Android 14 or later, the sheet itself + * also offers "AnkiQuest friends", so friends and other apps are one choice in one place. + */ private fun chooser( context: Context, send: Intent, - ): Intent = - Intent - .createChooser(send, AnkiquestLanguage.context(context).getString(R.string.ankiquest_share_title)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + friends: PendingIntent?, + ): Intent { + val language = AnkiquestLanguage.context(context) + val chooser = + Intent + .createChooser(send, language.getString(R.string.ankiquest_share_title)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (friends != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val action = + ChooserAction + .Builder( + Icon.createWithResource(context, R.drawable.ic_ankiquest_friends), + language.getString(R.string.ankiquest_share_friends_target), + friends, + ).build() + chooser.putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(action)) + } + return chooser + } + + /** Whether [chooser] can offer AnkiQuest friends itself, so no separate question is needed first. */ + fun sheetOffersFriends(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE + + /** The share sheet's entry for AnkiQuest friends: [share] again, told to send the win to friends. */ + fun friendsTarget( + context: Context, + share: Intent, + ): PendingIntent = + PendingIntent.getActivity( + context, + share.getLongExtra(EXTRA_WIN, 0).toInt(), + Intent(share) + .setClass(context, AnkiquestShareActivity::class.java) + .putExtra(EXTRA_DESTINATION, DESTINATION_FRIENDS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + /** The review id in a conquered card's win key `conquered:{at}`, or null for any other key. */ + fun conqueredAt(key: String?): Long? = key?.removePrefix("conquered:")?.takeIf { it != key }?.toLongOrNull() + + /** + * A card's first field as one line of plain text, at most [MAX_LABEL] characters, to offer as the + * conquered card's label. Sounds and markup are dropped, as the desktop add-on does. + */ + fun cardLabel(field: String): String { + val text = field.replace(Regex("""\[sound:[^\]]*\]"""), " ").replace(Regex("<[^>]*>"), " ") + return oneLine(HtmlCompat.fromHtml(text.replace("<", "<"), HtmlCompat.FROM_HTML_MODE_LEGACY).toString()) + } + + /** The label to share: the player's [text] as one line, only when they chose to [include] the card and wrote something. */ + fun chosenLabel( + include: Boolean, + text: String?, + ): String? = oneLine(text.orEmpty()).takeIf { include && it.isNotEmpty() } + + private fun oneLine(text: String): String = + text + .replace(' ', ' ') + .split(Regex("""\s+""")) + .filter { it.isNotEmpty() } + .joinToString(" ") + .take(MAX_LABEL) + + /** What the card answered at review [at] says on its front, for the player to choose to share; null when it is gone. */ + suspend fun conqueredCardText(at: Long): String? = + try { + CollectionManager.withCol { + val cid = db.queryLongScalar("select cid from revlog where id = ?", at) + if (cid == 0L) { + null + } else { + cardLabel( + getCard(cid) + .note(this) + .fields + .firstOrNull() + .orEmpty(), + ).ifEmpty { null } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "ankiquest conquered card text unavailable") + null + } /** * A card image from the server, at most [MAX_IMAGE_BYTES]. Cards are public, so no credentials are diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShareActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShareActivity.kt index b0c938a7fdf8..da3ba5584e51 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShareActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestShareActivity.kt @@ -2,9 +2,15 @@ package com.ichi2.anki.ankiquest +import android.app.PendingIntent import android.content.Context import android.os.Bundle +import android.text.InputFilter +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.appcompat.widget.AppCompatEditText import androidx.lifecycle.lifecycleScope import com.ichi2.anki.AnkiActivity import com.ichi2.anki.R @@ -16,7 +22,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -/** Asks where a win from a notification should go: to friends in AnkiQuest, or anywhere else. */ +/** + * Passes on a win from a notification: to friends in AnkiQuest, or anywhere else through Android's + * share sheet. A conquered card first asks whether to show what the card was. + */ class AnkiquestShareActivity : AnkiActivity() { override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { @@ -40,6 +49,37 @@ class AnkiquestShareActivity : AnkiActivity() { finish() return } + val share = Share(win, notification, place, title, body, scope) + when { + intent.getStringExtra(AnkiquestShare.EXTRA_DESTINATION) == AnkiquestShare.DESTINATION_FRIENDS -> { + shareWithFriends(applicationContext, share) + finish() + } + win != null && intent.getStringExtra(AnkiquestShare.EXTRA_TOPIC) == "conquered" -> askAboutCard(current, win, share) + else -> chooseDestination(share) + } + } + + /** What is being shared, as the notification's Share button handed it over. */ + private data class Share( + val win: Long?, + val notification: Long?, + val place: Int?, + val title: String, + val body: String, + val scope: String, + ) + + /** + * Where the win goes. From Android 14 the share sheet itself offers AnkiQuest friends next to + * the other apps; older versions ask first, then open the share sheet for other apps. + */ + private fun chooseDestination(share: Share) { + if (AnkiquestShare.sheetOffersFriends()) { + shareElsewhere(share, AnkiquestShare.friendsTarget(this, intent)) + return + } + val language = AnkiquestLanguage.context(this) val choices = arrayOf( language.getString(R.string.ankiquest_share_friends), @@ -51,35 +91,117 @@ class AnkiquestShareActivity : AnkiActivity() { .setTitle(language.getString(R.string.ankiquest_share_title)) .setItems(choices) { _, which -> if (which == 0) { - shareWithFriends(applicationContext, win, notification, place, scope) + shareWithFriends(applicationContext, share) } else { leaving = true - shareElsewhere(win, title, body, scope) + shareElsewhere(share, null) } }.setOnDismissListener { if (!leaving) finish() } .show() } + /** + * A conquered card can say what the card was. Nothing about the card leaves the phone unless the + * player ticks the box: the text starts as the card's first field, or the label they gave it + * before, and they see and can change exactly what is shared. Sharing without the box ticked + * takes an earlier label off the card and its page. + */ + private fun askAboutCard( + account: HomeAccount, + win: Long, + share: Share, + ) { + lifecycleScope.launch { + val language = AnkiquestLanguage.context(this@AnkiquestShareActivity) + val known = + try { + AnkiquestShare.conqueredAt(AnkiquestSharing.winKey(account, win))?.let { at -> + at to AnkiquestSharing.conquestLabel(account, at) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "ankiquest conquered card unavailable; sharing it without the card") + null + } + if (known == null) { + chooseDestination(share) + return@launch + } + val (at, saved) = known + val suggestion = saved ?: AnkiquestShare.conqueredCardText(at).orEmpty() + val include = + AppCompatCheckBox(this@AnkiquestShareActivity).apply { + text = language.getString(R.string.ankiquest_share_card_include) + isChecked = saved != null + } + val field = + AppCompatEditText(this@AnkiquestShareActivity).apply { + setText(suggestion) + hint = language.getString(R.string.ankiquest_share_card_hint) + isSingleLine = true + filters = arrayOf(InputFilter.LengthFilter(AnkiquestShare.MAX_LABEL)) + isEnabled = include.isChecked + } + include.setOnCheckedChangeListener { _, checked -> field.isEnabled = checked } + val padding = (20 * resources.displayMetrics.density).toInt() + val content = + LinearLayout(this@AnkiquestShareActivity).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding / 2, padding, 0) + addView(TextView(context).apply { text = language.getString(R.string.ankiquest_share_card_explanation) }) + addView(include) + addView(field) + } + var leaving = false + AlertDialog + .Builder(this@AnkiquestShareActivity) + .setTitle(language.getString(R.string.ankiquest_share_card_title)) + .setView(content) + .setPositiveButton(language.getString(R.string.ankiquest_share_next)) { _, _ -> + leaving = true + val label = AnkiquestShare.chosenLabel(include.isChecked, field.text?.toString()) + lifecycleScope.launch { + try { + if (label != saved) AnkiquestSharing.setConquestLabel(account, at, label) + chooseDestination(share) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "ankiquest conquered card label failed") + showThemedToast( + this@AnkiquestShareActivity, + language.getString(R.string.ankiquest_share_card_failed), + shortLength = false, + ) + finish() + } + } + }.setNegativeButton(language.getString(android.R.string.cancel), null) + .setOnDismissListener { if (!leaving) finish() } + .show() + } + } + /** * Fetches the win's link and card away from the main thread, then opens the share sheet and closes. - * Without a [win] the server keeps no card, so only a line of text is shared. + * Without a win the server keeps no card, so only a line of text is shared. [friends], when set, + * is the sheet's own entry for AnkiQuest friends. */ private fun shareElsewhere( - win: Long?, - title: String, - body: String, - scope: String, + share: Share, + friends: PendingIntent?, ) { lifecycleScope.launch { val language = AnkiquestLanguage.context(this@AnkiquestShareActivity) val failure = try { val chooser = - if (win == null) { - AnkiquestShare.textChooser(applicationContext, title, body) + if (share.win == null) { + AnkiquestShare.textChooser(applicationContext, share.title, share.body, friends) } else { - val link = Ankiquest.shareWinLink(win, scope) - withContext(Dispatchers.IO) { AnkiquestShare.linkChooser(applicationContext, link) } + val link = Ankiquest.shareWinLink(share.win, share.scope) + withContext(Dispatchers.IO) { AnkiquestShare.linkChooser(applicationContext, link, friends) } } startActivity(chooser) null @@ -98,20 +220,17 @@ class AnkiquestShareActivity : AnkiActivity() { private fun shareWithFriends( context: Context, - win: Long?, - notification: Long?, - place: Int?, - scope: String, + share: Share, ) { applicationScope.launch { val language = AnkiquestLanguage.context(context) val message = try { val friends = - if (win != null) { - Ankiquest.shareWinWithFriends(win, scope) + if (share.win != null) { + Ankiquest.shareWinWithFriends(share.win, share.scope) } else { - Ankiquest.shareWin(notification, place, scope) + Ankiquest.shareWin(share.notification, share.place, share.scope) } if (friends == 0) { language.getString(R.string.ankiquest_share_nobody) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestSharing.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestSharing.kt index e9387631310e..07bb7d7af271 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestSharing.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestSharing.kt @@ -4,6 +4,7 @@ package com.ichi2.anki.ankiquest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray import org.json.JSONObject /** Which wins reach friends, and the daily goal, as kept by the server for every client of one player. */ @@ -64,6 +65,58 @@ internal object AnkiquestSharing { false } + /** The server's event key of [account]'s win [id], such as `conquered:1793183400000`, or null when the server has no such win. */ + suspend fun winKey( + account: HomeAccount, + id: Long, + ): String? = + call(account, "GET", "api/wins/${account.encodedUser}?limit=1&before=${id + 1}") + .optJSONArray("wins") + ?.optJSONObject(0) + ?.takeIf { it.optLong("id") == id } + ?.optString("key") + ?.ifEmpty { null } + + /** The label [account] gave the card they conquered at [at], shown with it wherever it is shared; null when there is none. */ + suspend fun conquestLabel( + account: HomeAccount, + at: Long, + ): String? = + call(account, "GET", "api/conquests/${account.encodedUser}/$at") + .optString("label") + .ifBlank { null } + + /** Shows [label] with the card conquered at [at] on its shared card, page and friends' messages, or stops showing one when null. */ + suspend fun setConquestLabel( + account: HomeAccount, + at: Long, + label: String?, + ) { + val path = "api/conquests/${account.encodedUser}/$at/label" + if (label == null) { + call(account, "DELETE", path) + } else { + call(account, "POST", path, JSONObject().put("label", label)) + } + } + + /** Tells [recipients] that [account] finished the deck [name] today; answers how many heard it now. */ + suspend fun shareFinishedDeck( + account: HomeAccount, + id: Long, + name: String, + recipients: List, + ): Int = + call( + account, + "POST", + "api/decks/${account.encodedUser}/share", + JSONObject() + .put("id", id.toString()) + .put("name", name) + .put("recipients", JSONArray(recipients)), + ).optInt("recipients") + /** One request for the captured [account], which must still be the signed-in one when it is sent and when it answers. */ private suspend fun call( account: HomeAccount, diff --git a/AnkiDroid/src/main/res/drawable/ic_ankiquest_friends.xml b/AnkiDroid/src/main/res/drawable/ic_ankiquest_friends.xml new file mode 100644 index 000000000000..3b5c5ac57e4b --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_ankiquest_friends.xml @@ -0,0 +1,15 @@ + + + + diff --git a/AnkiDroid/src/main/res/values-de/ankiquest_share.xml b/AnkiDroid/src/main/res/values-de/ankiquest_share.xml index ad0608d17979..a1745187158b 100644 --- a/AnkiDroid/src/main/res/values-de/ankiquest_share.xml +++ b/AnkiDroid/src/main/res/values-de/ankiquest_share.xml @@ -40,4 +40,20 @@ Ich lerne mit AnkiQuest. Frag mich Freunden automatisch sagen + AnkiQuest-Freunde + Gemeisterte Karte teilen + Möchtest du zeigen, was auf der Karte stand? Freunde und alle mit dem Link sehen diesen Text. Lass das Kästchen leer, um ohne ihn zu teilen. + Karte zeigen + Was auf der Karte steht + Teilen… + Der Text der Karte konnte nicht gespeichert werden, deshalb wurde nichts geteilt. Prüfe deine Verbindung und versuch es noch einmal. + Du hast %1$s für heute abgeschlossen. Freunden Bescheid sagen? + Bescheid sagen + Jetzt nicht + Nicht mehr fragen + Du wirst nicht mehr gefragt. Unter „Freunde und Teilen“ in den AnkiQuest-Einstellungen kannst du es wieder einschalten. + Niemand Neues hat es erfahren: Sie wurden heute schon benachrichtigt oder haben Stapel-Benachrichtigungen ausgeschaltet. + Dein AnkiQuest-Server kann abgeschlossene Stapel noch nicht teilen. + Teilen fertiger Stapel anbieten + Wenn du einen Stapel für heute abschließt, fragen, welchen Freunden du Bescheid sagen möchtest diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_share.xml b/AnkiDroid/src/main/res/values-es/ankiquest_share.xml index 575ededddef5..ebf83fd6fb70 100644 --- a/AnkiDroid/src/main/res/values-es/ankiquest_share.xml +++ b/AnkiDroid/src/main/res/values-es/ankiquest_share.xml @@ -40,4 +40,20 @@ Estudio con AnkiQuest. Preguntarme Avisar a mis amigos automáticamente + Amigos de AnkiQuest + Comparte tu tarjeta conquistada + ¿Quieres mostrar qué decía la tarjeta? Tus amigos y cualquiera con el enlace verán este texto. Deja la casilla sin marcar para compartir sin él. + Mostrar la tarjeta + Lo que dice la tarjeta + Compartir… + No se pudo guardar el texto de la tarjeta, así que no se compartió nada. Comprueba tu conexión e inténtalo de nuevo. + Terminaste %1$s por hoy. ¿Avisar a tus amigos? + Avisar + Ahora no + No volver a preguntar + No se te volverá a preguntar. Puedes activarlo de nuevo en los ajustes de AnkiQuest, en Amigos y contenido compartido. + Nadie nuevo se enteró: ya se les avisó hoy o desactivaron los avisos de mazos. + Tu servidor de AnkiQuest todavía no puede compartir mazos terminados. + Ofrecer compartir mazos terminados + Al terminar un mazo por hoy, preguntar a qué amigos avisar diff --git a/AnkiDroid/src/main/res/values-fr/ankiquest_share.xml b/AnkiDroid/src/main/res/values-fr/ankiquest_share.xml index b6b0ca8a6d18..3897e63df1c7 100644 --- a/AnkiDroid/src/main/res/values-fr/ankiquest_share.xml +++ b/AnkiDroid/src/main/res/values-fr/ankiquest_share.xml @@ -40,4 +40,20 @@ Je révise avec AnkiQuest. Me demander Prévenir mes amis automatiquement + Amis AnkiQuest + Partager ta carte maîtrisée + Tu veux montrer ce que disait la carte ? Tes amis et toute personne ayant le lien verront ce texte. Laisse la case décochée pour partager sans lui. + Montrer la carte + Ce que dit la carte + Partager… + Impossible d’enregistrer le texte de la carte, donc rien n’a été partagé. Vérifie ta connexion et réessaie. + Tu as terminé %1$s pour aujourd’hui. Prévenir tes amis ? + Les prévenir + Pas maintenant + Ne plus demander + On ne te le demandera plus. Tu peux le réactiver dans les réglages AnkiQuest, sous Amis et partage. + Personne de nouveau n’a été prévenu : ils l’ont déjà été aujourd’hui, ou ont coupé les alertes de paquets. + Ton serveur AnkiQuest ne peut pas encore partager les paquets terminés. + Proposer de partager les paquets finis + Quand tu termines un paquet pour aujourd’hui, demander quels amis prévenir diff --git a/AnkiDroid/src/main/res/values-pt/ankiquest_share.xml b/AnkiDroid/src/main/res/values-pt/ankiquest_share.xml index cb0a109726e9..6ba62522c55d 100644 --- a/AnkiDroid/src/main/res/values-pt/ankiquest_share.xml +++ b/AnkiDroid/src/main/res/values-pt/ankiquest_share.xml @@ -40,4 +40,20 @@ Estudo com o AnkiQuest. Perguntar-me Avisar os amigos automaticamente + Amigos do AnkiQuest + Partilhar o cartão conquistado + Queres mostrar o que dizia o cartão? Os teus amigos e quem tiver a ligação verão este texto. Deixa a caixa por marcar para partilhar sem ele. + Mostrar o cartão + O que diz o cartão + Partilhar… + Não foi possível guardar o texto do cartão, por isso nada foi partilhado. Verifica a ligação e tenta outra vez. + Terminaste %1$s por hoje. Avisar os amigos? + Avisar + Agora não + Não perguntar mais + Não voltarás a ser questionado. Podes reativar nas definições do AnkiQuest, em Amigos e partilha. + Ninguém novo soube: já foram avisados hoje ou desligaram os alertas de baralhos. + O teu servidor AnkiQuest ainda não consegue partilhar baralhos terminados. + Propor partilhar baralhos terminados + Quando terminas um baralho por hoje, perguntar que amigos avisar diff --git a/AnkiDroid/src/main/res/values/ankiquest_share.xml b/AnkiDroid/src/main/res/values/ankiquest_share.xml index 6a8da764c566..b96218d654ef 100644 --- a/AnkiDroid/src/main/res/values/ankiquest_share.xml +++ b/AnkiDroid/src/main/res/values/ankiquest_share.xml @@ -61,4 +61,21 @@ I study with AnkiQuest. Ask me Tell friends automatically + AnkiQuest friends + Share your conquered card + Want to show what the card was? Friends and anyone with the link will see this text. Leave the box unticked to share without it. + Show the card + What the card says + Share… + Couldn’t save the card’s text, so nothing was shared. Check your connection and try again. + You finished %1$s for today. Tell friends? + Tell them + Not now + Don’t ask again + You won’t be asked again. Turn it back on in AnkiQuest settings under Friends & sharing. + Nobody new heard it: they were told today already, or they turned off deck alerts. + Your AnkiQuest server can’t share finished decks yet. + Ask to share finished decks + When you finish a deck for the day, ask which friends to tell + ankiquestAskToShareDecks diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt index f2d42beba23b..e04dfc3b1aed 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.ankiquest import android.content.Intent +import android.service.chooser.ChooserAction import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.AnkiDroidApp @@ -40,6 +41,7 @@ class AnkiquestShareTest : RobolectricTest() { private lateinit var server: HttpServer private lateinit var base: String private val posted = CopyOnWriteArrayList() + private val asked = CopyOnWriteArrayList() private val imageAuthorizations = CopyOnWriteArrayList() private val card = "not really a png".toByteArray() @@ -63,6 +65,25 @@ class AnkiquestShareTest : RobolectricTest() { } respond(exchange, 200, answer.toString().toByteArray()) } + server.createContext("/api/wins/hill") { exchange -> + asked.add("${exchange.requestMethod} ${exchange.requestURI}") + val wins = JSONObject().put("wins", org.json.JSONArray().put(JSONObject().put("id", 17).put("key", "conquered:1793183400000"))) + respond(exchange, 200, wins.toString().toByteArray()) + } + server.createContext("/api/conquests/hill/") { exchange -> + val body = exchange.requestBody.bufferedReader().use { it.readText() } + asked.add("${exchange.requestMethod} ${exchange.requestURI} $body".trim()) + if (exchange.requestMethod == "GET") { + respond(exchange, 200, JSONObject().put("label", "der Hund").toString().toByteArray()) + } else { + respond(exchange, 204, ByteArray(0)) + } + } + server.createContext("/api/decks/hill/share") { exchange -> + val body = exchange.requestBody.bufferedReader().use { it.readText() } + asked.add("${exchange.requestMethod} ${exchange.requestURI} $body") + respond(exchange, 200, JSONObject().put("recipients", 2).toString().toByteArray()) + } server.createContext("/w/token.png") { exchange -> imageAuthorizations.add(exchange.requestHeaders.getFirst("Authorization")) respond(exchange, 200, card) @@ -197,6 +218,80 @@ class AnkiquestShareTest : RobolectricTest() { assertNull(AnkiquestShare.action(targetContext, 1, "Level 54", "", 17, 42, null, null)) } + @Test + fun `a conquered card's win, label and a finished deck go to the server`() = + runBlocking { + val account = AnkiquestHomeData.account()!! + assertEquals("conquered:1793183400000", AnkiquestSharing.winKey(account, 17)) + assertNull(AnkiquestSharing.winKey(account, 16), "a page that starts with another win") + assertEquals("der Hund", AnkiquestSharing.conquestLabel(account, 1793183400000)) + AnkiquestSharing.setConquestLabel(account, 1793183400000, "der Hund – the dog") + AnkiquestSharing.setConquestLabel(account, 1793183400000, null) + assertEquals(2, AnkiquestSharing.shareFinishedDeck(account, 7, "Japanese::Kanji", listOf("cerro", "ana"))) + assertEquals( + listOf( + "GET /api/wins/hill?limit=1&before=18", + "GET /api/wins/hill?limit=1&before=17", + "GET /api/conquests/hill/1793183400000", + "DELETE /api/conquests/hill/1793183400000/label", + ), + asked.take(5).filterIndexed { index, _ -> index != 3 }, + ) + assertTrue(asked[3].startsWith("POST /api/conquests/hill/1793183400000/label ")) + assertEquals("der Hund – the dog", JSONObject(asked[3].substringAfter("label ")).getString("label")) + val deck = JSONObject(asked[5].substringAfter("share ")) + assertEquals("7", deck.getString("id")) + assertEquals("Japanese::Kanji", deck.getString("name")) + assertEquals(listOf("cerro", "ana"), deck.getJSONArray("recipients").strings()) + } + + @Test + fun `a card's text is offered as one plain line, and shared only when chosen`() { + assertEquals("der Hund the dog", AnkiquestShare.cardLabel("der Hund[sound:hund.mp3]
the dog")) + assertEquals("a < b & c", AnkiquestShare.cardLabel("a < b & c")) + assertEquals(AnkiquestShare.MAX_LABEL, AnkiquestShare.cardLabel("x".repeat(500)).length) + assertNull(AnkiquestShare.chosenLabel(false, "der Hund"), "the box is unticked by default") + assertNull(AnkiquestShare.chosenLabel(true, " \n ")) + assertEquals("der Hund", AnkiquestShare.chosenLabel(true, " der\nHund ")) + assertEquals(1793183400000, AnkiquestShare.conqueredAt("conquered:1793183400000")) + assertNull(AnkiquestShare.conqueredAt("level:5")) + assertNull(AnkiquestShare.conqueredAt(null)) + } + + @Test + fun `the share sheet offers AnkiQuest friends next to the other apps`() { + val share = + Intent(targetContext, AnkiquestShareActivity::class.java) + .putExtra(AnkiquestShare.EXTRA_WIN, 17L) + .putExtra(AnkiquestShare.EXTRA_TITLE, "Level 54") + val friends = AnkiquestShare.friendsTarget(targetContext, share) + val target = shadowOf(friends).savedIntent + assertEquals(AnkiquestShare.DESTINATION_FRIENDS, target.getStringExtra(AnkiquestShare.EXTRA_DESTINATION)) + assertEquals(17, target.getLongExtra(AnkiquestShare.EXTRA_WIN, 0)) + assertEquals("Level 54", target.getStringExtra(AnkiquestShare.EXTRA_TITLE)) + assertTrue(AnkiquestShare.sheetOffersFriends()) + val chooser = AnkiquestShare.textChooser(targetContext, "Level 54", "You reached level 54.", friends) + val actions = chooser.getParcelableArrayExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, ChooserAction::class.java) + assertEquals(listOf("AnkiQuest friends"), actions?.map { it.label.toString() }) + assertEquals(friends, actions?.single()?.action) + assertFalse(AnkiquestShare.textChooser(targetContext, "Level 54", "", null).hasExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS)) + } + + @Test + @Config(sdk = [33]) + fun `before Android 14 the share sheet is for other apps and friends are asked about first`() { + assertFalse(AnkiquestShare.sheetOffersFriends()) + val friends = AnkiquestShare.friendsTarget(targetContext, Intent(targetContext, AnkiquestShareActivity::class.java)) + val chooser = AnkiquestShare.textChooser(targetContext, "Level 54", "", friends) + assertFalse(chooser.hasExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS)) + } + + @Test + fun `a conquered card's share button says so`() { + val action = assertNotNull(AnkiquestShare.action(targetContext, 1, "Card conquered", "", 17, 42, null, null, "conquered")) + assertEquals("conquered", shadowOf(action.actionIntent).savedIntent.getStringExtra(AnkiquestShare.EXTRA_TOPIC)) + } + @Test fun `place sharing is for climbing, not for slipping or staying`() { val before = listOf("a", "b", "c", "me") From ea56507efbf221d2bbbbd023ad4d4d83b31b0dc3 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 03:01:35 +0100 Subject: [PATCH 2/3] Ask which friends to tell after finishing a deck for the day After a review session that finishes the selected deck, once per deck and Anki day, the app lists the people in the player's groups (none ticked) and sends the completion to those picked through the server. Decks that already announce themselves to chosen people are left alone. A switch in Friends & sharing turns the question off, as does Don't ask again. --- .../ankiquest/AnkiquestDeckPickerHooks.kt | 23 ++- .../anki/ankiquest/AnkiquestDeckShare.kt | 172 ++++++++++++++++++ .../res/xml/preferences_ankiquest_sharing.xml | 5 + .../analytics/PreferencesAnalyticsTest.kt | 1 + .../anki/ankiquest/AnkiquestDeckShareTest.kt | 87 +++++++++ .../preferences/AnkiquestSettingsMenuTest.kt | 1 + 6 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckShare.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckShareTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt index 8219fb63858b..d8deca377262 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt @@ -61,7 +61,8 @@ object AnkiquestDeckPickerHooks { } /** - * Shows the study session summary after a review session. + * Shows the study session summary after a review session, then asks whether to tell friends + * about a deck finished for the day. * * @return true if AnkiQuest consumed the result, in which case the caller skips its own handling. */ @@ -70,26 +71,31 @@ object AnkiquestDeckPickerHooks { resultCode: Int, openReviewer: () -> Unit, ): Boolean { - if (!AnkiquestNavigation.enabled() || - (resultCode != AbstractFlashcardViewer.RESULT_DEFAULT && resultCode != AbstractFlashcardViewer.RESULT_NO_MORE_CARDS) - ) { + val ended = resultCode == AbstractFlashcardViewer.RESULT_DEFAULT || resultCode == AbstractFlashcardViewer.RESULT_NO_MORE_CARDS + if (!AnkiquestNavigation.enabled() || !ended) { + if (ended) activity.launchCatchingTask { AnkiquestDeckShare.offer(activity) } return false } activity.launchCatchingTask { val summary = AnkiquestStudySession.finish() if (summary != null && activity.sharedPrefs().getBoolean(AnkiquestNavigation.PREF_SESSION_SUMMARY, true)) { - showSummary(activity, summary, openReviewer) + showSummary(activity, summary, openReviewer) { activity.launchCatchingTask { AnkiquestDeckShare.offer(activity) } } + } else { + AnkiquestDeckShare.offer(activity) } activity.fragment?.refreshInterface() } return true } + /** [then] runs when the summary is closed without going on to study or to the progress page. */ private fun showSummary( activity: DeckPicker, summary: AnkiquestStudySession.Summary, onContinue: () -> Unit, + then: () -> Unit, ) { + var elsewhere = false val neverAgain = AppCompatCheckBox(activity).apply { setText(R.string.ankiquest_summary_dont_show_again) @@ -129,15 +135,20 @@ object AnkiquestDeckPickerHooks { ), ).setNegativeButton(CommonString.multimedia_editor_field_editing_done, null) .setNeutralButton(R.string.ankiquest_summary_progress) { _, _ -> + elsewhere = true activity.startActivity(AnkiquestActivity.intent(activity, null)) } if (summary.remaining > 0) { - builder.setPositiveButton(R.string.ankiquest_summary_continue) { _, _ -> onContinue() } + builder.setPositiveButton(R.string.ankiquest_summary_continue) { _, _ -> + elsewhere = true + onContinue() + } } builder.show().setOnDismissListener { if (neverAgain.isChecked) { activity.sharedPrefs().edit { putBoolean(AnkiquestNavigation.PREF_SESSION_SUMMARY, false) } } + if (!elsewhere) then() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckShare.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckShare.kt new file mode 100644 index 000000000000..d96cde7f9c2e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckShare.kt @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.content.Context +import android.content.SharedPreferences +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.R +import com.ichi2.anki.common.coroutines.applicationScope +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.common.utils.android.showThemedToast +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import timber.log.Timber + +/** + * Right after a deck is finished for the day, asks whether to tell friends and which ones. Decks + * already announced to chosen people every day are left to the server; players can turn the + * question off in the sharing settings. Nobody is ticked to begin with, and the deck's name only + * leaves the phone for the friends the player picks. + */ +internal object AnkiquestDeckShare { + const val PREF_ASK = "ankiquestAskToShareDecks" + private const val PREF_ASKED = "ankiquestDeckShareAsked" + + data class Recipient( + val user: String, + val display: String, + ) + + /** The question for one finished deck: its [name] and the people the player may tell. */ + data class Prompt( + val deck: Long, + val name: String, + val recipients: List, + ) + + /** Whether the question is on; it is unless the player turned it off. */ + fun enabled(prefs: SharedPreferences = AnkiDroidApp.sharedPrefs()): Boolean = prefs.getBoolean(PREF_ASK, true) + + /** + * The question to ask about [deck] from the server's deck settings (`GET /api/decks`), or null + * when the deck already tells its chosen people by itself or there is nobody to tell. + */ + fun prompt( + deck: Long, + name: String, + settings: JSONObject, + ): Prompt? { + val automatic = + settings.optJSONArray("decks")?.objects().orEmpty().any { + it.optString("id") == deck.toString() && + it.optBoolean("enabled") && + (it.optJSONArray("recipients")?.length() ?: 0) > 0 + } + if (automatic) return null + val recipients = + settings.optJSONArray("recipients")?.objects().orEmpty().mapNotNull { person -> + val user = person.optString("user").ifEmpty { return@mapNotNull null } + Recipient(user, person.optString("display").ifEmpty { user }) + } + return if (recipients.isEmpty()) null else Prompt(deck, name, recipients) + } + + /** True the first time it is asked for [account], [deck] and Anki [day]; it keeps only the current day's answers. */ + fun firstAsk( + prefs: SharedPreferences, + account: String, + deck: Long, + day: Long, + ): Boolean { + val asked = prefs.getStringSet(PREF_ASKED, emptySet()).orEmpty() + val entry = "$account:$day:$deck" + if (entry in asked) return false + prefs.edit { putStringSet(PREF_ASKED, asked.filter { it.contains(":$day:") }.toSet() + entry) } + return true + } + + /** + * Asks about the selected deck when it was just finished for the day, once per deck and day. + * Every failure only means no question: the study session itself is already done. + */ + suspend fun offer(context: Context) { + try { + val prefs = AnkiDroidApp.sharedPrefs() + if (!enabled(prefs)) return + val account = AnkiquestHomeData.account()?.takeIf { it.token.isNotEmpty() } ?: return + val finished = + withCol { + val now = TimeManager.time.intTimeMS() + val deck = decks.selected() + val state = AnkiquestDecks.dailyStates(this, now)[deck] + if (state?.status != DailyDeckStatus.DONE) { + null + } else { + Triple(deck, decks.name(deck), AnkiquestDecks.studyDay(this, now)) + } + } ?: return + val (deck, name, day) = finished + if (!firstAsk(prefs, account.scope, deck, day)) return + val settings = + withContext(Dispatchers.IO) { + AnkiquestApi.json(account, AnkiquestApi.request(account, "api/decks/${account.encodedUser}").build()) + } + val prompt = prompt(deck, name, settings) ?: return + withContext(Dispatchers.Main) { show(context, account, prompt) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "ankiquest finished deck question skipped") + } + } + + private fun show( + context: Context, + account: HomeAccount, + prompt: Prompt, + ) { + val language = AnkiquestLanguage.context(context) + val picked = BooleanArray(prompt.recipients.size) + AlertDialog + .Builder(context) + .setTitle(language.getString(R.string.ankiquest_deck_share_title, prompt.name)) + .setMultiChoiceItems(prompt.recipients.map { it.display }.toTypedArray(), picked) { _, which, checked -> + picked[which] = checked + }.setPositiveButton(language.getString(R.string.ankiquest_deck_share_send)) { _, _ -> + val recipients = prompt.recipients.filterIndexed { index, _ -> picked[index] }.map { it.user } + if (recipients.isNotEmpty()) send(context.applicationContext, account, prompt, recipients) + }.setNegativeButton(language.getString(R.string.ankiquest_deck_share_not_now), null) + .setNeutralButton(language.getString(R.string.ankiquest_deck_share_never)) { _, _ -> + AnkiDroidApp.sharedPrefs().edit { putBoolean(PREF_ASK, false) } + showThemedToast(context, language.getString(R.string.ankiquest_deck_share_never_done), shortLength = false) + }.show() + } + + private fun send( + context: Context, + account: HomeAccount, + prompt: Prompt, + recipients: List, + ) { + applicationScope.launch { + val language = AnkiquestLanguage.context(context) + val message = + try { + val told = AnkiquestSharing.shareFinishedDeck(account, prompt.deck, prompt.name, recipients) + if (told == 0) { + language.getString(R.string.ankiquest_deck_share_nobody) + } else { + language.resources.getQuantityString(R.plurals.ankiquest_share_sent, told, told) + } + } catch (e: CancellationException) { + throw e + } catch (e: AnkiquestHttpException) { + Timber.w(e, "ankiquest finished deck share refused") + language.getString( + if (e.code == 404 || e.code == 405) R.string.ankiquest_deck_share_unsupported else R.string.ankiquest_share_failed, + ) + } catch (e: Exception) { + Timber.w(e, "ankiquest finished deck share failed") + language.getString(R.string.ankiquest_share_failed) + } + launch(Dispatchers.Main) { showThemedToast(context, message, shortLength = false) } + } + } +} diff --git a/AnkiDroid/src/main/res/xml/preferences_ankiquest_sharing.xml b/AnkiDroid/src/main/res/xml/preferences_ankiquest_sharing.xml index f04a511ab261..20f84cffd36d 100644 --- a/AnkiDroid/src/main/res/xml/preferences_ankiquest_sharing.xml +++ b/AnkiDroid/src/main/res/xml/preferences_ankiquest_sharing.xml @@ -30,6 +30,11 @@ + Date: Wed, 4 Nov 2026 07:13:08 +0100 Subject: [PATCH 3/3] Mark the share sheet tests' API levels so lint passes --- .../java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt index e04dfc3b1aed..98fe308ccbba 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestShareTest.kt @@ -2,10 +2,13 @@ package com.ichi2.anki.ankiquest +import android.annotation.SuppressLint import android.content.Intent +import android.os.Build import android.service.chooser.ChooserAction import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.EmptyApplicationCategory import com.ichi2.anki.RobolectricTest @@ -259,6 +262,7 @@ class AnkiquestShareTest : RobolectricTest() { } @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) fun `the share sheet offers AnkiQuest friends next to the other apps`() { val share = Intent(targetContext, AnkiquestShareActivity::class.java) @@ -279,6 +283,8 @@ class AnkiquestShareTest : RobolectricTest() { @Test @Config(sdk = [33]) + // The extra name is a compile-time string, so checking it is absent on Android 13 is safe. + @SuppressLint("InlinedApi") fun `before Android 14 the share sheet is for other apps and friends are asked about first`() { assertFalse(AnkiquestShare.sheetOffersFriends()) val friends = AnkiquestShare.friendsTarget(targetContext, Intent(targetContext, AnkiquestShareActivity::class.java))