diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckCopies.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckCopies.kt new file mode 100644 index 000000000000..2515a016ba71 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckCopies.kt @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import anki.import_export.exportLimit +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.libanki.exportAnkiPackage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** A friend receives an independent .apkg, including media but never the sender's review history. */ +internal object AnkiquestDeckCopies { + private const val MAX_BYTES = 25L * 1024 * 1024 + + class TooLarge : IOException("Deck copy exceeds 25 MB") + + private val client = + OkHttpClient + .Builder() + .connectTimeout( + 15, + TimeUnit.SECONDS, + ).readTimeout(90, TimeUnit.SECONDS) + .writeTimeout(90, TimeUnit.SECONDS) + .build() + + suspend fun inbox(account: HomeAccount): JSONObject = + withContext(Dispatchers.IO) { + request( + account, + Request + .Builder() + .url(account.url("api/deck-copies/${account.encodedUser}")) + .get() + .build(), + ) + } + + suspend fun share( + account: HomeAccount, + deckId: Long, + deckName: String, + recipients: List, + cache: File, + ) = withContext(Dispatchers.IO) { + if (AnkiquestHomeData.account()?.scope != account.scope) throw CancellationException("AnkiQuest account changed") + val packageFile = File(cache, "ankiquest-share-${UUID.randomUUID()}.apkg") + try { + CollectionManager.withCol { + exportAnkiPackage( + packageFile.absolutePath, + withScheduling = false, + withDeckConfigs = true, + withMedia = true, + limit = exportLimit { this.deckId = deckId }, + legacy = false, + ) + } + if (AnkiquestHomeData.account()?.scope != account.scope) throw CancellationException("AnkiQuest account changed") + if (packageFile.length() > MAX_BYTES) throw TooLarge() + val url = + account + .url("api/deck-copies/${account.encodedUser}") + .newBuilder() + .addQueryParameter("deck", deckName) + .addQueryParameter("recipients", JSONArray(recipients).toString()) + .build() + request( + account, + Request + .Builder() + .url(url) + .post(packageFile.asRequestBody("application/octet-stream".toMediaType())) + .build(), + ) + } finally { + packageFile.delete() + } + } + + suspend fun download( + account: HomeAccount, + id: Long, + cache: File, + ): File = + withContext(Dispatchers.IO) { + val file = File.createTempFile("ankiquest-friend-", ".apkg", cache) + try { + val request = + Request + .Builder() + .url(account.url("api/deck-copies/${account.encodedUser}/$id")) + .get() + .build() + client.newCall(authorized(account, request)).execute().use { response -> + if (!response.isSuccessful) throw Ankiquest.HttpStatusException(response.code) + val body = response.body + body.byteStream().use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + var size = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + size += read + if (size > MAX_BYTES) throw TooLarge() + output.write(buffer, 0, read) + } + } + } + } + if (file.length() == 0L) throw IOException("Empty deck copy") + file + } catch (error: Exception) { + file.delete() + throw error + } + } + + suspend fun dismiss( + account: HomeAccount, + id: Long, + ) = withContext(Dispatchers.IO) { + request( + account, + Request + .Builder() + .url(account.url("api/deck-copies/${account.encodedUser}/$id")) + .delete() + .build(), + ) + } + + private fun authorized( + account: HomeAccount, + request: Request, + ): Request = request.newBuilder().header("Authorization", "Bearer ${account.token}").build() + + private fun request( + account: HomeAccount, + request: Request, + ): JSONObject = + client.newCall(authorized(account, request)).execute().use { response -> + if (!response.isSuccessful) throw Ankiquest.HttpStatusException(response.code) + val body = response.body.string() + if (body.isEmpty()) JSONObject() else JSONObject(body) + } +} 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 59fc60a6a184..559a5b7d0b95 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt @@ -13,6 +13,7 @@ import android.view.ViewGroup import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.ColorRes import androidx.annotation.StringRes import androidx.core.view.ViewCompat @@ -29,6 +30,7 @@ import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CollectionManager import com.ichi2.anki.DeckPicker import com.ichi2.anki.R +import com.ichi2.anki.pages.AnkiPackageImporterFragment import com.ichi2.anki.preferences.AnkiquestSettingsFragment import com.ichi2.anki.preferences.PreferencesActivity import kotlinx.coroutines.CancellationException @@ -36,6 +38,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.json.JSONObject import timber.log.Timber +import java.io.File import java.io.IOException /** @@ -53,6 +56,18 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { @StringRes private var profileFailure: Int? = null private var loading = false private var networkJob: Job? = null + private var copyJob: Job? = null + private var copyInbox: JSONObject? = null + private var copyFailed = false + private var copyBusy = false + private var copyStatus: String? = null + private var importedCopyFile: File? = null + private val copyImporter = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + importedCopyFile?.delete() + importedCopyFile = null + refresh() + } private var generation = 0 override fun onCreate(savedInstanceState: Bundle?) { @@ -121,6 +136,9 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { account = current profile = null profileFailure = null + copyInbox = null + copyStatus = null + copyBusy = false } val turn = ++generation lifecycleScope.launch { @@ -143,6 +161,7 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { } } networkJob?.cancel() + copyJob?.cancel() if (current == null) { loading = false render() @@ -150,6 +169,7 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { } loading = true render() + refreshCopies(current, turn) networkJob = lifecycleScope.launch { val failure = @@ -175,6 +195,30 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { } } + private fun refreshCopies( + current: HomeAccount, + turn: Int = generation, + ) { + copyJob?.cancel() + copyJob = + lifecycleScope.launch { + val result = + try { + AnkiquestDeckCopies.inbox(current) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.w(error, "AnkiQuest deck copies could not be loaded") + null + } + if (turn == generation && account?.scope == current.scope) { + copyInbox = result + copyFailed = result == null + render() + } + } + } + private fun render() { if (!::content.isInitialized) return val position = scroll.scrollY @@ -222,6 +266,7 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { } } if (account == null) connectionCard() + if (account != null) renderDeckCopies() AnkiquestStudySession.latest()?.let { summary -> val panel = card() panel.addView(AnkiquestAki.image(this, if (summary.remaining == 0) R.drawable.aki_celebrate else R.drawable.aki_streak, 56)) @@ -247,6 +292,152 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { if (account != null) button(content, R.string.aq_home_retry, enabled = !loading) { refresh() } } + private fun renderDeckCopies() { + val panel = card() + text(panel, getString(R.string.aq_copies_title), heading = true) + text(panel, getString(R.string.aq_copies_description), small = true) + copyStatus?.let { text(panel, it, small = true) } + val data = copyInbox + if (data == null) { + text(panel, getString(if (copyFailed) R.string.aq_copies_unavailable else R.string.aq_home_loading_local), small = true) + return + } + val friends = data.optJSONArray("friends")?.objects().orEmpty() + val decks = local?.decks.orEmpty() + button(panel, R.string.aq_copies_share, enabled = !copyBusy && friends.isNotEmpty() && decks.isNotEmpty()) { + chooseDeckForCopy(decks, friends) + } + if (friends.isEmpty()) text(panel, getString(R.string.aq_copies_no_friends), small = true) + val offers = data.optJSONArray("offers")?.objects().orEmpty() + if (offers.isEmpty()) { + text(panel, getString(R.string.aq_copies_none), small = true) + } else { + text(panel, getString(R.string.aq_copies_received), heading = true) + for (offer in offers) { + val sender = offer.optString("sender") + val display = friends.firstOrNull { it.optString("user") == sender }?.optString("display") ?: sender + text(panel, getString(R.string.aq_copies_offer, display, offer.optString("deck"))) + val id = offer.optLong("id") + button(panel, R.string.aq_copies_import, enabled = !copyBusy) { importCopy(id) } + button(panel, R.string.aq_copies_dismiss, enabled = !copyBusy) { dismissCopy(id) } + } + } + } + + private fun chooseDeckForCopy( + decks: List, + friends: List, + ) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.aq_copies_choose_deck) + .setItems(decks.map { it.name }.toTypedArray()) { _, position -> chooseFriendsForCopy(decks[position], friends) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun chooseFriendsForCopy( + deck: HomeDeck, + friends: List, + ) { + val picked = BooleanArray(friends.size) + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.aq_copies_choose_friends, deck.name)) + .setMultiChoiceItems(friends.map { it.optString("display") }.toTypedArray(), picked) { _, which, selected -> + picked[which] = selected + }.setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.aq_copies_share) { _, _ -> + val selected = friends.indices.filter { picked[it] }.map { friends[it].getString("user") } + if (selected.isEmpty()) { + copyStatus = getString(R.string.aq_copies_choose_one) + render() + } else { + shareCopy(deck, selected) + } + }.show() + } + + private fun shareCopy( + deck: HomeDeck, + recipients: List, + ) { + val current = account ?: return + copyBusy = true + copyStatus = getString(R.string.aq_copies_sending) + render() + lifecycleScope.launch { + val status = + try { + AnkiquestDeckCopies.share(current, deck.id, deck.name, recipients, cacheDir) + getString(R.string.aq_copies_sent) + } catch (error: CancellationException) { + throw error + } catch (error: AnkiquestDeckCopies.TooLarge) { + getString(R.string.aq_copies_too_large) + } catch (error: Exception) { + Timber.w(error, "AnkiQuest deck copy could not be sent") + getString(R.string.aq_copies_failed) + } + if (account?.scope == current.scope) { + copyStatus = status + copyBusy = false + render() + } + } + } + + private fun importCopy(id: Long) { + val current = account ?: return + copyBusy = true + copyStatus = getString(R.string.aq_copies_downloading) + render() + lifecycleScope.launch { + try { + val file = AnkiquestDeckCopies.download(current, id, cacheDir) + if (account?.scope == current.scope) { + copyStatus = null + importedCopyFile = file + copyImporter.launch(AnkiPackageImporterFragment.getIntent(this@AnkiquestHomeActivity, file.absolutePath)) + } else { + file.delete() + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.w(error, "AnkiQuest deck copy could not be imported") + if (account?.scope == current.scope) copyStatus = getString(R.string.aq_copies_failed) + } + if (account?.scope == current.scope) { + copyBusy = false + render() + } + } + } + + private fun dismissCopy(id: Long) { + val current = account ?: return + copyBusy = true + render() + lifecycleScope.launch { + try { + AnkiquestDeckCopies.dismiss(current, id) + if (account?.scope == current.scope) { + copyStatus = null + copyBusy = false + refreshCopies(current) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.w(error, "AnkiQuest deck copy could not be dismissed") + if (account?.scope == current.scope) { + copyStatus = getString(R.string.aq_copies_failed) + copyBusy = false + render() + } + } + } + } + private fun chooseDeck() { val rows = local?.decks.orEmpty() MaterialAlertDialogBuilder(this) diff --git a/AnkiDroid/src/main/res/values-de/ankiquest_deck_copies.xml b/AnkiDroid/src/main/res/values-de/ankiquest_deck_copies.xml new file mode 100644 index 000000000000..61d1c9583e51 --- /dev/null +++ b/AnkiDroid/src/main/res/values-de/ankiquest_deck_copies.xml @@ -0,0 +1,21 @@ + + + Stapel mit Freunden + Sende eine eigenständige Kopie mit Karten und Medien, ohne deinen Lernverlauf. Freunde entscheiden selbst, ob sie sie importieren. + Stapel können derzeit nicht geteilt werden. Prüfe die Verbindung und aktualisiere den Server. + Auf diesem Server sind noch keine Freunde verfügbar. + Es warten keine Stapelkopien auf dich. + Mit dir geteilt + %1$s hat %2$s geteilt + Stapel teilen + Stapel auswählen + %1$s teilen mit + Wähle mindestens einen Freund aus. + Stapelkopie wird vorbereitet und gesendet… + Stapelkopie gesendet. + Kopie importieren + Angebot entfernen + Stapelkopie wird heruntergeladen… + Diese Aktion zum Teilen des Stapels konnte nicht abgeschlossen werden. Versuche es erneut. + Diese Stapelkopie ist größer als 25 MB. Nutze für größere Stapel den normalen Anki-Export. + diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_deck_copies.xml b/AnkiDroid/src/main/res/values-es/ankiquest_deck_copies.xml new file mode 100644 index 000000000000..d7d176afb305 --- /dev/null +++ b/AnkiDroid/src/main/res/values-es/ankiquest_deck_copies.xml @@ -0,0 +1,21 @@ + + + Mazos con amigos + Envía una copia independiente con tarjetas y archivos multimedia, sin tu historial de estudio. Tus amigos deciden si la importan. + No se pueden compartir mazos. Comprueba la conexión y actualiza el servidor. + Todavía no hay amigos disponibles en este servidor. + No tienes copias de mazos pendientes. + Compartidos contigo + %1$s compartió %2$s + Compartir un mazo + Elige un mazo + Compartir %1$s con + Elige al menos un amigo. + Preparando y enviando la copia del mazo… + Copia del mazo enviada. + Importar una copia + Eliminar oferta + Descargando la copia del mazo… + No se pudo completar esta acción para compartir mazos. Inténtalo de nuevo. + Esta copia supera los 25 MB. Usa la exportación normal de Anki para mazos más grandes. + diff --git a/AnkiDroid/src/main/res/values-fr/ankiquest_deck_copies.xml b/AnkiDroid/src/main/res/values-fr/ankiquest_deck_copies.xml new file mode 100644 index 000000000000..9b410ea063ea --- /dev/null +++ b/AnkiDroid/src/main/res/values-fr/ankiquest_deck_copies.xml @@ -0,0 +1,21 @@ + + + Paquets entre amis + Envoie une copie indépendante avec les cartes et les médias, sans ton historique de révision. Tes amis choisissent de l’importer ou non. + Le partage de paquets est indisponible. Vérifie ta connexion et mets le serveur à jour. + Aucun ami n’est encore disponible sur ce serveur. + Aucune copie de paquet ne t’attend. + Partagés avec toi + %1$s a partagé %2$s + Partager un paquet + Choisis un paquet + Partager %1$s avec + Choisis au moins un ami. + Préparation et envoi de la copie… + Copie du paquet envoyée. + Importer une copie + Supprimer l’offre + Téléchargement de la copie… + Impossible de terminer cette action de partage. Réessaie. + Cette copie dépasse 25 Mo. Utilise l’exportation normale d’Anki pour les paquets plus volumineux. + diff --git a/AnkiDroid/src/main/res/values-pt/ankiquest_deck_copies.xml b/AnkiDroid/src/main/res/values-pt/ankiquest_deck_copies.xml new file mode 100644 index 000000000000..946c5eaee519 --- /dev/null +++ b/AnkiDroid/src/main/res/values-pt/ankiquest_deck_copies.xml @@ -0,0 +1,21 @@ + + + Baralhos com amigos + Envia uma cópia independente com cartões e multimédia, sem o teu histórico de estudo. Os amigos decidem se a importam. + A partilha de baralhos não está disponível. Verifica a ligação e atualiza o servidor. + Ainda não há amigos disponíveis neste servidor. + Não há cópias de baralhos à tua espera. + Partilhados contigo + %1$s partilhou %2$s + Partilhar um baralho + Escolhe um baralho + Partilhar %1$s com + Escolhe pelo menos um amigo. + A preparar e enviar a cópia do baralho… + Cópia do baralho enviada. + Importar uma cópia + Remover oferta + A transferir a cópia do baralho… + Não foi possível concluir esta ação de partilha. Tenta novamente. + Esta cópia ultrapassa 25 MB. Usa a exportação normal do Anki para baralhos maiores. + diff --git a/AnkiDroid/src/main/res/values/ankiquest_deck_copies.xml b/AnkiDroid/src/main/res/values/ankiquest_deck_copies.xml new file mode 100644 index 000000000000..ee542b205f8e --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_deck_copies.xml @@ -0,0 +1,21 @@ + + + Decks with friends + Send an independent copy with cards and media, without your study history. Friends choose whether to import it. + Deck sharing is unavailable. Check your connection and update the server. + No friends are available on this server yet. + No deck copies are waiting for you. + Shared with you + %1$s shared %2$s + Share a deck + Choose a deck + Share %1$s with + Choose at least one friend. + Preparing and sending the deck copy… + Deck copy sent. + Import a copy + Remove offer + Downloading the deck copy… + Could not complete this deck-sharing action. Try again. + This deck copy is over 25 MB. Use Anki’s normal export to share larger decks. + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckCopiesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckCopiesTest.kt new file mode 100644 index 000000000000..1c6e153da78f --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckCopiesTest.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.net.InetSocketAddress +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [32]) +class AnkiquestDeckCopiesTest : RobolectricTest() { + @Test + fun `selected member can list download and dismiss a shared deck copy`() = + runBlocking { + val packageBytes = byteArrayOf(80, 75, 3, 4) + "sample".toByteArray() + val requests = mutableListOf() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/api/deck-copies/hill") { exchange -> + requests += "${exchange.requestMethod} ${exchange.requestHeaders.getFirst("Authorization")} ${exchange.requestURI.path}" + val bytes = + when { + exchange.requestMethod == "DELETE" -> ByteArray(0) + exchange.requestURI.path.endsWith("/7") -> packageBytes + else -> + """{"offers":[{"id":7,"sender":"cerro","deck":"Spanish","bytes":10}],"friends":[],"max_bytes":26214400}""" + .toByteArray() + } + exchange.sendResponseHeaders(if (exchange.requestMethod == "DELETE") 204 else 200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + server.start() + try { + val account = HomeAccount("http://127.0.0.1:${server.address.port}", "hill", "hill-secret") + val inbox = AnkiquestDeckCopies.inbox(account) + assertEquals("Spanish", inbox.getJSONArray("offers").getJSONObject(0).getString("deck")) + val file = AnkiquestDeckCopies.download(account, 7, targetContext.cacheDir) + try { + assertTrue(file.readBytes().contentEquals(packageBytes)) + } finally { + file.delete() + } + AnkiquestDeckCopies.dismiss(account, 7) + assertEquals( + listOf( + "GET Bearer hill-secret /api/deck-copies/hill", + "GET Bearer hill-secret /api/deck-copies/hill/7", + "DELETE Bearer hill-secret /api/deck-copies/hill/7", + ), + requests, + ) + } finally { + server.stop(0) + } + } +}