diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAccount.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAccount.kt new file mode 100644 index 000000000000..283fa181e48e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAccount.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.content.Context +import android.os.Build +import com.ichi2.anki.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import timber.log.Timber +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit + +/** Signs in with a username and password, or creates an account, and keeps only the device token it returns. */ +object AnkiquestAccount { + const val DEFAULT_SERVER = "https://ankiquest.rationality-munich.com" + + data class Credentials( + val url: String, + val user: String, + val password: String, + val display: String, + val create: Boolean, + ) + + data class Signed( + val url: String, + val user: String, + val token: String, + ) + + private val client = + OkHttpClient + .Builder() + .callTimeout(20, TimeUnit.SECONDS) + .build() + + fun device(): String = "AnkiDroid (${Build.MODEL})" + + internal fun httpRequest( + request: Credentials, + device: String, + ): okhttp3.Request { + val body = + JSONObject() + .put("user", request.user) + .put("password", request.password) + .put("device", device) + if (request.create && request.display.isNotBlank()) body.put("display", request.display) + return okhttp3.Request + .Builder() + .url(request.url.trimEnd('/') + if (request.create) "/api/accounts" else "/api/accounts/tokens") + .header("Accept-Language", AnkiquestLanguage.tag()) + .post(body.toString().toRequestBody("application/json".toMediaType())) + .build() + } + + /** @throws Ankiquest.HttpStatusException when the server turns the request down */ + suspend fun submit( + request: Credentials, + device: String = device(), + ): Signed = + withContext(Dispatchers.IO) { + client.newCall(httpRequest(request, device)).execute().use { response -> + if (!response.isSuccessful) throw Ankiquest.HttpStatusException(response.code) + val answer = JSONObject(response.body.string()) + Signed(request.url.trimEnd('/'), answer.getString("user"), answer.getString("token")) + } + } + + fun message( + context: Context, + error: Throwable, + url: String, + ): String = + when (error) { + is Ankiquest.HttpStatusException -> + when (error.code) { + 400 -> context.getString(R.string.ankiquest_sign_in_invalid) + 401 -> context.getString(R.string.ankiquest_sign_in_wrong) + 403 -> context.getString(R.string.ankiquest_sign_in_closed) + 409 -> context.getString(R.string.ankiquest_sign_in_taken) + 429 -> context.getString(R.string.ankiquest_sign_in_limited) + else -> context.getString(R.string.ankiquest_check_http, error.code) + } + is UnknownHostException -> context.getString(R.string.ankiquest_check_unreachable, url) + is IllegalArgumentException -> context.getString(R.string.ankiquest_check_bad_url, url) + else -> { + Timber.w(error, "ankiquest sign-in failed") + context.getString(R.string.ankiquest_check_failed, error.message ?: error.javaClass.simpleName) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt index 65d8dfe16ed0..8831adf2ce04 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt @@ -9,6 +9,10 @@ import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build import android.text.format.DateFormat +import android.view.View +import android.widget.CheckBox +import android.widget.EditText +import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat @@ -21,6 +25,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.R import com.ichi2.anki.ankiquest.Ankiquest +import com.ichi2.anki.ankiquest.AnkiquestAccount import com.ichi2.anki.ankiquest.AnkiquestActivity import com.ichi2.anki.ankiquest.AnkiquestAvatarEditor import com.ichi2.anki.ankiquest.AnkiquestAvatars @@ -33,6 +38,7 @@ import com.ichi2.anki.ankiquest.AnkiquestNotifier import com.ichi2.anki.ankiquest.AnkiquestPoll import com.ichi2.anki.ankiquest.AnkiquestUpdater import com.ichi2.anki.ankiquest.AnkiquestWidget +import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.preferences.VersatileTextPreference import com.ichi2.utils.Permissions.openAppSettingsScreen import kotlinx.coroutines.CancellationException @@ -88,6 +94,10 @@ open class AnkiquestSettingsFragment : SettingsFragment() { if (value.isNotEmpty()) value.toHttpUrl() } bindCompanion() + requirePreference(R.string.ankiquest_sign_in_key).setOnPreferenceClickListener { + showSignIn() + true + } requirePreference(R.string.ankiquest_avatar_key).setOnPreferenceClickListener { pictureAction { val account = checkNotNull(AnkiquestAvatars.account()) { getString(R.string.ankiquest_nudges_unavailable) } @@ -127,6 +137,76 @@ open class AnkiquestSettingsFragment : SettingsFragment() { bindAction(R.string.ankiquest_test_key) { Ankiquest.runFromSettings(requireContext(), uploadAll = false) } } + /** Asks for a username and password, and fills in the server, player and token from the answer. */ + private fun showSignIn() { + val view = layoutInflater.inflate(R.layout.dialog_ankiquest_sign_in, null) + val server = view.findViewById(R.id.ankiquest_sign_in_server) + val user = view.findViewById(R.id.ankiquest_sign_in_user) + val password = view.findViewById(R.id.ankiquest_sign_in_password) + val create = view.findViewById(R.id.ankiquest_sign_in_create) + val display = view.findViewById(R.id.ankiquest_sign_in_display) + val hint = view.findViewById(R.id.ankiquest_sign_in_hint) + val status = view.findViewById(R.id.ankiquest_sign_in_status) + val urlPreference = requirePreference(R.string.ankiquest_url_key) + server.setText(urlPreference.text?.takeIf { it.isNotBlank() } ?: AnkiquestAccount.DEFAULT_SERVER) + val dialog = + AlertDialog + .Builder(requireContext()) + .setTitle(R.string.ankiquest_sign_in_title) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.ankiquest_sign_in_submit, null) + .show() + val submit = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + create.setOnCheckedChangeListener { _, creating -> + display.visibility = if (creating) View.VISIBLE else View.GONE + hint.visibility = if (creating) View.VISIBLE else View.GONE + submit.setText(if (creating) R.string.ankiquest_sign_in_submit_create else R.string.ankiquest_sign_in_submit) + } + submit.setOnClickListener { + val credentials = + AnkiquestAccount.Credentials( + url = + server.text + .toString() + .trim() + .trimEnd('/'), + user = + user.text + .toString() + .trim() + .lowercase(), + password = password.text.toString(), + display = display.text.toString().trim(), + create = create.isChecked, + ) + if (credentials.url.isEmpty() || credentials.user.isEmpty() || credentials.password.isEmpty()) { + status.setText(R.string.ankiquest_sign_in_missing) + return@setOnClickListener + } + submit.isEnabled = false + status.setText(R.string.ankiquest_sign_in_connecting) + lifecycleScope.launch { + try { + val signed = AnkiquestAccount.submit(credentials) + if (!isAdded) return@launch + urlPreference.text = signed.url + requirePreference(R.string.ankiquest_user_key).text = signed.user + requirePreference(R.string.ankiquest_token_key).text = signed.token + dialog.dismiss() + showSnackbar(getString(R.string.ankiquest_sign_in_done, signed.user)) + AnkiquestPoll.refreshNow(requireContext()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (!isAdded) return@launch + submit.isEnabled = true + status.text = AnkiquestAccount.message(requireContext(), e, credentials.url) + } + } + } + } + protected fun bindStudy() { bindServerToggle( R.string.ankiquest_streak_protection_key, diff --git a/AnkiDroid/src/main/res/layout/dialog_ankiquest_sign_in.xml b/AnkiDroid/src/main/res/layout/dialog_ankiquest_sign_in.xml new file mode 100644 index 000000000000..0b6b2ffcba3e --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_ankiquest_sign_in.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/values-de/ankiquest.xml b/AnkiDroid/src/main/res/values-de/ankiquest.xml index b00a8db5ab3b..8fb793f3ffb1 100644 --- a/AnkiDroid/src/main/res/values-de/ankiquest.xml +++ b/AnkiDroid/src/main/res/values-de/ankiquest.xml @@ -63,6 +63,23 @@ AnkiQuest-Token Verbindung testen Prüfe URL, Spieler und Token + Anmelden oder Konto erstellen + Benutzername und Passwort statt eines kopierten Tokens verwenden + Server + Benutzername + Anzeigename (optional) + Neues Konto erstellen + Benutzernamen bestehen aus Kleinbuchstaben, Ziffern, - und _. Passwörter brauchen mindestens 10 Zeichen. + Anmelden + Konto erstellen + Server, Benutzername und Passwort ausfüllen. + Benutzername oder Passwort nicht erkannt. + Dieser Server nimmt keine neuen Konten an. Bitte die Person, die ihn betreibt, um ein Token. + Dieser Benutzername ist vergeben. Wähle einen anderen. + Zu viele Versuche. Versuch es später noch einmal. + Prüfe den Benutzernamen (3 bis 32 Kleinbuchstaben, Ziffern, - oder _) und verwende ein Passwort mit mindestens 10 Zeichen. + Verbinde… + Angemeldet als %1$s. Alle Wiederholungen hochladen Sende deinen gesamten Wiederholungsverlauf. Bereits hochgeladene Wiederholungen werden übersprungen. Verbindung zum Server wird hergestellt… diff --git a/AnkiDroid/src/main/res/values-es/ankiquest.xml b/AnkiDroid/src/main/res/values-es/ankiquest.xml index 0e5230cc384f..00b637d6e48d 100644 --- a/AnkiDroid/src/main/res/values-es/ankiquest.xml +++ b/AnkiDroid/src/main/res/values-es/ankiquest.xml @@ -74,6 +74,23 @@ Token de ankiquest Probar conexión Comprueba la URL, el jugador y el token + Iniciar sesión o crear cuenta + Usa un nombre de usuario y una contraseña en lugar de copiar un token + Servidor + Nombre de usuario + Nombre visible (opcional) + Crear una cuenta nueva + Los nombres de usuario usan minúsculas, números, - y _. Las contraseñas necesitan al menos 10 caracteres. + Iniciar sesión + Crear cuenta + Rellena el servidor, el nombre de usuario y la contraseña. + Nombre de usuario o contraseña no reconocidos. + Este servidor no admite cuentas nuevas. Pide un token a quien lo administra. + Ese nombre de usuario ya existe. Elige otro. + Demasiados intentos. Vuelve a intentarlo más tarde. + Revisa el nombre de usuario (de 3 a 32 minúsculas, números, - o _) y usa una contraseña de al menos 10 caracteres. + Conectando… + Sesión iniciada como %1$s. Subir todos los repasos ahora Envía todo tu historial de repasos. El servidor omite los que ya se hayan subido. Contactando con el servidor… diff --git a/AnkiDroid/src/main/res/values-fr/ankiquest.xml b/AnkiDroid/src/main/res/values-fr/ankiquest.xml index 8db42da5384b..81bac10f59a5 100644 --- a/AnkiDroid/src/main/res/values-fr/ankiquest.xml +++ b/AnkiDroid/src/main/res/values-fr/ankiquest.xml @@ -63,6 +63,23 @@ Jeton AnkiQuest Tester la connexion Vérifie l’adresse, le joueur et le jeton + Se connecter ou créer un compte + Utilise un nom d’utilisateur et un mot de passe au lieu de copier un jeton + Serveur + Nom d’utilisateur + Nom affiché (facultatif) + Créer un nouveau compte + Les noms d’utilisateur utilisent des minuscules, des chiffres, - et _. Les mots de passe font au moins 10 caractères. + Se connecter + Créer le compte + Remplis le serveur, le nom d’utilisateur et le mot de passe. + Nom d’utilisateur ou mot de passe non reconnu. + Ce serveur n’accepte pas de nouveaux comptes. Demande un jeton à la personne qui le gère. + Ce nom d’utilisateur est déjà pris. Choisis-en un autre. + Trop de tentatives. Réessaie plus tard. + Vérifie le nom d’utilisateur (3 à 32 minuscules, chiffres, - ou _) et utilise un mot de passe d’au moins 10 caractères. + Connexion… + Connecté en tant que %1$s. Envoyer toutes les révisions Envoie tout l’historique des révisions. Le serveur ignore celles déjà reçues. Connexion au serveur… diff --git a/AnkiDroid/src/main/res/values-pt/ankiquest.xml b/AnkiDroid/src/main/res/values-pt/ankiquest.xml index 82caf94555b9..8c4412cc686f 100644 --- a/AnkiDroid/src/main/res/values-pt/ankiquest.xml +++ b/AnkiDroid/src/main/res/values-pt/ankiquest.xml @@ -63,6 +63,23 @@ Token AnkiQuest Testar ligação Verifica o endereço, o jogador e o token + Iniciar sessão ou criar conta + Usa um nome de utilizador e uma palavra-passe em vez de copiar um token + Servidor + Nome de utilizador + Nome visível (opcional) + Criar uma conta nova + Os nomes de utilizador usam minúsculas, números, - e _. As palavras-passe precisam de pelo menos 10 caracteres. + Iniciar sessão + Criar conta + Preenche o servidor, o nome de utilizador e a palavra-passe. + Nome de utilizador ou palavra-passe não reconhecidos. + Este servidor não aceita contas novas. Pede um token a quem o gere. + Esse nome de utilizador já existe. Escolhe outro. + Demasiadas tentativas. Tenta novamente mais tarde. + Verifica o nome de utilizador (3 a 32 minúsculas, números, - ou _) e usa uma palavra-passe com pelo menos 10 caracteres. + A ligar… + Sessão iniciada como %1$s. Enviar todas as revisões Envia todo o histórico de revisões. O servidor ignora as revisões já enviadas. A ligar ao servidor… diff --git a/AnkiDroid/src/main/res/values/ankiquest.xml b/AnkiDroid/src/main/res/values/ankiquest.xml index 776354ce2507..3acabf0b017e 100644 --- a/AnkiDroid/src/main/res/values/ankiquest.xml +++ b/AnkiDroid/src/main/res/values/ankiquest.xml @@ -85,6 +85,24 @@ ankiquestUploadAll Test connection Check the URL, player and token + ankiquestSignIn + Sign in or create account + Use a username and password instead of copying a token + Server + Username + Display name (optional) + Create a new account + Usernames use lowercase letters, numbers, - and _. Passwords need at least 10 characters. + Sign in + Create account + Fill in the server, username and password. + Username or password not recognized. + This server does not take new accounts. Ask whoever runs it for a token. + That username is taken. Pick another one. + Too many attempts. Try again later. + Check the username (3 to 32 lowercase letters, numbers, - or _) and use a password of at least 10 characters. + Connecting… + Signed in as %1$s. Upload all reviews now Send your whole review history. Already uploaded reviews are skipped by the server. Contacting the server… diff --git a/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml b/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml index 101d76baf98d..f3c8fcd760f4 100644 --- a/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml +++ b/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml @@ -4,6 +4,11 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/ankiquest_account_title" android:key="@string/ankiquest_account_screen_key"> + >() + + @Before + fun startServer() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + val body = JSONObject(exchange.requestBody.readBytes().decodeToString()) + received += exchange.requestURI.path to body + val (status, answer) = + when { + exchange.requestURI.path == "/api/accounts/tokens" && body.getString("password") == "right password" -> + 200 to JSONObject().put("user", body.getString("user")).put("token", "device-token") + exchange.requestURI.path == "/api/accounts/tokens" -> 401 to JSONObject().put("error", "no") + body.getString("user") == "taken" -> 409 to JSONObject().put("error", "taken") + else -> 201 to JSONObject().put("user", body.getString("user")).put("token", "first-token") + } + val bytes = answer.toString().toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + server.start() + url = "http://127.0.0.1:${server.address.port}" + } + + @After + fun stopServer() { + server.stop(0) + } + + private fun credentials( + user: String = "ana", + password: String = "right password", + display: String = "", + create: Boolean = false, + ) = AnkiquestAccount.Credentials("$url/", user, password, display, create) + + @Test + fun `signing in trades the password for a device token`() = + runBlocking { + val signed = AnkiquestAccount.submit(credentials(), device = "AnkiDroid (Pixel)") + assertEquals(AnkiquestAccount.Signed(url, "ana", "device-token"), signed) + val (path, body) = received.single() + assertEquals("/api/accounts/tokens", path) + assertEquals("AnkiDroid (Pixel)", body.getString("device")) + assertFalse(body.has("display")) + } + + @Test + fun `creating an account sends the display name and returns the first token`() = + runBlocking { + val signed = AnkiquestAccount.submit(credentials(user = "bo", display = "Bo", create = true), device = "AnkiDroid") + assertEquals("first-token", signed.token) + val (path, body) = received.single() + assertEquals("/api/accounts", path) + assertEquals("Bo", body.getString("display")) + } + + @Test + fun `refusals explain themselves`() = + runBlocking { + val wrong = assertFailsWith { AnkiquestAccount.submit(credentials(password = "wrong")) } + assertEquals(targetContext.getString(R.string.ankiquest_sign_in_wrong), AnkiquestAccount.message(targetContext, wrong, url)) + val taken = + assertFailsWith { + AnkiquestAccount.submit(credentials(user = "taken", create = true)) + } + assertEquals(targetContext.getString(R.string.ankiquest_sign_in_taken), AnkiquestAccount.message(targetContext, taken, url)) + } +} 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 81ff55610f53..0f1cd53fbd35 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt @@ -39,6 +39,7 @@ class AnkiquestSettingsMenuTest : RobolectricTest() { val expected = listOf( R.string.ankiquest_dashboard_key, + R.string.ankiquest_sign_in_key, R.string.ankiquest_url_key, R.string.ankiquest_user_key, R.string.ankiquest_token_key,