From 23e3cf103521c19ed976516dd8ff64ac5a94cb76 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 17 Feb 2024 15:57:32 +0100 Subject: [PATCH] feat: Show information about notification fetches on "About" screen (#454) Some users report that Pachli is not retrieving/displaying notifications in a timely fashion. To assist in diagnosing these errors, provide an additional set of tabs on the "About" screen that contain information about how Pachli is fetching notifications, and if not, why not. Allow the user to save notification related logs and other details to a file that can be attached to an e-mail or bug report. Recording data: - Provide a `NotificationConfig` singleton with properties to record different aspects of the notification configuration. Update these properties as different notification actions occur. - Store logs in a `LogEntryEntity` table. Log events of interest with a new `Timber` `LogEntryTree` that is planted in all cases. - Add `PruneLogEntryEntityWorker` to trim saved logs to the last 48 hours. Display: - Add a `NotificationFragment` to `AboutActivity`. It hosts two other fragments in tabs to show details from `NotificationConfig` and the relevant logs, as well as controls for interacting with them. Bug fixes: - Filter out notifications with a null tag when processing active notifications, prevents an NPE crash Other changes: - Log more details when errors occur so the bug reports are more helpful --- app/src/main/java/app/pachli/MainActivity.kt | 4 +- .../main/java/app/pachli/PachliApplication.kt | 19 +- .../notifications/NotificationFetcher.kt | 26 +- .../notifications/NotificationHelper.kt | 42 +- .../notifications/PushNotificationHelper.kt | 49 +- .../NotificationPreferencesFragment.kt | 4 +- .../java/app/pachli/usecase/LogoutUsecase.kt | 4 +- .../app/pachli/worker/NotificationWorker.kt | 2 + .../worker/PruneLogEntryEntityWorker.kt | 63 + .../pachli/core/accounts/AccountManager.kt | 4 +- .../app/pachli/core/activity/LogEntryTree.kt | 72 + .../core/activity/NotificationConfig.kt | 80 ++ .../app/pachli/core/activity/CrashReporter.kt | 39 +- .../core/common/di/SystemServiceModule.kt | 40 + core/database/build.gradle.kts | 6 + .../4.json | 1190 +++++++++++++++++ .../app/pachli/core/database/AppDatabase.kt | 7 +- .../app/pachli/core/database/Converters.kt | 13 + .../pachli/core/database/dao/LogEntryDao.kt | 57 + .../pachli/core/database/di/DatabaseModule.kt | 3 + .../core/database/model/AccountEntity.kt | 3 + .../core/database/model/LogEntryEntity.kt | 66 + .../app/pachli/core/network/model/Account.kt | 5 + .../pachli/core/network/model/Notification.kt | 1 + .../pachli/core/ui/AlertDialogExtensions.kt | 4 +- feature/about/build.gradle.kts | 7 + .../app/pachli/feature/about/AboutActivity.kt | 6 +- .../about/NotificationDetailsFragment.kt | 269 ++++ .../feature/about/NotificationFragment.kt | 140 ++ .../feature/about/NotificationLogFragment.kt | 453 +++++++ .../feature/about/NotificationViewModel.kt | 208 +++ .../main/res/drawable/baseline_download.xml | 5 + .../about/src/main/res/drawable/ic_filter.xml | 10 + .../src/main/res/layout/activity_about.xml | 2 +- .../main/res/layout/fragment_notification.xml | 43 + .../layout/fragment_notification_details.xml | 287 ++++ .../res/layout/fragment_notification_log.xml | 64 + .../src/main/res/layout/item_log_entry.xml | 39 + .../src/main/res/layout/item_usage_event.xml | 41 + .../src/main/res/layout/item_work_info.xml | 56 + .../main/res/menu/fragment_notification.xml | 25 + feature/about/src/main/res/values/strings.xml | 47 + 42 files changed, 3449 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/app/pachli/worker/PruneLogEntryEntityWorker.kt create mode 100644 core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt create mode 100644 core/activity/src/main/kotlin/app/pachli/core/activity/NotificationConfig.kt create mode 100644 core/common/src/main/kotlin/app/pachli/core/common/di/SystemServiceModule.kt create mode 100644 core/database/schemas/app.pachli.core.database.AppDatabase/4.json create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/LogEntryEntity.kt create mode 100644 feature/about/src/main/kotlin/app/pachli/feature/about/NotificationDetailsFragment.kt create mode 100644 feature/about/src/main/kotlin/app/pachli/feature/about/NotificationFragment.kt create mode 100644 feature/about/src/main/kotlin/app/pachli/feature/about/NotificationLogFragment.kt create mode 100644 feature/about/src/main/kotlin/app/pachli/feature/about/NotificationViewModel.kt create mode 100644 feature/about/src/main/res/drawable/baseline_download.xml create mode 100644 feature/about/src/main/res/drawable/ic_filter.xml create mode 100644 feature/about/src/main/res/layout/fragment_notification.xml create mode 100644 feature/about/src/main/res/layout/fragment_notification_details.xml create mode 100644 feature/about/src/main/res/layout/fragment_notification_log.xml create mode 100644 feature/about/src/main/res/layout/item_log_entry.xml create mode 100644 feature/about/src/main/res/layout/item_usage_event.xml create mode 100644 feature/about/src/main/res/layout/item_work_info.xml create mode 100644 feature/about/src/main/res/menu/fragment_notification.xml diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index 7dd8bc675..181e9804c 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -61,10 +61,10 @@ import app.pachli.appstore.EventHub import app.pachli.appstore.MainTabsChangedEvent import app.pachli.appstore.ProfileEditedEvent import app.pachli.components.compose.ComposeActivity.Companion.canHandleMimeType +import app.pachli.components.notifications.androidNotificationsAreEnabled import app.pachli.components.notifications.createNotificationChannelsForAccount import app.pachli.components.notifications.disableAllNotifications import app.pachli.components.notifications.enablePushNotificationsWithFallback -import app.pachli.components.notifications.notificationsAreEnabled import app.pachli.components.notifications.showMigrationNoticeIfNecessary import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.BottomSheetActivity @@ -1020,7 +1020,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { accountManager, sharedPreferencesRepository, ) - if (notificationsAreEnabled(this, accountManager)) { + if (androidNotificationsAreEnabled(this, accountManager)) { lifecycleScope.launch { enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) } diff --git a/app/src/main/java/app/pachli/PachliApplication.kt b/app/src/main/java/app/pachli/PachliApplication.kt index 9a06d5969..e2b330e9b 100644 --- a/app/src/main/java/app/pachli/PachliApplication.kt +++ b/app/src/main/java/app/pachli/PachliApplication.kt @@ -25,6 +25,7 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import app.pachli.components.notifications.createWorkerNotificationChannel +import app.pachli.core.activity.LogEntryTree import app.pachli.core.activity.TreeRing import app.pachli.core.activity.initCrashReporter import app.pachli.core.preferences.AppTheme @@ -35,6 +36,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.util.LocaleManager import app.pachli.util.setAppNightMode import app.pachli.worker.PruneCacheWorker +import app.pachli.worker.PruneLogEntryEntityWorker import app.pachli.worker.WorkerFactory import autodispose2.AutoDisposePlugins import dagger.hilt.android.HiltAndroidApp @@ -59,6 +61,9 @@ class PachliApplication : Application() { @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository + @Inject + lateinit var logEntryTree: LogEntryTree + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) @@ -86,6 +91,7 @@ class PachliApplication : Application() { BuildConfig.DEBUG -> Timber.plant(Timber.DebugTree()) BuildConfig.FLAVOR_color == "orange" -> Timber.plant(TreeRing) } + Timber.plant(logEntryTree) // Migrate shared preference keys and defaults from version to version. val oldVersion = sharedPreferencesRepository.getInt(PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION) @@ -117,15 +123,26 @@ class PachliApplication : Application() { .build(), ) + val workManager = WorkManager.getInstance(this) // Prune the database every ~ 12 hours when the device is idle. val pruneCacheWorker = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) .build() - WorkManager.getInstance(this).enqueueUniquePeriodicWork( + workManager.enqueueUniquePeriodicWork( PruneCacheWorker.PERIODIC_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, pruneCacheWorker, ) + + // Delete old logs every ~ 12 hours when the device is idle. + val pruneLogEntryEntityWorker = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) + .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) + .build() + workManager.enqueueUniquePeriodicWork( + PruneLogEntryEntityWorker.PERIODIC_WORK_TAG, + ExistingPeriodicWorkPolicy.KEEP, + pruneLogEntryEntityWorker, + ) } private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt b/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt index ff6b4a477..bd3db9270 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt @@ -21,13 +21,17 @@ import android.app.NotificationManager import android.content.Context import androidx.annotation.WorkerThread import app.pachli.core.accounts.AccountManager +import app.pachli.core.activity.NotificationConfig import app.pachli.core.common.string.isLessThan import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.model.Links import app.pachli.core.network.model.Marker import app.pachli.core.network.model.Notification import app.pachli.core.network.retrofit.MastodonApi +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Instant import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds @@ -49,7 +53,13 @@ class NotificationFetcher @Inject constructor( @ApplicationContext private val context: Context, ) { suspend fun fetchAndShow() { + Timber.d("NotificationFetcher.fetchAndShow() started") for (account in accountManager.getAllAccountsOrderedByActive()) { + Timber.d( + "Checking %s$, notificationsEnabled = %s", + account.fullName, + account.notificationsEnabled, + ) if (account.notificationsEnabled) { try { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -67,6 +77,7 @@ class NotificationFetcher @Inject constructor( // Err on the side of removing *older* notifications to make room for newer // notifications. val currentAndroidNotifications = notificationManager.activeNotifications + .filter { it.tag != null } .sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first // Check to see if any notifications need to be removed @@ -135,6 +146,7 @@ class NotificationFetcher @Inject constructor( * than the marker. */ private suspend fun fetchNewNotifications(account: AccountEntity): List { + Timber.d("fetchNewNotifications(%s)", account.fullName) val authHeader = String.format("Bearer %s", account.accessToken) // Figure out where to read from. Choose the most recent notification ID from: @@ -158,12 +170,24 @@ class NotificationFetcher @Inject constructor( // Fetch all outstanding notifications val notifications = buildList { while (minId != null) { + val now = Instant.now() + Timber.d("Fetching notifications from server") val response = mastodonApi.notificationsWithAuth( authHeader, account.domain, minId = minId, ) - if (!response.isSuccessful) break + if (!response.isSuccessful) { + val error = response.errorBody()?.string() + Timber.e("Fetching notifications from server failed: %s", error) + NotificationConfig.lastFetchNewNotifications[account.fullName] = Pair(now, Err(error ?: "Unknown error")) + break + } + NotificationConfig.lastFetchNewNotifications[account.fullName] = Pair(now, Ok(Unit)) + Timber.i( + "Fetching notifications from server succeeded, returned %d notifications", + response.body()?.size, + ) // Notifications are returned in the page in order, newest first, // (https://github.com/mastodon/documentation/issues/1226), insert the diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt b/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt index 0ed90cb60..6dea3cbee 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt @@ -44,6 +44,7 @@ import app.pachli.BuildConfig import app.pachli.MainActivity import app.pachli.R import app.pachli.core.accounts.AccountManager +import app.pachli.core.activity.NotificationConfig import app.pachli.core.common.string.unicodeWrap import app.pachli.core.database.model.AccountEntity import app.pachli.core.designsystem.R as DR @@ -112,7 +113,6 @@ private const val EXTRA_NOTIFICATION_TYPE = * Takes a given Mastodon notification and creates a new Android notification or updates the * existing Android notification. * - * * The Android notification has it's tag set to the Mastodon notification ID, and it's ID set * to the ID of the account that received the notification. * @@ -557,34 +557,64 @@ fun deleteNotificationChannelsForAccount(account: AccountEntity, context: Contex } } -fun notificationsAreEnabled(context: Context, accountManager: AccountManager): Boolean { +/** + * @return True if at least one account has Android notifications enabled. + */ +fun androidNotificationsAreEnabled(context: Context, accountManager: AccountManager): Boolean { + Timber.d("Checking if Android notifications are enabled") + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Timber.d( + "%d >= %d, checking notification manager", + Build.VERSION.SDK_INT, + Build.VERSION_CODES.O, + ) // on Android >= O notifications are enabled if at least one channel is enabled val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (notificationManager.areNotificationsEnabled()) { for (channel in notificationManager.notificationChannels) { + Timber.d( + "Checking NotificationChannel %s / importance: %s", + channel.id, + channel.importance, + ) if (channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE) { Timber.d("NotificationsEnabled") + Timber.d("Channel notification importance > %d, enabling notifications", NotificationManager.IMPORTANCE_NONE) + NotificationConfig.androidNotificationsEnabled = true return true + } else { + Timber.d("Channel notification importance <= %d, skipping", NotificationManager.IMPORTANCE_NONE) } } } - Timber.d("NotificationsDisabled") + Timber.i("Notifications disabled because no notification channels are enabled") + NotificationConfig.androidNotificationsEnabled = false false } else { // on Android < O notifications are enabled if at least one account has notification enabled - accountManager.areNotificationsEnabled() + Timber.d( + "%d < %d, checking account manager", + Build.VERSION.SDK_INT, + Build.VERSION_CODES.O, + ) + val result = accountManager.areAndroidNotificationsEnabled() + Timber.d("Did any accounts have notifications enabled?: %s", result) + NotificationConfig.androidNotificationsEnabled = result + return result } } fun enablePullNotifications(context: Context) { + Timber.i("Enabling pull notifications for all accounts") val workManager = WorkManager.getInstance(context) workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG) // Periodic work requests are supposed to start running soon after being enqueued. In // practice that may not be soon enough, so create and enqueue an expedited one-time // request to get new notifications immediately. + Timber.d("Enqueing immediate notification worker") val fetchNotifications: WorkRequest = OneTimeWorkRequest.Builder(NotificationWorker::class.java) .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) @@ -603,11 +633,13 @@ fun enablePullNotifications(context: Context) { .build() workManager.enqueue(workRequest) Timber.d("enabled notification checks with %d ms interval", PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS) + NotificationConfig.notificationMethod = NotificationConfig.Method.Pull } fun disablePullNotifications(context: Context) { WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG) - Timber.d("disabled notification checks") + Timber.w("Disabling pull notifications for all accounts") + NotificationConfig.notificationMethod = NotificationConfig.Method.Unknown } fun clearNotificationsForAccount(context: Context, account: AccountEntity) { diff --git a/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt b/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt index 16b4df772..02eba9c87 100644 --- a/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt @@ -23,6 +23,7 @@ import android.view.View import androidx.appcompat.app.AlertDialog import app.pachli.R import app.pachli.core.accounts.AccountManager +import app.pachli.core.activity.NotificationConfig import app.pachli.core.database.model.AccountEntity import app.pachli.core.navigation.LoginActivityIntent import app.pachli.core.navigation.LoginActivityIntent.LoginMode @@ -30,8 +31,12 @@ import app.pachli.core.network.model.Notification import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.util.CryptoUtil +import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -43,6 +48,7 @@ private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean = accountManager.accounts.any(::accountNeedsMigration) +/** @return True if the account does not have the `push` OAuth scope, false otherwise */ private fun accountNeedsMigration(account: AccountEntity): Boolean = !account.oauthScopes.contains("push") @@ -100,9 +106,14 @@ private fun showMigrationExplanationDialog( private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { if (isUnifiedPushNotificationEnabledForAccount(account)) { // Already registered, update the subscription to match notification settings - updateUnifiedPushSubscription(context, api, accountManager, account) + val result = updateUnifiedPushSubscription(context, api, accountManager, account) + NotificationConfig.notificationMethodAccount[account.fullName] = when (result) { + is Err -> NotificationConfig.Method.PushError(result.error) + is Ok -> NotificationConfig.Method.Push + } } else { UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) + NotificationConfig.notificationMethodAccount[account.fullName] = NotificationConfig.Method.Push } } @@ -118,19 +129,35 @@ fun disableUnifiedPushNotificationsForAccount(context: Context, account: Account fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = account.unifiedPushUrl.isNotEmpty() +/** True if one or more UnifiedPush distributors are available */ private fun isUnifiedPushAvailable(context: Context): Boolean = UnifiedPush.getDistributors(context).isNotEmpty() -fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = - isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) +fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean { + val unifiedPushAvailable = isUnifiedPushAvailable(context) + val anyAccountNeedsMigration = anyAccountNeedsMigration(accountManager) + + NotificationConfig.unifiedPushAvailable = unifiedPushAvailable + NotificationConfig.anyAccountNeedsMigration = anyAccountNeedsMigration + + return unifiedPushAvailable && !anyAccountNeedsMigration +} suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) { + Timber.d("Enabling push notifications with fallback") if (!canEnablePushNotifications(context, accountManager)) { + Timber.d("Cannot enable push notifications, switching to pull") + NotificationConfig.notificationMethod = NotificationConfig.Method.Pull + accountManager.accounts.map { + NotificationConfig.notificationMethodAccount[it.fullName] = NotificationConfig.Method.Pull + } // No UP distributors enablePullNotifications(context) return } + NotificationConfig.notificationMethod = NotificationConfig.Method.Push + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager accountManager.accounts.forEach { @@ -153,6 +180,7 @@ private fun disablePushNotifications(context: Context, accountManager: AccountMa } fun disableAllNotifications(context: Context, accountManager: AccountManager) { + Timber.d("Disabling all notifications") disablePushNotifications(context, accountManager) disablePullNotifications(context) } @@ -207,18 +235,21 @@ suspend fun registerUnifiedPushEndpoint( } // Synchronize the enabled / disabled state of notifications with server-side subscription -suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { - withContext(Dispatchers.IO) { - api.updatePushNotificationSubscription( +suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity): Result { + return withContext(Dispatchers.IO) { + return@withContext api.updatePushNotificationSubscription( "Bearer ${account.accessToken}", account.domain, buildSubscriptionData(context, account), - ).onSuccess { + ).fold({ Timber.d("UnifiedPush subscription updated for account %d", account.id) - account.pushServerKey = it.serverKey accountManager.saveAccount(account) - } + Ok(Unit) + }, { + Timber.e(it, "Could not enable UnifiedPush subscription for account %d", account.id) + Err(it) + }) } } diff --git a/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt index d924c449f..20ffa064b 100644 --- a/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt @@ -19,9 +19,9 @@ package app.pachli.components.preference import android.os.Bundle import androidx.preference.PreferenceFragmentCompat import app.pachli.R +import app.pachli.components.notifications.androidNotificationsAreEnabled import app.pachli.components.notifications.disablePullNotifications import app.pachli.components.notifications.enablePullNotifications -import app.pachli.components.notifications.notificationsAreEnabled import app.pachli.core.accounts.AccountManager import app.pachli.core.database.model.AccountEntity import app.pachli.core.preferences.PrefKeys @@ -48,7 +48,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isChecked = activeAccount.notificationsEnabled setOnPreferenceChangeListener { _, newValue -> updateAccount { it.notificationsEnabled = newValue as Boolean } - if (notificationsAreEnabled(context, accountManager)) { + if (androidNotificationsAreEnabled(context, accountManager)) { enablePullNotifications(context) } else { disablePullNotifications(context) diff --git a/app/src/main/java/app/pachli/usecase/LogoutUsecase.kt b/app/src/main/java/app/pachli/usecase/LogoutUsecase.kt index cdcb40cfc..bd4483ab9 100644 --- a/app/src/main/java/app/pachli/usecase/LogoutUsecase.kt +++ b/app/src/main/java/app/pachli/usecase/LogoutUsecase.kt @@ -2,10 +2,10 @@ package app.pachli.usecase import android.content.Context import app.pachli.components.drafts.DraftHelper +import app.pachli.components.notifications.androidNotificationsAreEnabled import app.pachli.components.notifications.deleteNotificationChannelsForAccount import app.pachli.components.notifications.disablePullNotifications import app.pachli.components.notifications.disableUnifiedPushNotificationsForAccount -import app.pachli.components.notifications.notificationsAreEnabled import app.pachli.core.accounts.AccountManager import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.dao.RemoteKeyDao @@ -48,7 +48,7 @@ class LogoutUsecase @Inject constructor( disableUnifiedPushNotificationsForAccount(context, activeAccount) // disable pull notifications - if (!notificationsAreEnabled(context, accountManager)) { + if (!androidNotificationsAreEnabled(context, accountManager)) { disablePullNotifications(context) } diff --git a/app/src/main/java/app/pachli/worker/NotificationWorker.kt b/app/src/main/java/app/pachli/worker/NotificationWorker.kt index 23df95485..a6ae37fce 100644 --- a/app/src/main/java/app/pachli/worker/NotificationWorker.kt +++ b/app/src/main/java/app/pachli/worker/NotificationWorker.kt @@ -27,6 +27,7 @@ import app.pachli.components.notifications.NOTIFICATION_ID_FETCH_NOTIFICATION import app.pachli.components.notifications.NotificationFetcher import app.pachli.components.notifications.createWorkerNotification import javax.inject.Inject +import timber.log.Timber /** Fetch and show new notifications. */ class NotificationWorker( @@ -37,6 +38,7 @@ class NotificationWorker( val notification: Notification = createWorkerNotification(applicationContext, R.string.notification_notification_worker) override suspend fun doWork(): Result { + Timber.d("NotificationWorker.doWork() started") notificationsFetcher.fetchAndShow() return Result.success() } diff --git a/app/src/main/java/app/pachli/worker/PruneLogEntryEntityWorker.kt b/app/src/main/java/app/pachli/worker/PruneLogEntryEntityWorker.kt new file mode 100644 index 000000000..cdecd8522 --- /dev/null +++ b/app/src/main/java/app/pachli/worker/PruneLogEntryEntityWorker.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.worker + +import android.app.Notification +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import app.pachli.R +import app.pachli.components.notifications.NOTIFICATION_ID_PRUNE_CACHE +import app.pachli.components.notifications.createWorkerNotification +import app.pachli.core.database.dao.LogEntryDao +import java.time.Instant +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours + +/** Prune the database cache of old statuses. */ +class PruneLogEntryEntityWorker( + appContext: Context, + workerParams: WorkerParameters, + private val logEntryDao: LogEntryDao, +) : CoroutineWorker(appContext, workerParams) { + val notification: Notification = createWorkerNotification(applicationContext, R.string.notification_prune_cache) + + override suspend fun doWork(): Result { + val now = Instant.now() + val oldest = now.minusMillis(OLDEST_ENTRY.inWholeMilliseconds) + logEntryDao.prune(oldest) + return Result.success() + } + + override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification) + + companion object { + private val OLDEST_ENTRY = 48.hours + const val PERIODIC_WORK_TAG = "PruneLogEntryEntityWorker_periodic" + } + + class Factory @Inject constructor( + private val logEntryDao: LogEntryDao, + ) : ChildWorkerFactory { + override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker { + return PruneLogEntryEntityWorker(appContext, params, logEntryDao) + } + } +} diff --git a/core/accounts/src/main/kotlin/app/pachli/core/accounts/AccountManager.kt b/core/accounts/src/main/kotlin/app/pachli/core/accounts/AccountManager.kt index 237da4295..02dabce97 100644 --- a/core/accounts/src/main/kotlin/app/pachli/core/accounts/AccountManager.kt +++ b/core/accounts/src/main/kotlin/app/pachli/core/accounts/AccountManager.kt @@ -223,9 +223,9 @@ class AccountManager @Inject constructor( } /** - * @return true if at least one account has notifications enabled + * @return True if at least one account has Android notifications enabled */ - fun areNotificationsEnabled(): Boolean { + fun areAndroidNotificationsEnabled(): Boolean { return accounts.any { it.notificationsEnabled } } diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt new file mode 100644 index 000000000..a71028d8f --- /dev/null +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/LogEntryTree.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.activity + +import android.util.Log +import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.database.dao.LogEntryDao +import app.pachli.core.database.model.LogEntryEntity +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * [Timber.Tree] that writes logs to the [LogEntryDao]. + * + * Only logs that are of level [Log.WARN] or higher, or are tagged with one + * [loggableTags] are logged, everything else is ignored. + */ +@Singleton +class LogEntryTree @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, + private val logEntryDao: LogEntryDao, +) : Timber.DebugTree() { + /** Logs with a tag in this set will be logged */ + private val loggableTags = setOf( + "Noti", + "NotificationFetcher", + "NotificationHelperKt", + "NotificationWorker", + "PushNotificationHelperKt", + "UnifiedPushBroadcastReceiver", + ) + + /** Logs with this priority or higher will be logged */ + private val minPriority = Log.WARN + + override fun isLoggable(tag: String?, priority: Int): Boolean { + return (priority >= minPriority) || (tag in loggableTags) + } + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + externalScope.launch { + logEntryDao.upsert( + LogEntryEntity( + instant = Instant.now(), + priority = priority, + tag = tag, + message = message, + t = t, + ), + ) + } + } +} diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/NotificationConfig.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/NotificationConfig.kt new file mode 100644 index 000000000..4f3abefcf --- /dev/null +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/NotificationConfig.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.activity + +import com.github.michaelbull.result.Result +import java.time.Instant + +/** + * Singleton to record information about how notifications are configured + * and fetched as the app runs. + */ +object NotificationConfig { + /** Method used to fetch Mastodon notifications */ + sealed interface Method { + /** Notifications are pushed using UnifiedPush */ + data object Push : Method + + /** Notifications are periodically pulled */ + data object Pull : Method + + /** Notification method is not known */ + data object Unknown : Method + + /** Notifications should be pushed, there was an error configuring UnifiedPush */ + data class PushError(val t: Throwable) : Method + } + + /** True if notification channels are enabled */ + var androidNotificationsEnabled = false + + /** + * True if UnifiedPush is available + * + * @see [app.pachli.components.notifications.isUnifiedPushAvailable] + */ + var unifiedPushAvailable = false + + /** + * True if any account is missing the `push` OAuth scope. + * + * @see [app.pachli.components.notifications.anyAccountNeedsMigration] + */ + var anyAccountNeedsMigration = false + + /** The current global method for fetching notifications */ + var notificationMethod: Method = Method.Unknown + + /** + * The current per-account method for fetching notifications for that account. + * + * The map key is [app.pachli.core.database.model.AccountEntity.fullName] + */ + var notificationMethodAccount = mutableMapOf() + + /** + * Per-account details of the last time notifications were fetched for + * the account. + * + * The map key is [app.pachli.core.database.model.AccountEntity.fullName]. + * + * The value [Pair] is a timestamp of the fetch, and either a successful result, + * or a description of what failed. + */ + var lastFetchNewNotifications = mutableMapOf>>() +} diff --git a/core/activity/src/orange/kotlin/app/pachli/core/activity/CrashReporter.kt b/core/activity/src/orange/kotlin/app/pachli/core/activity/CrashReporter.kt index 8ec30f620..bd1b32b31 100644 --- a/core/activity/src/orange/kotlin/app/pachli/core/activity/CrashReporter.kt +++ b/core/activity/src/orange/kotlin/app/pachli/core/activity/CrashReporter.kt @@ -19,7 +19,7 @@ package app.pachli.core.activity import android.app.Application import android.content.Context -import android.util.Log +import app.pachli.core.database.model.LogEntry import app.pachli.core.designsystem.R as DR import com.google.auto.service.AutoService import java.time.Instant @@ -73,18 +73,18 @@ object TreeRing : Timber.DebugTree() { * Store the components of a log line without doing any formatting or other * work at logging time. */ - data class LogEntry( - val instant: Instant, - val priority: Int, - val tag: String?, - val message: String, - val t: Throwable?, - ) + data class TreeRingLogEntry( + override val instant: Instant, + override val priority: Int, + override val tag: String?, + override val message: String, + override val t: Throwable?, + ) : LogEntry - val buffer = RingBuffer(1000) + val buffer = RingBuffer(1000) override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - buffer.add(LogEntry(Instant.now(), priority, tag, message, t)) + buffer.add(TreeRingLogEntry(Instant.now(), priority, tag, message, t)) } } @@ -94,15 +94,6 @@ object TreeRing : Timber.DebugTree() { */ @AutoService(Collector::class) class TreeRingCollector : Collector { - /** Map log priority values to characters to use when displaying the log */ - private val priority = mapOf( - Log.VERBOSE to 'V', - Log.DEBUG to 'D', - Log.INFO to 'I', - Log.WARN to 'W', - Log.ERROR to 'E', - Log.ASSERT to 'A', - ) override fun collect( context: Context, @@ -112,15 +103,7 @@ class TreeRingCollector : Collector { ) { crashReportData.put( "TreeRing", - TreeRing.buffer.toList().joinToString("\n") { - "%s %c/%s: %s%s".format( - it.instant.toString(), - priority[it.priority] ?: '?', - it.tag, - it.message, - it.t?.let { t -> " $t" } ?: "", - ) - }, + TreeRing.buffer.toList().joinToString("\n"), ) } } diff --git a/core/common/src/main/kotlin/app/pachli/core/common/di/SystemServiceModule.kt b/core/common/src/main/kotlin/app/pachli/core/common/di/SystemServiceModule.kt new file mode 100644 index 000000000..e7b83221e --- /dev/null +++ b/core/common/src/main/kotlin/app/pachli/core/common/di/SystemServiceModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.common.di + +import android.app.Application +import android.app.usage.UsageStatsManager +import android.content.Context +import android.os.PowerManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object SystemServiceModule { + @Provides + @Singleton + fun providesPowerManager(application: Application) = application.getSystemService(Context.POWER_SERVICE) as PowerManager + + @Provides + @Singleton + fun providesUsageStatsManager(application: Application) = application.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 5a71b7114..058451a30 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -28,6 +28,10 @@ android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } dependencies { @@ -39,4 +43,6 @@ dependencies { implementation(libs.moshi) implementation(libs.moshi.adapters) ksp(libs.moshi.codegen) + // Instant in LogEntryEntity + coreLibraryDesugaring(libs.desugar.jdk.libs) } diff --git a/core/database/schemas/app.pachli.core.database.AppDatabase/4.json b/core/database/schemas/app.pachli.core.database.AppDatabase/4.json new file mode 100644 index 000000000..c5e7eb27d --- /dev/null +++ b/core/database/schemas/app.pachli.core.database.AppDatabase/4.json @@ -0,0 +1,1190 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "8a29c83f5eb998107d9367e5326c35b9", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LogEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `instant` INTEGER NOT NULL, `priority` INTEGER, `tag` TEXT, `message` TEXT NOT NULL, `t` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "t", + "columnName": "t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8a29c83f5eb998107d9367e5326c35b9')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index 402431fa2..9d2ac71f0 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -26,6 +26,7 @@ import app.pachli.core.database.dao.AccountDao import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.dao.DraftDao import app.pachli.core.database.dao.InstanceDao +import app.pachli.core.database.dao.LogEntryDao import app.pachli.core.database.dao.RemoteKeyDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TranslatedStatusDao @@ -33,6 +34,7 @@ import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.ConversationEntity import app.pachli.core.database.model.DraftEntity import app.pachli.core.database.model.InstanceEntity +import app.pachli.core.database.model.LogEntryEntity import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity @@ -51,11 +53,13 @@ import app.pachli.core.database.model.TranslatedStatusEntity RemoteKeyEntity::class, StatusViewDataEntity::class, TranslatedStatusEntity::class, + LogEntryEntity::class, ], - version = 3, + version = 4, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4), ], ) abstract class AppDatabase : RoomDatabase() { @@ -66,6 +70,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun draftDao(): DraftDao abstract fun remoteKeyDao(): RemoteKeyDao abstract fun translatedStatusDao(): TranslatedStatusDao + abstract fun logEntryDao(): LogEntryDao @DeleteColumn("TimelineStatusEntity", "expanded") @DeleteColumn("TimelineStatusEntity", "contentCollapsed") diff --git a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt index 28ead74ef..6d8e9948c 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt @@ -34,6 +34,7 @@ import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import java.net.URLDecoder import java.net.URLEncoder +import java.time.Instant import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -199,4 +200,16 @@ class Converters @Inject constructor( fun jsonToTranslatedAttachment(translatedAttachmentJson: String): List? { return moshi.adapter?>().fromJson(translatedAttachmentJson) } + + @TypeConverter + fun instantToLong(instant: Instant) = instant.toEpochMilli() + + @TypeConverter + fun longToInstant(millis: Long): Instant = Instant.ofEpochMilli(millis) + + @TypeConverter + fun throwableToString(t: Throwable) = t.message + + @TypeConverter + fun stringToThrowable(s: String) = Throwable(message = s) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt new file mode 100644 index 000000000..6b71f6881 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/LogEntryDao.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2018 Conny Duck + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.TypeConverters +import androidx.room.Upsert +import app.pachli.core.database.Converters +import app.pachli.core.database.model.LogEntryEntity +import java.time.Instant + +/** + * Read and write [LogEntryEntity]. + */ +@Dao +interface LogEntryDao { + /** Upsert [logEntry] */ + @Upsert + suspend fun upsert(logEntry: LogEntryEntity): Long + + /** Load all [LogEntryEntity], ordered oldest first */ + @Query( + """ +SELECT * + FROM LogEntryEntity + ORDER BY id ASC + """, + ) + suspend fun loadAll(): List + + /** Delete all [LogEntryEntity] older than [cutoff] */ + @TypeConverters(Converters::class) + @Query( + """ +DELETE + FROM LogEntryEntity + WHERE instant < :cutoff + """, + ) + suspend fun prune(cutoff: Instant) +} diff --git a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt index df2afae66..20e4d280e 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt @@ -68,6 +68,9 @@ object DatabaseModule { @Provides fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao() + + @Provides + fun providesLogEntryDao(appDatabase: AppDatabase) = appDatabase.logEntryDao() } /** diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt index acb8f8d2b..03dda0719 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt @@ -45,9 +45,11 @@ data class AccountEntity( var clientSecret: String?, var isActive: Boolean, var accountId: String = "", + /** User's local name, without the leading `@` or the `@domain` portion */ var username: String = "", var displayName: String = "", var profilePictureUrl: String = "", + /** User wants Android notifications enabled for this account */ var notificationsEnabled: Boolean = true, var notificationsMentioned: Boolean = true, var notificationsFollowed: Boolean = true, @@ -113,6 +115,7 @@ data class AccountEntity( val identifier: String get() = "$domain:$accountId" + /** Full account name, of the form `@username@domain` */ val fullName: String get() = "@$username@$domain" diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/LogEntryEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/LogEntryEntity.kt new file mode 100644 index 000000000..08bea2c90 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/LogEntryEntity.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import android.util.Log +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import app.pachli.core.database.Converters +import java.time.Instant + +/** Map log priority values to characters to use when displaying the log */ +private val priorityToChar = mapOf( + Log.VERBOSE to 'V', + Log.DEBUG to 'D', + Log.INFO to 'I', + Log.WARN to 'W', + Log.ERROR to 'E', + Log.ASSERT to 'A', +) + +/** An entry in the log. See [Log] for details. */ +interface LogEntry { + val instant: Instant + val priority: Int? + val tag: String? + val message: String + val t: Throwable? + + fun LogEntry.toString() = "%s %c/%s: %s%s".format( + instant.toString(), + priorityToChar[priority] ?: '?', + tag, + message, + t?.let { t -> " $t" } ?: "", + ) +} + +/** + * @see [LogEntry] + */ +@Entity +@TypeConverters(Converters::class) +data class LogEntryEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + override val instant: Instant, + override val priority: Int? = null, + override val tag: String? = null, + override val message: String, + override val t: Throwable? = null, +) : LogEntry diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt index 4b3d05d67..a94241714 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt @@ -23,7 +23,12 @@ import java.util.Date @JsonClass(generateAdapter = true) data class Account( val id: String, + /** The username of the account, without the domain */ @Json(name = "username") val localUsername: String, + /** + * The webfinger account URI. Equal to [localUsername] for local users, or + * [localUsername]@domain for remote users. + */ @Json(name = "acct") val username: String, // should never be null per API definition, but some servers break the contract @Json(name = "display_name") val displayName: String?, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt index 57d75b673..57619c8ed 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt @@ -32,6 +32,7 @@ data class Notification( ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ + @JsonClass(generateAdapter = false) enum class Type(val presentation: String) { @Json(name = "unknown") UNKNOWN("unknown"), diff --git a/core/ui/src/main/kotlin/app/pachli/core/ui/AlertDialogExtensions.kt b/core/ui/src/main/kotlin/app/pachli/core/ui/AlertDialogExtensions.kt index 4a4a4a13e..934df47e5 100644 --- a/core/ui/src/main/kotlin/app/pachli/core/ui/AlertDialogExtensions.kt +++ b/core/ui/src/main/kotlin/app/pachli/core/ui/AlertDialogExtensions.kt @@ -24,7 +24,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine /** - * Wait for the alert dialog buttons to be clicked, return the ID of the clicked button + * Wait for the alert dialog buttons to be clicked, return the ID of the clicked button, + * [AlertDialog.BUTTON_POSITIVE], [AlertDialog.BUTTON_NEGATIVE], or + * [AlertDialog.BUTTON_NEUTRAL]. * * @param positiveText Text to show on the positive button * @param negativeText Optional text to show on the negative button diff --git a/feature/about/build.gradle.kts b/feature/about/build.gradle.kts index a53f8516d..83110ad2a 100644 --- a/feature/about/build.gradle.kts +++ b/feature/about/build.gradle.kts @@ -30,6 +30,10 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["disableAnalytics"] = "true" } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } aboutLibraries { @@ -61,4 +65,7 @@ dependencies { // For FixedSizeDrawable implementation(libs.glide.core) + + // For Instant.now() in NotificationLogFragment + coreLibraryDesugaring(libs.desugar.jdk.libs) } diff --git a/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt b/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt index 9bbd31cee..0adf5f126 100644 --- a/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt +++ b/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt @@ -55,6 +55,8 @@ class AboutActivity : BottomSheetActivity(), MenuProvider { setDisplayShowHomeEnabled(true) } + addMenuProvider(this) + val adapter = AboutFragmentAdapter(this) binding.pager.adapter = adapter binding.pager.reduceSwipeSensitivity() @@ -75,13 +77,14 @@ class AboutActivity : BottomSheetActivity(), MenuProvider { } class AboutFragmentAdapter(val activity: FragmentActivity) : FragmentStateAdapter(activity) { - override fun getItemCount() = 3 + override fun getItemCount() = 4 override fun createFragment(position: Int): Fragment { return when (position) { 0 -> AboutFragment.newInstance() 1 -> LibsBuilder().supportFragment() 2 -> PrivacyPolicyFragment.newInstance() + 3 -> NotificationFragment.newInstance() else -> throw IllegalStateException() } } @@ -91,6 +94,7 @@ class AboutFragmentAdapter(val activity: FragmentActivity) : FragmentStateAdapte 0 -> activity.getString(R.string.about_title_activity) 1 -> activity.getString(R.string.title_licenses) 2 -> activity.getString(R.string.about_privacy_policy) + 3 -> "Notifications" else -> throw IllegalStateException() } } diff --git a/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationDetailsFragment.kt b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationDetailsFragment.kt new file mode 100644 index 000000000..c109e8b59 --- /dev/null +++ b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationDetailsFragment.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.feature.about + +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager.STANDBY_BUCKET_ACTIVE +import android.app.usage.UsageStatsManager.STANDBY_BUCKET_FREQUENT +import android.app.usage.UsageStatsManager.STANDBY_BUCKET_RARE +import android.app.usage.UsageStatsManager.STANDBY_BUCKET_RESTRICTED +import android.app.usage.UsageStatsManager.STANDBY_BUCKET_WORKING_SET +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.work.WorkInfo +import app.pachli.core.accounts.AccountManager +import app.pachli.core.activity.NotificationConfig +import app.pachli.core.activity.RefreshableFragment +import app.pachli.core.common.extensions.hide +import app.pachli.core.common.extensions.show +import app.pachli.core.common.extensions.viewBinding +import app.pachli.core.common.extensions.visible +import app.pachli.feature.about.databinding.FragmentNotificationDetailsBinding +import app.pachli.feature.about.databinding.ItemUsageEventBinding +import app.pachli.feature.about.databinding.ItemWorkInfoBinding +import dagger.hilt.android.AndroidEntryPoint +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.launch + +/** + * Fragment that shows details from [NotificationConfig]. + */ +@AndroidEntryPoint +class NotificationDetailsFragment : + Fragment(R.layout.fragment_notification_details), + RefreshableFragment { + @Inject + lateinit var accountManager: AccountManager + + private val viewModel: NotificationViewModel by viewModels() + + private val binding by viewBinding(FragmentNotificationDetailsBinding::bind) + + private val workInfoAdapter = WorkInfoAdapter() + + // Usage events need API 30, so this is conditionally created in onViewCreated. + private var usageEventAdapter: UsageEventAdapter? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.workInfoRecyclerView.adapter = workInfoAdapter + binding.workInfoRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + usageEventAdapter = UsageEventAdapter() + binding.usageEventSection.show() + binding.usageEventRecyclerView.adapter = usageEventAdapter + binding.usageEventRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + } else { + binding.usageEventSection.hide() + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiState.collect { bind(it) } } + + launch { + viewModel.pullWorkerFlow.collect { + workInfoAdapter.submitList(it.filter { it.state != WorkInfo.State.CANCELLED }) + } + } + + usageEventAdapter?.also { adapter -> + launch { + viewModel.usageEventsFlow.collect { adapter.submitList(it) } + } + } + } + } + } + + fun bind(uiState: UiState) { + binding.androidNotificationsEnabled.isChecked = uiState.androidNotificationsEnabled + binding.notificationsEnabledHelp.visible(!uiState.androidNotificationsEnabled) + binding.notificationsEnabledAccounts.text = uiState.notificationEnabledAccounts + + if (uiState.androidNotificationsEnabled) { + val method = uiState.notificationMethod + binding.notificationMethod.text = method.label(requireContext()) + binding.notificationMethod.show() + } else { + binding.notificationMethod.hide() + binding.pushSection.hide() + binding.pullSection.hide() + return + } + + binding.pushSection.show() + + binding.unifiedPushAvailable.isChecked = uiState.unifiedPushAvailable + binding.unifiedPushAvailableHelp.visible(!uiState.unifiedPushAvailable) + + binding.anyAccountNeedsMigration.isChecked = !uiState.anyAccountNeedsMigration + binding.anyAccountNeedsMigrationHelp.visible(uiState.anyAccountNeedsMigration) + binding.anyAccountNeedsMigrationAccounts.text = uiState.anyAccountNeedsMigrationAccounts + + binding.accountsUnifiedPushUrl.isChecked = uiState.allAccountsHaveUnifiedPushUrl + binding.accountsUnifiedPushUrlHelp.visible(!uiState.allAccountsHaveUnifiedPushUrl) + binding.accountsUnifiedPushUrlAccounts.text = uiState.allAccountsUnifiedPushUrlAccounts + + binding.accountsUnifiedPushSubscription.isChecked = uiState.allAccountsHaveUnifiedPushSubscription + binding.accountsUnifiedPushSubscriptionHelp.visible(!uiState.allAccountsHaveUnifiedPushSubscription) + binding.accountsUnifiedPushSubscriptionAccounts.text = uiState.allAccountsUnifiedPushSubscriptionAccounts + + binding.ntfyExempt.isChecked = uiState.ntfyIsExemptFromBatteryOptimisation + binding.ntfyExemptHelp.visible(!uiState.ntfyIsExemptFromBatteryOptimisation) + + binding.pullSection.visible(NotificationConfig.notificationMethod == NotificationConfig.Method.Pull) + + binding.pachliExempt.isChecked = uiState.pachliIsExemptFromBatteryOptimisation + binding.pachliExemptHelp.visible(!uiState.pachliIsExemptFromBatteryOptimisation) + + binding.lastFetch.text = uiState.lastFetch + } + + override fun refreshContent() { + viewModel.refresh() + } + + companion object { + fun newInstance() = NotificationDetailsFragment() + } +} + +fun NotificationConfig.Method.label(context: Context) = when (this) { + is NotificationConfig.Method.Push -> context.getString(R.string.notification_log_method_push) + is NotificationConfig.Method.Pull -> context.getString(R.string.notification_log_method_pull) + is NotificationConfig.Method.Unknown -> context.getString(R.string.notification_log_method_unknown) + is NotificationConfig.Method.PushError -> context.getString( + R.string.notification_log_method_pusherror, + this.t, + ) +} + +class WorkInfoAdapter : ListAdapter(diffCallback) { + class ViewHolder(private val binding: ItemWorkInfoBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(workInfo: WorkInfo) = with(workInfo) { + binding.id.text = id.toString() + binding.state.text = state.toString() + + binding.runAttemptCount.text = binding.root.context.getString( + R.string.notification_log_previous_attempts, + runAttemptCount, + ) + + if (state == WorkInfo.State.ENQUEUED) { + binding.nextScheduleTime.show() + val now = Instant.now() + val nextScheduleInstant = Instant.ofEpochMilli(nextScheduleTimeMillis) + binding.nextScheduleTime.text = binding.root.context.getString( + R.string.notification_log_scheduled_in, + Duration.between(now, nextScheduleInstant).asDdHhMmSs(), + instantFormatter.format(nextScheduleInstant), + ) + } else { + binding.nextScheduleTime.hide() + } + + binding.runAttemptCount.show() + + if (runAttemptCount > 0 && state == WorkInfo.State.ENQUEUED) { + binding.stopReason.show() + binding.stopReason.text = stopReason.toString() + } else { + binding.stopReason.hide() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(ItemWorkInfoBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = + holder.bind(getItem(position)) + + companion object { + val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: WorkInfo, newItem: WorkInfo) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: WorkInfo, newItem: WorkInfo) = false + } + } +} + +@RequiresApi(Build.VERSION_CODES.R) +class UsageEventAdapter : ListAdapter(diffCallback) { + class ViewHolder(private val binding: ItemUsageEventBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(usageEvent: UsageEvents.Event) = with(usageEvent) { + val now = Instant.now() + val then = Instant.ofEpochMilli(timeStamp) + binding.text.text = binding.root.context.getString( + bucketDesc[appStandbyBucket] ?: R.string.notification_details_standby_bucket_unknown, + ) + binding.timestamp.text = binding.root.context.getString( + R.string.notification_details_ago, + Duration.between(then, now).asDdHhMmSs(), + instantFormatter.format(then), + ) + } + companion object { + /** Descriptions for each `STANDBY_BUCKET_` type */ + // Descriptions from https://developer.android.com/topic/performance/power/power-details + val bucketDesc = mapOf( + // 5 = STANDBY_BUCKET_EXEMPTED, marked as @SystemApi + 5 to R.string.notification_details_standby_bucket_exempted, + STANDBY_BUCKET_ACTIVE to R.string.notification_details_standby_bucket_active, + STANDBY_BUCKET_WORKING_SET to R.string.notification_details_standby_bucket_working_set, + STANDBY_BUCKET_FREQUENT to R.string.notification_details_standby_bucket_frequent, + STANDBY_BUCKET_RARE to R.string.notification_details_standby_bucket_rare, + STANDBY_BUCKET_RESTRICTED to R.string.notification_details_standby_bucket_restricted, + // 50 = STANDBY_BUCKET_NEVER, marked as @SystemApi + 50 to R.string.notification_details_standby_bucket_never, + ) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(ItemUsageEventBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UsageEvents.Event, newItem: UsageEvents.Event) = oldItem.timeStamp == newItem.timeStamp + override fun areContentsTheSame(oldItem: UsageEvents.Event, newItem: UsageEvents.Event) = false + } + } +} diff --git a/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationFragment.kt b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationFragment.kt new file mode 100644 index 000000000..2188854cf --- /dev/null +++ b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationFragment.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.feature.about + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import app.pachli.core.activity.CustomFragmentStateAdapter +import app.pachli.core.activity.RefreshableFragment +import app.pachli.core.common.extensions.viewBinding +import app.pachli.core.ui.reduceSwipeSensitivity +import app.pachli.feature.about.databinding.FragmentNotificationBinding +import com.google.android.material.color.MaterialColors +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import java.time.Duration +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Fragment that hosts [NotificationDetailsFragment] and [NotificationLogFragment] + * and helper functions they use. + */ +@AndroidEntryPoint +class NotificationFragment : + Fragment(R.layout.fragment_notification), + MenuProvider, + OnRefreshListener { + private val binding by viewBinding(FragmentNotificationBinding::bind) + + lateinit var adapter: NotificationFragmentAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + adapter = NotificationFragmentAdapter(this) + binding.pager.adapter = adapter + binding.pager.reduceSwipeSensitivity() + + TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position -> + tab.text = adapter.title(position) + }.attach() + + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeColors(MaterialColors.getColor(binding.root, androidx.appcompat.R.attr.colorPrimary)) + } + + override fun onRefresh() { + adapter.refreshContent() + binding.swipeRefreshLayout.isRefreshing = false + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notification, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + companion object { + fun newInstance() = NotificationFragment() + } +} + +class NotificationFragmentAdapter(val fragment: Fragment) : CustomFragmentStateAdapter(fragment) { + override fun getItemCount() = 2 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> NotificationDetailsFragment.newInstance() + 1 -> NotificationLogFragment.newInstance() + else -> throw IllegalStateException() + } + } + + fun title(position: Int): CharSequence { + return when (position) { + 0 -> fragment.getString(R.string.notification_details_title) + 1 -> fragment.getString(R.string.notification_log_title) + else -> throw IllegalStateException() + } + } + + fun refreshContent() { + for (i in 0..itemCount) { + (getFragment(i) as? RefreshableFragment)?.refreshContent() + } + } +} + +/** + * @return The [Duration] formatted as `NNdNNhNNmNNs` (e.g., `01d23h15m23s`) with any leading + * zero components removed. So 34 minutes and 15 seconds is `34m15s` not `00d00h34m15s`. + */ +fun Duration.asDdHhMmSs(): String { + val days = this.toDaysPart() + val hours = this.toHoursPart() + val minutes = this.toMinutesPart() + val seconds = this.toSecondsPart() + + return when { + days > 0 -> "%02dd%02dh%02dm%02ds".format(days, hours, minutes, seconds) + hours > 0 -> "%02dh%02dm%02ds".format(hours, minutes, seconds) + minutes > 0 -> "%02dm%02ds".format(minutes, seconds) + else -> "%02ds".format(seconds) + } +} + +val instantFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH:mm:ss") + .withZone(ZoneId.systemDefault()) diff --git a/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationLogFragment.kt b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationLogFragment.kt new file mode 100644 index 000000000..0ac70baa9 --- /dev/null +++ b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationLogFragment.kt @@ -0,0 +1,453 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.feature.about + +import android.app.Activity +import android.app.usage.UsageEvents +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.text.set +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.work.WorkInfo +import androidx.work.WorkInfo.State.CANCELLED +import androidx.work.WorkInfo.State.ENQUEUED +import app.pachli.core.activity.RefreshableFragment +import app.pachli.core.common.extensions.viewBinding +import app.pachli.core.database.dao.LogEntryDao +import app.pachli.core.database.model.LogEntryEntity +import app.pachli.core.ui.await +import app.pachli.feature.about.databinding.FragmentNotificationLogBinding +import app.pachli.feature.about.databinding.ItemLogEntryBinding +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Fragment that shows logs from [LogEntryDao], and can download them as a text + * report with additional information from [app.pachli.core.activity.NotificationConfig]. + */ +@AndroidEntryPoint +class NotificationLogFragment : + Fragment(R.layout.fragment_notification_log), + RefreshableFragment { + @Inject + @ApplicationContext + lateinit var applicationContext: Context + + @Inject + lateinit var logEntryDao: LogEntryDao + + private val viewModel: NotificationViewModel by viewModels() + + private val binding by viewBinding(FragmentNotificationLogBinding::bind) + + private val adapter = LogEntryAdapter() + + private lateinit var layoutManager: LinearLayoutManager + + /** The set of log priorities to show */ + private val shownPriorities = MutableStateFlow(defaultPriorities) + + /** Increment to trigger a reload */ + private val reload = MutableStateFlow(0) + + /** True if the log should be sorted in reverse, newest entries first */ + private val sortReverse = MutableStateFlow(false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = layoutManager + + binding.filter.setOnClickListener { + viewLifecycleOwner.lifecycleScope.launch { + val filters = shownPriorities.value.toMutableSet() + val result = showFilterDialog(view.context, filters) + if (result == AlertDialog.BUTTON_POSITIVE) { + shownPriorities.value = filters + } + } + } + + binding.sort.setOnCheckedChangeListener { _, isChecked -> + sortReverse.value = isChecked + } + + binding.download.setOnClickListener { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plan" + val now = Instant.now() + + putExtra( + Intent.EXTRA_TITLE, + "pachli-notification-logs-${instantFormatter.format(now)}.txt", + ) + } + startActivityForResult(intent, CREATE_FILE) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Collect pullWorkerFlow and usageEventsFlow so `.value` can be read in onActivityResult. + launch { viewModel.pullWorkerFlow.collect() } + launch { viewModel.usageEventsFlow.collect() } + + launch { + sortReverse.collect { + layoutManager.stackFromEnd = it + layoutManager.reverseLayout = it + } + } + + launch { + combine(shownPriorities, reload) { shownLevels, _ -> + logEntryDao.loadAll().filter { it.priority in shownLevels } + }.collect { + adapter.submitList(it) + } + } + } + } + } + + fun bind() { + reload.getAndUpdate { it + 1 } + } + + override fun refreshContent() { + bind() + } + + /** + * Shows a dialog allowing the user to select one or more Log [priorities]. + * + * @param priorities Initial set of priorities to show. This will be modified as + * the user interacts with the dialog, and **is not** restored if they + * cancel. + */ + private suspend fun showFilterDialog(context: Context, priorities: MutableSet): Int { + val items = arrayOf("Verbose", "Debug", "Info", "Warn", "Error", "Assert") + val checkedItems = booleanArrayOf( + Log.VERBOSE in priorities, + Log.DEBUG in priorities, + Log.INFO in priorities, + Log.WARN in priorities, + Log.ERROR in priorities, + Log.ASSERT in priorities, + ) + + return AlertDialog.Builder(context) + .setTitle(R.string.notitication_log_filter_dialog_title) + .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> + val priority = when (which) { + 0 -> Log.VERBOSE + 1 -> Log.DEBUG + 2 -> Log.INFO + 3 -> Log.WARN + 4 -> Log.ERROR + 5 -> Log.ASSERT + else -> throw IllegalStateException("unknown log priority in filter dialog") + } + if (isChecked) { + priorities.add(priority) + } else { + priorities.remove(priority) + } + } + .create() + .await(android.R.string.ok, android.R.string.cancel) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != CREATE_FILE) return + if (resultCode != Activity.RESULT_OK) return + + data?.data?.also { uri -> + val contentResolver = applicationContext.contentResolver + + try { + contentResolver.openFileDescriptor(uri, "w")?.use { + FileOutputStream(it.fileDescriptor).use { stream -> + stream.write( + viewModel.uiState.value.asReport( + applicationContext, + Instant.now(), + viewModel.pullWorkerFlow.value, + viewModel.usageEventsFlow.value, + ).toByteArray(), + ) + stream.write( + adapter.currentList.joinToString("\n").toByteArray(), + ) + } + } + } catch (e: FileNotFoundException) { + Timber.e(e, "Download failed") + Snackbar.make( + binding.root, + getString(R.string.notification_log_download_failed, e.message), + Snackbar.LENGTH_INDEFINITE, + ).show() + } catch (e: IOException) { + Timber.e(e, "Download failed") + Snackbar.make( + binding.root, + getString(R.string.notification_log_download_failed, e.message), + Snackbar.LENGTH_INDEFINITE, + ).show() + } + } + } + + companion object { + /** Request code for startActivityForResult when creating a new file */ + private const val CREATE_FILE = 0 + + fun newInstance() = NotificationLogFragment() + + /** Default log priorities to show */ + private val defaultPriorities = setOf( + Log.VERBOSE, + Log.DEBUG, + Log.INFO, + Log.WARN, + Log.ERROR, + Log.ASSERT, + ) + } + + private fun Boolean.tick() = if (this) '✔' else ' ' + + /** + * @return A plain text report detailing the contents of [UiState], + * [pullWorkers], and [usageEvents]. + */ + private fun UiState.asReport( + context: Context, + now: Instant, + pullWorkers: List, + usageEvents: List, + ): String { + return """ + [%c] Android notifications are enabled? + %s + + %s + + --- + UnifiedPush + + [%c] UnifiedPush is available? + [%c] All accounts have 'push' OAuth scope? + %s + + [%c] All accounts have a UnifiedPush URL + %s + + [%c] All accounts are subscribed + %s + + [%c] ntfy is exempt from battery optimisation + + --- + + Pull notifications + [%c] Pachli is exempt from battery optimisation + + Workers + %s + + --- + + Last /api/v1/notifications request + %s + + --- + + Power management restrictions + %s + --- + Log follows: + + + """.trimIndent().format( + this.androidNotificationsEnabled.tick(), + this.notificationEnabledAccounts, + this.notificationMethod.label(context), + + this.unifiedPushAvailable.tick(), + (!this.anyAccountNeedsMigration).tick(), + this.anyAccountNeedsMigrationAccounts, + + this.allAccountsHaveUnifiedPushUrl.tick(), + this.allAccountsUnifiedPushUrlAccounts, + + this.allAccountsHaveUnifiedPushSubscription.tick(), + this.allAccountsUnifiedPushSubscriptionAccounts, + + this.ntfyIsExemptFromBatteryOptimisation.tick(), + + this.pachliIsExemptFromBatteryOptimisation.tick(), + pullWorkers + .filter { it.state != CANCELLED } + .takeIf { it.isNotEmpty() } + ?.joinToString("\n") { it.asReport(now) } ?: "No workers in non-CANCELLED state!", + + this.lastFetch, + + usageEvents + .takeIf { it.isNotEmpty() } + ?.joinToString("\n") { it.asReport(context, now) } ?: "No usage events", + ) + } + + private fun WorkInfo.asReport(now: Instant): String { + return "%s #%d %s %s\n".format( + state, + runAttemptCount, + if (state == ENQUEUED) { + val then = Instant.ofEpochMilli(nextScheduleTimeMillis) + "Scheduled in %s @ %s".format( + Duration.between(now, then).asDdHhMmSs(), + instantFormatter.format(then), + ) + } else { + "" + }, + if (runAttemptCount > 0 && state == ENQUEUED) { + stopReason + } else { + "" + }, + ) + } + + private fun UsageEvents.Event.asReport(context: Context, now: Instant): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return "No events, Build SDK ${Build.VERSION.SDK_INT} < R (${Build.VERSION_CODES.R}" + } + val then = Instant.ofEpochMilli(timeStamp) + return "%s %s".format( + context.getString(UsageEventAdapter.ViewHolder.bucketDesc[appStandbyBucket] ?: R.string.notification_details_standby_bucket_unknown), + context.getString( + R.string.notification_details_ago, + Duration.between(then, now).asDdHhMmSs(), + instantFormatter.format(then), + ), + ) + } +} + +class LogEntryAdapter : ListAdapter(diffCallback) { + class ViewHolder(private val binding: ItemLogEntryBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(logEntry: LogEntryEntity) { + val now = Instant.now() + + val tag = SpannableString(logEntry.tag).apply { set(0, this.length, tagSpan) } + val duration = Duration.between(logEntry.instant, now).asDdHhMmSs() + val instant = logEntry.instant.toString() + + val timestamp = SpannableStringBuilder() + .append(tag) + .append(": ") + .append( + binding.root.context.getString( + R.string.notification_details_ago, + duration, + instant, + ), + ) + .apply { + set(0, this.length, tagSpan) + set(0, this.length, RelativeSizeSpan(0.7f)) + } + + binding.timestamp.text = timestamp + + val text = SpannableStringBuilder() + text.append( + SpannableString("%s%s".format(logEntry.message, logEntry.t?.let { t -> " $t" } ?: "")).apply { + set(0, this.length, messageSpan) + set(0, this.length, prioritySpan[logEntry.priority] ?: ForegroundColorSpan(Color.GRAY)) + }, + ) + + binding.text.text = text + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(ItemLogEntryBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = + holder.bind(getItem(position)) + + companion object { + val tagSpan = ForegroundColorSpan(Color.GRAY) + val messageSpan = ForegroundColorSpan(Color.BLACK) + + private val prioritySpan = mapOf( + Log.VERBOSE to ForegroundColorSpan(Color.GRAY), + Log.DEBUG to ForegroundColorSpan(Color.GRAY), + Log.INFO to ForegroundColorSpan(Color.BLACK), + Log.WARN to ForegroundColorSpan(Color.YELLOW), + Log.ERROR to ForegroundColorSpan(Color.RED), + Log.ASSERT to ForegroundColorSpan(Color.RED), + ) + + val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: LogEntryEntity, newItem: LogEntryEntity) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: LogEntryEntity, newItem: LogEntryEntity) = false + } + } +} diff --git a/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationViewModel.kt b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationViewModel.kt new file mode 100644 index 000000000..4fe696f89 --- /dev/null +++ b/feature/about/src/main/kotlin/app/pachli/feature/about/NotificationViewModel.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.feature.about + +import android.app.Application +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.os.Build +import android.os.PowerManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo +import androidx.work.WorkManager +import app.pachli.core.accounts.AccountManager +import app.pachli.core.activity.NotificationConfig +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class UiState( + /** Timestamp for this state. Ensures that each state is different */ + val now: Instant, + + /** @see [app.pachli.core.activity.NotificationConfig.androidNotificationsEnabled] */ + val androidNotificationsEnabled: Boolean, + + /** + * Formatted string of accounts and whether notifications are enabled for each + * account. + */ + val notificationEnabledAccounts: String, + + /** @see [app.pachli.core.activity.NotificationConfig.notificationMethod] */ + val notificationMethod: NotificationConfig.Method, + + /** @see [app.pachli.core.activity.NotificationConfig.unifiedPushAvailable] */ + val unifiedPushAvailable: Boolean, + + /** @see [app.pachli.core.activity.NotificationConfig.anyAccountNeedsMigration] */ + val anyAccountNeedsMigration: Boolean, + + /** Formatted string of accounts and whether the account needs migration */ + val anyAccountNeedsMigrationAccounts: String, + + /** True if all accounts have a UnifiedPush URL */ + val allAccountsHaveUnifiedPushUrl: Boolean, + + /** Formatted string of accounts with/out their UnifiedPush URL */ + val allAccountsUnifiedPushUrlAccounts: String, + + /** True if all accounts have a UnifiedPush notification method */ + val allAccountsHaveUnifiedPushSubscription: Boolean, + + /** Formatted string of accounts and their notification method */ + val allAccountsUnifiedPushSubscriptionAccounts: String, + + /** True if Android exempts ntfy from battery optimisation */ + val ntfyIsExemptFromBatteryOptimisation: Boolean, + + /** True if Android exempts Pachli from battery optimisation */ + val pachliIsExemptFromBatteryOptimisation: Boolean, + + /** + * Formatted string of accounts, when the last notification fetch was for each + * account, and the result. + */ + val lastFetch: String, +) { + companion object { + fun from(notificationConfig: NotificationConfig, accountManager: AccountManager, powerManager: PowerManager, now: Instant, packageName: String) = UiState( + now = now, + + androidNotificationsEnabled = notificationConfig.androidNotificationsEnabled, + notificationEnabledAccounts = accountManager.accounts.joinToString("\n") { + "%s %s".format(if (it.notificationsEnabled) "✔" else "✖", it.fullName) + }, + notificationMethod = notificationConfig.notificationMethod, + + unifiedPushAvailable = notificationConfig.unifiedPushAvailable, + anyAccountNeedsMigration = notificationConfig.anyAccountNeedsMigration, + anyAccountNeedsMigrationAccounts = accountManager.accounts.joinToString("\n") { + // Duplicate of accountNeedsMigration from PushNotificationHelper + "%s %s".format(if (it.oauthScopes.contains("push")) "✔" else "✖", it.fullName) + }, + + allAccountsHaveUnifiedPushUrl = accountManager.accounts.all { it.unifiedPushUrl.isNotEmpty() }, + allAccountsUnifiedPushUrlAccounts = accountManager.accounts.joinToString("\n") { + if (it.unifiedPushUrl.isNotEmpty()) { + "✔ %s %s".format(it.fullName, it.unifiedPushUrl) + } else { + "✖ %s".format(it.fullName) + } + }, + allAccountsHaveUnifiedPushSubscription = notificationConfig.notificationMethodAccount.all { it.value is NotificationConfig.Method.Push }, + allAccountsUnifiedPushSubscriptionAccounts = notificationConfig.notificationMethodAccount.map { + when (val method = it.value) { + NotificationConfig.Method.Pull -> "✖ ${it.key} (Pull)" + NotificationConfig.Method.Push -> "✔ ${it.key} (Push)" + is NotificationConfig.Method.PushError -> "✖ ${it.key} (Error: ${method.t})" + NotificationConfig.Method.Unknown -> "✖ ${it.key} (Unknown)" + } + }.joinToString("\n"), + + ntfyIsExemptFromBatteryOptimisation = powerManager.isIgnoringBatteryOptimizations("io.heckel.ntfy"), + pachliIsExemptFromBatteryOptimisation = powerManager.isIgnoringBatteryOptimizations(packageName), + + lastFetch = notificationConfig + .lastFetchNewNotifications + .map { (fullName, outcome) -> + val instant = outcome.first + val result = outcome.second + "%s\n %s ago @ %s".format( + when (result) { + is Ok -> "✔ $fullName" + is Err -> "✖ $fullName: ${result.error}" + }, + Duration.between(instant, now).asDdHhMmSs(), + instantFormatter.format(instant), + ) + }.joinToString("\n"), + ) + } +} + +@HiltViewModel +class NotificationViewModel @Inject constructor( + private val application: Application, + private val accountManager: AccountManager, + private val powerManager: PowerManager, + private val usageStatsManager: UsageStatsManager, +) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow( + UiState.from( + NotificationConfig, + accountManager, + powerManager, + Instant.now(), + application.packageName, + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + val pullWorkerFlow: StateFlow> = WorkManager.getInstance(application) + .getWorkInfosByTagFlow("pullNotifications").stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + private val _usageEventsFlow = MutableStateFlow>(emptyList()) + val usageEventsFlow = _usageEventsFlow.asStateFlow() + + init { + refresh() + } + + fun refresh() = viewModelScope.launch { + val now = Instant.now() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val usageEvents = usageStatsManager.queryEventsForSelf( + now.minusSeconds(86400 * 3).toEpochMilli(), + now.toEpochMilli(), + ) + val events = buildList { + var event = UsageEvents.Event() + while (usageEvents.getNextEvent(event)) { + if (event.eventType != UsageEvents.Event.STANDBY_BUCKET_CHANGED) continue + this.add(event) + event = UsageEvents.Event() + } + }.sortedByDescending { it.timeStamp } + _usageEventsFlow.value = events + } + + _uiState.value = UiState.from( + NotificationConfig, + accountManager, + powerManager, + now, + application.packageName, + ) + } +} diff --git a/feature/about/src/main/res/drawable/baseline_download.xml b/feature/about/src/main/res/drawable/baseline_download.xml new file mode 100644 index 000000000..1fc810652 --- /dev/null +++ b/feature/about/src/main/res/drawable/baseline_download.xml @@ -0,0 +1,5 @@ + + + diff --git a/feature/about/src/main/res/drawable/ic_filter.xml b/feature/about/src/main/res/drawable/ic_filter.xml new file mode 100644 index 000000000..249a05d57 --- /dev/null +++ b/feature/about/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/about/src/main/res/layout/activity_about.xml b/feature/about/src/main/res/layout/activity_about.xml index 7a5e98cbe..7ed901f6d 100644 --- a/feature/about/src/main/res/layout/activity_about.xml +++ b/feature/about/src/main/res/layout/activity_about.xml @@ -41,7 +41,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:tabGravity="fill" - app:tabMode="fixed" /> + app:tabMode="scrollable" /> + + + + + + + + + + diff --git a/feature/about/src/main/res/layout/fragment_notification_details.xml b/feature/about/src/main/res/layout/fragment_notification_details.xml new file mode 100644 index 000000000..9dce41780 --- /dev/null +++ b/feature/about/src/main/res/layout/fragment_notification_details.xml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/about/src/main/res/layout/fragment_notification_log.xml b/feature/about/src/main/res/layout/fragment_notification_log.xml new file mode 100644 index 000000000..786aa9d7e --- /dev/null +++ b/feature/about/src/main/res/layout/fragment_notification_log.xml @@ -0,0 +1,64 @@ + + + + +