Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -88,6 +94,10 @@ open class AnkiquestSettingsFragment : SettingsFragment() {
if (value.isNotEmpty()) value.toHttpUrl()
}
bindCompanion()
requirePreference<Preference>(R.string.ankiquest_sign_in_key).setOnPreferenceClickListener {
showSignIn()
true
}
requirePreference<Preference>(R.string.ankiquest_avatar_key).setOnPreferenceClickListener {
pictureAction {
val account = checkNotNull(AnkiquestAvatars.account()) { getString(R.string.ankiquest_nudges_unavailable) }
Expand Down Expand Up @@ -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<EditText>(R.id.ankiquest_sign_in_server)
val user = view.findViewById<EditText>(R.id.ankiquest_sign_in_user)
val password = view.findViewById<EditText>(R.id.ankiquest_sign_in_password)
val create = view.findViewById<CheckBox>(R.id.ankiquest_sign_in_create)
val display = view.findViewById<EditText>(R.id.ankiquest_sign_in_display)
val hint = view.findViewById<TextView>(R.id.ankiquest_sign_in_hint)
val status = view.findViewById<TextView>(R.id.ankiquest_sign_in_status)
val urlPreference = requirePreference<VersatileTextPreference>(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<VersatileTextPreference>(R.string.ankiquest_user_key).text = signed.user
requirePreference<VersatileTextPreference>(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,
Expand Down
68 changes: 68 additions & 0 deletions AnkiDroid/src/main/res/layout/dialog_ankiquest_sign_in.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="12dp">

<EditText
android:id="@+id/ankiquest_sign_in_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="url"
android:hint="@string/ankiquest_sign_in_server"
android:inputType="textUri" />

<EditText
android:id="@+id/ankiquest_sign_in_user"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="username"
android:hint="@string/ankiquest_sign_in_user"
android:inputType="textNoSuggestions|textVisiblePassword" />

<EditText
android:id="@+id/ankiquest_sign_in_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="password"
android:hint="@string/password"
android:inputType="textPassword" />

<CheckBox
android:id="@+id/ankiquest_sign_in_create"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ankiquest_sign_in_create" />

<EditText
android:id="@+id/ankiquest_sign_in_display"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="name"
android:hint="@string/ankiquest_sign_in_display"
android:inputType="textPersonName"
android:maxLength="40"
android:visibility="gone" />

<TextView
android:id="@+id/ankiquest_sign_in_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ankiquest_sign_in_hint"
android:visibility="gone" />

<TextView
android:id="@+id/ankiquest_sign_in_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:accessibilityLiveRegion="polite" />
</LinearLayout>
</ScrollView>
17 changes: 17 additions & 0 deletions AnkiDroid/src/main/res/values-de/ankiquest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@
<string name="ankiquest_token_title">AnkiQuest-Token</string>
<string name="ankiquest_test_title">Verbindung testen</string>
<string name="ankiquest_test_summary">Prüfe URL, Spieler und Token</string>
<string name="ankiquest_sign_in_title">Anmelden oder Konto erstellen</string>
<string name="ankiquest_sign_in_summary">Benutzername und Passwort statt eines kopierten Tokens verwenden</string>
<string name="ankiquest_sign_in_server">Server</string>
<string name="ankiquest_sign_in_user">Benutzername</string>
<string name="ankiquest_sign_in_display">Anzeigename (optional)</string>
<string name="ankiquest_sign_in_create">Neues Konto erstellen</string>
<string name="ankiquest_sign_in_hint">Benutzernamen bestehen aus Kleinbuchstaben, Ziffern, - und _. Passwörter brauchen mindestens 10 Zeichen.</string>
<string name="ankiquest_sign_in_submit">Anmelden</string>
<string name="ankiquest_sign_in_submit_create">Konto erstellen</string>
<string name="ankiquest_sign_in_missing">Server, Benutzername und Passwort ausfüllen.</string>
<string name="ankiquest_sign_in_wrong">Benutzername oder Passwort nicht erkannt.</string>
<string name="ankiquest_sign_in_closed">Dieser Server nimmt keine neuen Konten an. Bitte die Person, die ihn betreibt, um ein Token.</string>
<string name="ankiquest_sign_in_taken">Dieser Benutzername ist vergeben. Wähle einen anderen.</string>
<string name="ankiquest_sign_in_limited">Zu viele Versuche. Versuch es später noch einmal.</string>
<string name="ankiquest_sign_in_invalid">Prüfe den Benutzernamen (3 bis 32 Kleinbuchstaben, Ziffern, - oder _) und verwende ein Passwort mit mindestens 10 Zeichen.</string>
<string name="ankiquest_sign_in_connecting">Verbinde…</string>
<string name="ankiquest_sign_in_done">Angemeldet als %1$s.</string>
<string name="ankiquest_upload_all_title">Alle Wiederholungen hochladen</string>
<string name="ankiquest_upload_all_summary">Sende deinen gesamten Wiederholungsverlauf. Bereits hochgeladene Wiederholungen werden übersprungen.</string>
<string name="ankiquest_check_running">Verbindung zum Server wird hergestellt…</string>
Expand Down
17 changes: 17 additions & 0 deletions AnkiDroid/src/main/res/values-es/ankiquest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@
<string name="ankiquest_token_title">Token de ankiquest</string>
<string name="ankiquest_test_title">Probar conexión</string>
<string name="ankiquest_test_summary">Comprueba la URL, el jugador y el token</string>
<string name="ankiquest_sign_in_title">Iniciar sesión o crear cuenta</string>
<string name="ankiquest_sign_in_summary">Usa un nombre de usuario y una contraseña en lugar de copiar un token</string>
<string name="ankiquest_sign_in_server">Servidor</string>
<string name="ankiquest_sign_in_user">Nombre de usuario</string>
<string name="ankiquest_sign_in_display">Nombre visible (opcional)</string>
<string name="ankiquest_sign_in_create">Crear una cuenta nueva</string>
<string name="ankiquest_sign_in_hint">Los nombres de usuario usan minúsculas, números, - y _. Las contraseñas necesitan al menos 10 caracteres.</string>
<string name="ankiquest_sign_in_submit">Iniciar sesión</string>
<string name="ankiquest_sign_in_submit_create">Crear cuenta</string>
<string name="ankiquest_sign_in_missing">Rellena el servidor, el nombre de usuario y la contraseña.</string>
<string name="ankiquest_sign_in_wrong">Nombre de usuario o contraseña no reconocidos.</string>
<string name="ankiquest_sign_in_closed">Este servidor no admite cuentas nuevas. Pide un token a quien lo administra.</string>
<string name="ankiquest_sign_in_taken">Ese nombre de usuario ya existe. Elige otro.</string>
<string name="ankiquest_sign_in_limited">Demasiados intentos. Vuelve a intentarlo más tarde.</string>
<string name="ankiquest_sign_in_invalid">Revisa el nombre de usuario (de 3 a 32 minúsculas, números, - o _) y usa una contraseña de al menos 10 caracteres.</string>
<string name="ankiquest_sign_in_connecting">Conectando…</string>
<string name="ankiquest_sign_in_done">Sesión iniciada como %1$s.</string>
<string name="ankiquest_upload_all_title">Subir todos los repasos ahora</string>
<string name="ankiquest_upload_all_summary">Envía todo tu historial de repasos. El servidor omite los que ya se hayan subido.</string>
<string name="ankiquest_check_running">Contactando con el servidor…</string>
Expand Down
17 changes: 17 additions & 0 deletions AnkiDroid/src/main/res/values-fr/ankiquest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@
<string name="ankiquest_token_title">Jeton AnkiQuest</string>
<string name="ankiquest_test_title">Tester la connexion</string>
<string name="ankiquest_test_summary">Vérifie l’adresse, le joueur et le jeton</string>
<string name="ankiquest_sign_in_title">Se connecter ou créer un compte</string>
<string name="ankiquest_sign_in_summary">Utilise un nom d’utilisateur et un mot de passe au lieu de copier un jeton</string>
<string name="ankiquest_sign_in_server">Serveur</string>
<string name="ankiquest_sign_in_user">Nom d’utilisateur</string>
<string name="ankiquest_sign_in_display">Nom affiché (facultatif)</string>
<string name="ankiquest_sign_in_create">Créer un nouveau compte</string>
<string name="ankiquest_sign_in_hint">Les noms d’utilisateur utilisent des minuscules, des chiffres, - et _. Les mots de passe font au moins 10 caractères.</string>
<string name="ankiquest_sign_in_submit">Se connecter</string>
<string name="ankiquest_sign_in_submit_create">Créer le compte</string>
<string name="ankiquest_sign_in_missing">Remplis le serveur, le nom d’utilisateur et le mot de passe.</string>
<string name="ankiquest_sign_in_wrong">Nom d’utilisateur ou mot de passe non reconnu.</string>
<string name="ankiquest_sign_in_closed">Ce serveur n’accepte pas de nouveaux comptes. Demande un jeton à la personne qui le gère.</string>
<string name="ankiquest_sign_in_taken">Ce nom d’utilisateur est déjà pris. Choisis-en un autre.</string>
<string name="ankiquest_sign_in_limited">Trop de tentatives. Réessaie plus tard.</string>
<string name="ankiquest_sign_in_invalid">Vérifie le nom d’utilisateur (3 à 32 minuscules, chiffres, - ou _) et utilise un mot de passe d’au moins 10 caractères.</string>
<string name="ankiquest_sign_in_connecting">Connexion…</string>
<string name="ankiquest_sign_in_done">Connecté en tant que %1$s.</string>
<string name="ankiquest_upload_all_title">Envoyer toutes les révisions</string>
<string name="ankiquest_upload_all_summary">Envoie tout l’historique des révisions. Le serveur ignore celles déjà reçues.</string>
<string name="ankiquest_check_running">Connexion au serveur…</string>
Expand Down
Loading
Loading