diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotificationHealth.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotificationHealth.kt new file mode 100644 index 000000000000..186a2ce521c7 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotificationHealth.kt @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.ichi2.anki.ankiquest + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.ichi2.anki.AnkiDroidApp + +/** The inbox timestamp belongs to the authenticated server account that fetched it. */ +object AnkiquestNotificationHealth { + private const val SYNC_KEY = "ankiquestInboxSyncedAt:" + + fun recordSuccessfulSync( + scope: String, + at: Long, + preferences: SharedPreferences = AnkiDroidApp.sharedPrefs(), + ) { + if (AnkiquestHomeData.account(preferences)?.scope != scope) return + preferences.edit { putLong("$SYNC_KEY$scope", at) } + } + + fun lastSuccessfulSync(preferences: SharedPreferences = AnkiDroidApp.sharedPrefs()): Long? { + val account = AnkiquestHomeData.account(preferences)?.takeIf { it.token.isNotEmpty() } ?: return null + return preferences.getLong("$SYNC_KEY${account.scope}", 0L).takeIf { it > 0L } + } +} 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 2cbef00cc236..bf02c342fdb9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestNotifier.kt @@ -50,6 +50,7 @@ object AnkiquestNotifier { private const val STREAK_DAY_KEY = "ankiquestStreakNotifiedDay" private const val RANK_ID = 5_130_001 private const val STREAK_ID = 5_130_002 + private const val TEST_ID = 5_130_003 private const val HOUR_MS = 60 * 60 * 1000L @Synchronized @@ -237,6 +238,29 @@ object AnkiquestNotifier { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) } + /** A local alert checks Android delivery without sending a server message. */ + fun testAlert(context: Context): Delivery = + notify( + context, + TEST_ID, + AnkiquestLanguage.context(context).getString(R.string.ankiquest_health_test_title), + AnkiquestLanguage.context(context).getString(R.string.ankiquest_health_test_body), + context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: Intent(context, AnkiquestHomeActivity::class.java), + ) + + fun alertsEnabled( + context: Context, + nudge: Boolean, + ): Boolean { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + return manager.getNotificationChannel(ensureChannel(context, nudge))?.importance?.let { + it != NotificationManagerCompat.IMPORTANCE_NONE + } ?: false + } + private fun ensureChannel( context: Context, nudge: Boolean, @@ -260,7 +284,7 @@ object AnkiquestNotifier { return channel } - private enum class Delivery { + enum class Delivery { POSTED, CHANNEL_BLOCKED, DISABLED, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt index 50d87dfd3f8e..8d9699dc470e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt @@ -124,6 +124,7 @@ object AnkiquestPoll { val result = try { Ankiquest.completionNotifications()?.let { (account, notifications, scope) -> + AnkiquestNotificationHealth.recordSuccessfulSync(scope, TimeManager.time.intTimeMS(), prefs) AnkiquestNotifier.onDeckCompletions(context, account, notifications, scope) } Result.success() 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 570b5618d381..ccb241117f80 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt @@ -19,6 +19,7 @@ import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build +import android.text.format.DateFormat import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat @@ -36,6 +37,7 @@ import com.ichi2.anki.ankiquest.AnkiquestAvatarEditor import com.ichi2.anki.ankiquest.AnkiquestAvatars import com.ichi2.anki.ankiquest.AnkiquestDeckAdapter import com.ichi2.anki.ankiquest.AnkiquestDeckTree +import com.ichi2.anki.ankiquest.AnkiquestNotificationHealth import com.ichi2.anki.ankiquest.AnkiquestNotifier import com.ichi2.anki.ankiquest.AnkiquestPoll import com.ichi2.anki.ankiquest.AnkiquestUpdater @@ -45,6 +47,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrl import org.json.JSONObject +import java.util.Date class AnkiquestSettingsFragment : SettingsFragment() { override val preferenceResource = R.xml.preferences_ankiquest @@ -146,6 +149,10 @@ class AnkiquestSettingsFragment : SettingsFragment() { } bindAlertSettings(R.string.ankiquest_message_alerts_key, nudge = false) bindAlertSettings(R.string.ankiquest_nudge_alerts_key, nudge = true) + requirePreference(R.string.ankiquest_health_key).setOnPreferenceClickListener { + showNotificationHealth() + true + } bindAction(R.string.ankiquest_test_key) { Ankiquest.runFromSettings(requireContext(), uploadAll = false) } bindAction(R.string.ankiquest_upload_all_key) { Ankiquest.runFromSettings(requireContext(), uploadAll = true) } bindAction(R.string.ankiquest_deck_notifications_key) { @@ -197,15 +204,80 @@ class AnkiquestSettingsFragment : SettingsFragment() { nudge: Boolean, ) { requirePreference(key).setOnPreferenceClickListener { - try { - startActivity(AnkiquestNotifier.alertSettingsIntent(requireContext(), nudge)) - } catch (_: ActivityNotFoundException) { - openAppSettingsScreen() - } + openAlertSettings(nudge) true } } + private fun openAlertSettings(nudge: Boolean) { + try { + startActivity(AnkiquestNotifier.alertSettingsIntent(requireContext(), nudge)) + } catch (_: ActivityNotFoundException) { + openAppSettingsScreen() + } + } + + private fun showNotificationHealth() { + val context = requireContext() + val permissionGranted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + val state = { enabled: Boolean -> getString(if (enabled) R.string.ankiquest_health_allowed else R.string.ankiquest_health_blocked) } + val lastSync = + AnkiquestNotificationHealth.lastSuccessfulSync()?.let { at -> + val date = Date(at) + "${DateFormat.getDateFormat(context).format(date)} ${DateFormat.getTimeFormat(context).format(date)}" + } ?: getString(R.string.ankiquest_health_never) + val details = + listOf( + getString(R.string.ankiquest_health_permission, state(permissionGranted)), + getString(R.string.ankiquest_health_messages, state(AnkiquestNotifier.alertsEnabled(context, false))), + getString(R.string.ankiquest_health_nudges, state(AnkiquestNotifier.alertsEnabled(context, true))), + getString(R.string.ankiquest_health_last_sync, lastSync), + ).joinToString("\n") + AlertDialog + .Builder(context) + .setTitle(R.string.ankiquest_health_title) + .setMessage(details) + .setPositiveButton(R.string.ankiquest_health_test) { _, _ -> + val message = + when (AnkiquestNotifier.testAlert(context)) { + AnkiquestNotifier.Delivery.POSTED -> R.string.ankiquest_health_test_posted + AnkiquestNotifier.Delivery.CHANNEL_BLOCKED -> R.string.ankiquest_health_test_blocked + AnkiquestNotifier.Delivery.DISABLED -> R.string.ankiquest_health_test_disabled + } + AlertDialog + .Builder(context) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + }.setNeutralButton(R.string.settings) { _, _ -> + AlertDialog + .Builder(context) + .setTitle(R.string.settings) + .setItems( + arrayOf( + getString(R.string.ankiquest_health_permission_settings), + getString(R.string.ankiquest_message_alerts_title), + getString(R.string.ankiquest_nudge_alerts_title), + ), + ) { _, choice -> + when (choice) { + 0 -> + if (!permissionGranted) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + openAppSettingsScreen() + } + 1 -> openAlertSettings(nudge = false) + 2 -> openAlertSettings(nudge = true) + } + }.setNegativeButton(android.R.string.cancel, null) + .show() + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun bindDashboard( key: Int, community: Boolean = false, diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_notification_health.xml b/AnkiDroid/src/main/res/values-es/ankiquest_notification_health.xml new file mode 100644 index 000000000000..da3d45ef6712 --- /dev/null +++ b/AnkiDroid/src/main/res/values-es/ankiquest_notification_health.xml @@ -0,0 +1,19 @@ + + + Estado de las notificaciones + Comprueba la entrega y envía un aviso de prueba + Permiso de notificaciones: %1$s + Avisos de mensajes: %1$s + Avisos de toques: %1$s + Última sincronización de mensajes: %1$s + Permitido + Bloqueado en los ajustes de Android + Nunca para esta cuenta + Enviar aviso de prueba + Abrir permisos de notificaciones + Aviso de prueba de AnkiQuest + Si ves esto, los avisos locales funcionan. + Aviso de prueba enviado. El teléfono controla los banners, el sonido y la vibración. + Los avisos de mensajes están desactivados. Actívalos en los ajustes de Android. + AnkiQuest no tiene permiso para mostrar notificaciones. Actívalo en los ajustes de Android. + diff --git a/AnkiDroid/src/main/res/values/ankiquest_notification_health.xml b/AnkiDroid/src/main/res/values/ankiquest_notification_health.xml new file mode 100644 index 000000000000..736178379885 --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_notification_health.xml @@ -0,0 +1,20 @@ + + + ankiquestNotificationHealth + Notification health + Check delivery and send a test alert + Notification permission: %1$s + Message alerts: %1$s + Nudge alerts: %1$s + Last inbox sync: %1$s + Allowed + Blocked in Android settings + Never for this account + Send test alert + Open notification permissions + AnkiQuest test alert + If you see this, local alerts are working. + Test alert sent. Your phone controls banners, sound and vibration. + The message alert channel is off. Open its Android settings to enable it. + Notifications are not allowed for AnkiQuest. Enable them in Android settings. + diff --git a/AnkiDroid/src/main/res/xml/preferences_ankiquest.xml b/AnkiDroid/src/main/res/xml/preferences_ankiquest.xml index be4f0f1bdfbb..ca39b527330f 100644 --- a/AnkiDroid/src/main/res/xml/preferences_ankiquest.xml +++ b/AnkiDroid/src/main/res/xml/preferences_ankiquest.xml @@ -73,6 +73,11 @@ android:persistent="false" android:title="@string/ankiquest_nudge_alerts_title" android:summary="@string/ankiquest_alerts_summary" /> + (targetContext.getString(R.string.ankiquest_health_key))) + + health.performClick() + + val dialog = assertNotNull(ShadowDialog.getLatestDialog() as? AlertDialog) + assertTrue( + assertNotNull( + dialog.findViewById(android.R.id.message), + ).text.contains(targetContext.getString(R.string.ankiquest_health_last_sync, "")), + ) + assertTrue(assertNotNull(dialog.getButton(AlertDialog.BUTTON_POSITIVE)).isShown) + val settingsButton = assertNotNull(dialog.getButton(AlertDialog.BUTTON_NEUTRAL)) + assertTrue(settingsButton.isShown) + settingsButton.performClick() + shadowOf(Looper.getMainLooper()).idle() + val settings = assertNotNull(ShadowDialog.getLatestDialog() as? AlertDialog) + assertEquals(3, assertNotNull(settings.listView).adapter.count) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt index f0fab53b175e..4dd7e055de61 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt @@ -32,6 +32,8 @@ import java.net.InetSocketAddress import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -134,6 +136,28 @@ class AnkiquestPollTest : RobolectricTest() { verify(exactly = 1) { AnkiquestNotifier.onDeckCompletions(any(), "$url/cerro", any(), any()) } } + @Test + fun `inbox health records only successful syncs for the current account`() = + runBlocking { + assertNull(AnkiquestNotificationHealth.lastSuccessfulSync()) + assertEquals(Result.success(), worker().doWork()) + val synced = assertNotNull(AnkiquestNotificationHealth.lastSuccessfulSync()) + assertTrue(synced > 0L) + + AnkiDroidApp.sharedPrefs().edit { putString(Ankiquest.USER_KEY, "other") } + assertNull(AnkiquestNotificationHealth.lastSuccessfulSync()) + AnkiDroidApp.sharedPrefs().edit { putString(Ankiquest.USER_KEY, "cerro") } + assertEquals(synced, AnkiquestNotificationHealth.lastSuccessfulSync()) + } + + @Test + fun `failed inbox sync does not claim success`() = + runBlocking { + inboxStatus = 503 + assertEquals(Result.retry(), worker().doWork()) + assertNull(AnkiquestNotificationHealth.lastSuccessfulSync()) + } + @Test fun `corrupt cached leaderboard does not prevent inbox delivery`() = runBlocking { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt index ce77fa8a912a..25bc16f83cd0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt @@ -118,6 +118,8 @@ class AnkiquestPrivateAccessTest : RobolectricTest() { private fun switchServerDuringSettingsRead() { val preferences = targetContext.sharedPrefs() val changingPreferences = mockk() + val originalSettings = preferences.all + val originalUrl = preferences.getString(Ankiquest.URL_KEY, "") fun switchServer() { preferences.edit { @@ -127,14 +129,12 @@ class AnkiquestPrivateAccessTest : RobolectricTest() { } every { changingPreferences.getString(any(), any()) } answers { preferences.getString(firstArg(), secondArg()) } every { changingPreferences.getString(Ankiquest.URL_KEY, "") } answers { - val captured = preferences.getString(Ankiquest.URL_KEY, "") switchServer() - captured + originalUrl } every { changingPreferences.all } answers { - val captured = preferences.all switchServer() - captured + originalSettings } AnkiDroidApp.sharedPreferencesTestingOverride = changingPreferences } @@ -146,8 +146,9 @@ class AnkiquestPrivateAccessTest : RobolectricTest() { Ankiquest.profile() - assertEquals("/api/profile/member%20name", requests.single().path) - assertEquals("Bearer member-secret", requests.single().authorization) + assertTrue(requests.any { it.path == "/api/profile/member%20name" }) + assertTrue(requests.filter { it.path == "/api/profile/member%20name" }.all { it.authorization == "Bearer member-secret" }) + assertTrue(requests.none { it.authorization == "Bearer new-private-secret" }) } @Test @@ -158,8 +159,8 @@ class AnkiquestPrivateAccessTest : RobolectricTest() { Ankiquest.runFromSettings(targetContext, uploadAll = false) - assertEquals("/api/profile/member%20name", requests.single().path) - assertNull(requests.single().authorization) + assertTrue(requests.any { it.path == "/api/profile/member%20name" }) + assertTrue(requests.all { it.authorization == null }) } @Test