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,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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -260,7 +284,7 @@ object AnkiquestNotifier {
return channel
}

private enum class Delivery {
enum class Delivery {
POSTED,
CHANNEL_BLOCKED,
DISABLED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Preference>(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) {
Expand Down Expand Up @@ -197,15 +204,80 @@ class AnkiquestSettingsFragment : SettingsFragment() {
nudge: Boolean,
) {
requirePreference<Preference>(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,
Expand Down
19 changes: 19 additions & 0 deletions AnkiDroid/src/main/res/values-es/ankiquest_notification_health.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="ankiquest_health_title">Estado de las notificaciones</string>
<string name="ankiquest_health_summary">Comprueba la entrega y envía un aviso de prueba</string>
<string name="ankiquest_health_permission">Permiso de notificaciones: %1$s</string>
<string name="ankiquest_health_messages">Avisos de mensajes: %1$s</string>
<string name="ankiquest_health_nudges">Avisos de toques: %1$s</string>
<string name="ankiquest_health_last_sync">Última sincronización de mensajes: %1$s</string>
<string name="ankiquest_health_allowed">Permitido</string>
<string name="ankiquest_health_blocked">Bloqueado en los ajustes de Android</string>
<string name="ankiquest_health_never">Nunca para esta cuenta</string>
<string name="ankiquest_health_test">Enviar aviso de prueba</string>
<string name="ankiquest_health_permission_settings">Abrir permisos de notificaciones</string>
<string name="ankiquest_health_test_title">Aviso de prueba de AnkiQuest</string>
<string name="ankiquest_health_test_body">Si ves esto, los avisos locales funcionan.</string>
<string name="ankiquest_health_test_posted">Aviso de prueba enviado. El teléfono controla los banners, el sonido y la vibración.</string>
<string name="ankiquest_health_test_blocked">Los avisos de mensajes están desactivados. Actívalos en los ajustes de Android.</string>
<string name="ankiquest_health_test_disabled">AnkiQuest no tiene permiso para mostrar notificaciones. Actívalo en los ajustes de Android.</string>
</resources>
20 changes: 20 additions & 0 deletions AnkiDroid/src/main/res/values/ankiquest_notification_health.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="ankiquest_health_key" translatable="false">ankiquestNotificationHealth</string>
<string name="ankiquest_health_title" maxLength="41">Notification health</string>
<string name="ankiquest_health_summary">Check delivery and send a test alert</string>
<string name="ankiquest_health_permission">Notification permission: %1$s</string>
<string name="ankiquest_health_messages">Message alerts: %1$s</string>
<string name="ankiquest_health_nudges">Nudge alerts: %1$s</string>
<string name="ankiquest_health_last_sync">Last inbox sync: %1$s</string>
<string name="ankiquest_health_allowed">Allowed</string>
<string name="ankiquest_health_blocked">Blocked in Android settings</string>
<string name="ankiquest_health_never">Never for this account</string>
<string name="ankiquest_health_test">Send test alert</string>
<string name="ankiquest_health_permission_settings">Open notification permissions</string>
<string name="ankiquest_health_test_title">AnkiQuest test alert</string>
<string name="ankiquest_health_test_body">If you see this, local alerts are working.</string>
<string name="ankiquest_health_test_posted">Test alert sent. Your phone controls banners, sound and vibration.</string>
<string name="ankiquest_health_test_blocked">The message alert channel is off. Open its Android settings to enable it.</string>
<string name="ankiquest_health_test_disabled">Notifications are not allowed for AnkiQuest. Enable them in Android settings.</string>
</resources>
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/res/xml/preferences_ankiquest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@
android:persistent="false"
android:title="@string/ankiquest_nudge_alerts_title"
android:summary="@string/ankiquest_alerts_summary" />
<Preference
android:key="@string/ankiquest_health_key"
android:persistent="false"
android:title="@string/ankiquest_health_title"
android:summary="@string/ankiquest_health_summary" />
<Preference
android:key="@string/ankiquest_deck_notifications_key"
android:title="@string/ankiquest_deck_notifications_title"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class PreferencesAnalyticsTest : RobolectricTest() {
R.string.ankiquest_nudges_key,
R.string.ankiquest_message_alerts_key,
R.string.ankiquest_nudge_alerts_key,
R.string.ankiquest_health_key,
R.string.ankiquest_dashboard_key,
R.string.ankiquest_community_reminders_key,
R.string.ankiquest_streak_protection_key,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.ankiquest

import android.os.Looper
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.preferences.AnkiquestSettingsFragment
import com.ichi2.anki.preferences.PreferencesActivity
import com.ichi2.anki.preferences.PreferencesFragment
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowDialog
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

@RunWith(AndroidJUnit4::class)
class AnkiquestNotificationHealthDialogTest : RobolectricTest() {
@Test
fun `notification status and actions are both visible`() {
val intent = PreferencesActivity.getIntent(targetContext, AnkiquestSettingsFragment::class)
val controller = Robolectric.buildActivity(PreferencesActivity::class.java, intent).setup()
saveControllerForCleanup(controller)
val fragment =
(controller.get().fragment as PreferencesFragment)
.childFragmentManager
.findFragmentById(R.id.settings_container) as AnkiquestSettingsFragment
val health = assertNotNull(fragment.findPreference<Preference>(targetContext.getString(R.string.ankiquest_health_key)))

health.performClick()

val dialog = assertNotNull(ShadowDialog.getLatestDialog() as? AlertDialog)
assertTrue(
assertNotNull(
dialog.findViewById<TextView>(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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading