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 @@ + + + + +