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 971f4a21f580..1c5b545f904e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt @@ -68,7 +68,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. */ @@ -77,26 +78,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) @@ -136,15 +142,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/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/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 @@ + () + private val asked = CopyOnWriteArrayList() private val imageAuthorizations = CopyOnWriteArrayList() private val card = "not really a png".toByteArray() @@ -63,6 +68,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 +221,83 @@ 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 + @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) + .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]) + // 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)) + 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") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt index 2053cdb36836..3ab9fdb5d866 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt @@ -63,6 +63,7 @@ class AnkiquestSettingsMenuTest : RobolectricTest() { R.string.ankiquest_moments_status_key, R.string.ankiquest_share_cap_key, R.string.ankiquest_moments_decks_key, + R.string.ankiquest_deck_share_ask_key, R.string.ankiquest_deck_notifications_key, R.string.ankiquest_subscriptions_key, R.string.ankiquest_upload_all_key,