feat: Show information about notification fetches on "About" screen (#454)

Some users report that Pachli is not retrieving/displaying notifications
in a timely fashion.

To assist in diagnosing these errors, provide an additional set of tabs
on the "About" screen that contain information about how Pachli is
fetching notifications, and if not, why not.

Allow the user to save notification related logs and other details to a
file that can be attached to an e-mail or bug report.

Recording data:

- Provide a `NotificationConfig` singleton with properties to record
different aspects of the notification configuration. Update these
properties as different notification actions occur.

- Store logs in a `LogEntryEntity` table. Log events of interest with a
new `Timber` `LogEntryTree` that is planted in all cases.

- Add `PruneLogEntryEntityWorker` to trim saved logs to the last 48
hours.

Display:

- Add a `NotificationFragment` to `AboutActivity`. It hosts two other
fragments in tabs to show details from `NotificationConfig` and the
relevant logs, as well as controls for interacting with them.

Bug fixes:

- Filter out notifications with a null tag when processing active
notifications, prevents an NPE crash

Other changes:

- Log more details when errors occur so the bug reports are more helpful
This commit is contained in:
Nik Clayton 2024-02-17 15:57:32 +01:00 committed by GitHub
parent 3fb6994429
commit 23e3cf1035
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 3449 additions and 56 deletions

View File

@ -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)
}

View File

@ -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<PruneCacheWorker>(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<PruneLogEntryEntityWorker>(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) {

View File

@ -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<Notification> {
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

View File

@ -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) {

View File

@ -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<Unit, Throwable> {
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)
})
}
}

View File

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

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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)
}
}
}

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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,
),
)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<String, Method>()
/**
* 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<String, Pair<Instant, Result<Unit, String>>>()
}

View File

@ -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<LogEntry>(1000)
val buffer = RingBuffer<TreeRingLogEntry>(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"),
)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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
}

View File

@ -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)
}

File diff suppressed because it is too large Load Diff

View File

@ -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")

View File

@ -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<TranslatedAttachment>? {
return moshi.adapter<List<TranslatedAttachment>?>().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)
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<LogEntryEntity>
/** Delete all [LogEntryEntity] older than [cutoff] */
@TypeConverters(Converters::class)
@Query(
"""
DELETE
FROM LogEntryEntity
WHERE instant < :cutoff
""",
)
suspend fun prune(cutoff: Instant)
}

View File

@ -68,6 +68,9 @@ object DatabaseModule {
@Provides
fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao()
@Provides
fun providesLogEntryDao(appDatabase: AppDatabase) = appDatabase.logEntryDao()
}
/**

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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

View File

@ -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?,

View File

@ -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"),

View File

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

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<WorkInfo, WorkInfoAdapter.ViewHolder>(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<WorkInfo>() {
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<UsageEvents.Event, UsageEventAdapter.ViewHolder>(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<UsageEvents.Event>() {
override fun areItemsTheSame(oldItem: UsageEvents.Event, newItem: UsageEvents.Event) = oldItem.timeStamp == newItem.timeStamp
override fun areContentsTheSame(oldItem: UsageEvents.Event, newItem: UsageEvents.Event) = false
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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())

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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>): 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<WorkInfo>,
usageEvents: List<UsageEvents.Event>,
): 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<LogEntryEntity, LogEntryAdapter.ViewHolder>(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<LogEntryEntity>() {
override fun areItemsTheSame(oldItem: LogEntryEntity, newItem: LogEntryEntity) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: LogEntryEntity, newItem: LogEntryEntity) = false
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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> = _uiState.asStateFlow()
val pullWorkerFlow: StateFlow<List<WorkInfo>> = WorkManager.getInstance(application)
.getWorkInfosByTagFlow("pullNotifications").stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList(),
)
private val _usageEventsFlow = MutableStateFlow<List<UsageEvents.Event>>(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,
)
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4.25,5.61C6.27,8.2 10,13 10,13v6c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1v-6c0,0 3.72,-4.8 5.74,-7.39C20.25,4.95 19.78,4 18.95,4H5.04C4.21,4 3.74,4.95 4.25,5.61z"/>
</vector>

View File

@ -41,7 +41,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabMode="fixed" />
app:tabMode="scrollable" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabMode="scrollable" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -0,0 +1,287 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:orientation="vertical">
<CheckBox
android:id="@+id/android_notifications_enabled"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:text="@string/notification_details_android_notifications_enabled" />
<TextView
android:id="@+id/notificationsEnabledHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_notifications_enabled_help" />
<TextView
android:id="@+id/notificationsEnabledAccounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:paddingEnd="8dp"
android:textIsSelectable="false" />
<TextView
android:id="@+id/notificationMethod"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingStart="32dp"
android:paddingEnd="8dp"
android:textIsSelectable="false" />
<LinearLayout
android:id="@+id/pushSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.divider.MaterialDivider
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_details_push_notifications_with_unifiedpush"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<CheckBox
android:id="@+id/unifiedPushAvailable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:text="@string/notification_details_unifiedpush_available" />
<TextView
android:id="@+id/unifiedPushAvailableHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_unifiedpush_available_help" />
<CheckBox
android:id="@+id/anyAccountNeedsMigration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_details_any_account_needs_migration" />
<TextView
android:id="@+id/anyAccountNeedsMigrationHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_any_account_needs_migration_help" />
<TextView
android:id="@+id/anyAccountNeedsMigrationAccounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:textIsSelectable="false" />
<CheckBox
android:id="@+id/accountsUnifiedPushUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_details_accounts_unified_push_url" />
<TextView
android:id="@+id/accountsUnifiedPushUrlHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_accounts_unified_push_url_help" />
<TextView
android:id="@+id/accountsUnifiedPushUrlAccounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:textIsSelectable="false" />
<CheckBox
android:id="@+id/accountsUnifiedPushSubscription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_details_accounts_unified_push_subscription" />
<TextView
android:id="@+id/accountsUnifiedPushSubscriptionHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_accounts_unified_push_subscription_help" />
<TextView
android:id="@+id/accountsUnifiedPushSubscriptionAccounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:textIsSelectable="false" />
<CheckBox
android:id="@+id/ntfyExempt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:text="@string/notification_details_ntfy_exempt" />
<TextView
android:id="@+id/ntfyExemptHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_ntfy_exempt_help" />
</LinearLayout>
<LinearLayout
android:id="@+id/pullSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.divider.MaterialDivider
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_details_pull_section"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<CheckBox
android:id="@+id/pachliExempt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:text="@string/notification_details_pachli_exempt" />
<TextView
android:id="@+id/pachliExemptHelp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:text="@string/notification_details_pachli_exempt_help" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/notification_details_workers_help" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/workInfoRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="8dp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.divider.MaterialDivider
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/lastFetchLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:text="@string/notification_details_last_fetch_label" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/notification_details_last_fetch_help" />
<TextView
android:id="@+id/lastFetch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:textIsSelectable="false" />
</LinearLayout>
<LinearLayout
android:id="@+id/usageEventSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.divider.MaterialDivider
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_details_usage_event"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<TextView
android:id="@+id/usageEventLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/notification_details_usage_event_label" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/usageEventRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="8dp" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/filter"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/notification_log_filter_content_description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:icon="@drawable/ic_filter"
android:text="@string/notification_log_filter"/>
<Button
android:id="@+id/download"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/notification_log_download_content_description"
app:layout_constraintStart_toEndOf="@id/filter"
app:layout_constraintTop_toTopOf="parent"
app:icon="@drawable/baseline_download"
android:text="@string/notification_log_download" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/sort"
style="@style/Widget.Material3.CompoundButton.CheckBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@id/download"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/download"
android:text="@string/notification_log_sort" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scrollbars="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/filter"
android:paddingTop="8dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false" />
<TextView
android:id="@+id/timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="textEnd"
android:textIsSelectable="false" />
</LinearLayout>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:textIsSelectable="false" />
<TextView
android:id="@+id/timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:textAlignment="textEnd"
android:textIsSelectable="false" />
</LinearLayout>

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false" />
<TextView
android:id="@+id/state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false" />
<TextView
android:id="@+id/runAttemptCount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false" />
<TextView
android:id="@+id/nextScheduleTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false" />
<TextView
android:id="@+id/stopReason"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false" />
</LinearLayout>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View File

@ -17,4 +17,51 @@
<string name="about_device_info">%s %s\nAndroid version: %s\nSDK level: %d</string>
<string name="about_account_info">\@%s\@%s\nVersion: %s</string>
<string name="license_description">Pachli contains code and assets from the following open source projects:</string>
<string name="notification_details_title">Details</string>
<string name="notification_details_android_notifications_enabled">Android notifications are enabled?</string>
<string name="notification_details_notifications_enabled_help"><![CDATA[Notifications are not enabled for any of your accounts. Change this in Account Preferences > Notifications. Until then all notifications are disabled.]]></string>
<string name="notification_details_push_notifications_with_unifiedpush">Push notifications with UnifiedPush</string>
<string name="notification_details_unifiedpush_available">UnifiedPush is available?</string>
<string name="notification_details_unifiedpush_available_help">UnifiedPush (ntfy.sh) is not available, notifications will be periodically pulled. The rest of this section explains why UnifiedPush is not being used, but not relevant to how notifications are currently pulled.</string>
<string name="notification_details_any_account_needs_migration">All accounts have \'push\' OAuth scope?</string>
<string name="notification_details_any_account_needs_migration_help">One or more accounts does not have the \'push\' OAuth scope, push notifications are disabled.</string>
<string name="notification_details_accounts_unified_push_url">All accounts have a UnifiedPush URL?</string>
<string name="notification_details_accounts_unified_push_url_help">One or more accounts is not configured with a UnifiedPush URL, push notifications for this account are disabled.</string>
<string name="notification_details_accounts_unified_push_subscription">All accounts are subscribed?</string>
<string name="notification_details_accounts_unified_push_subscription_help">One or more accounts failed to update its UnifiedPush subscription and will not receive notifications.</string>
<string name="notification_details_ntfy_exempt">NTFY is exempt from battery optimization?</string>
<string name="notification_details_ntfy_exempt_help">NTFY is not exempt from battery optimizations. Android may be delaying notifications whenever the screen is off.</string>
<string name="notification_details_pull_section">Pull notifications with periodic workers</string>
<string name="notification_details_pachli_exempt">Pachli is exempt from battery optimization?</string>
<string name="notification_details_pachli_exempt_help">Pachli is not exempt from battery optimizations. Android may be delaying notifications whenever the screen is off.</string>
<string name="notification_details_workers_help">This is the list of workers that fetch new notifications. There should be one, the state should be RUNNING or ENQUEUED, and it should be scheduled to run in less than 15 minutes from now.</string>
<string name="notification_details_last_fetch_label">Last /api/v1/notifications request</string>
<string name="notification_details_last_fetch_help">Irrespective of the notification method, this shows, per-account, when the last fetch occurred, whether it was successful, and the error message if it wasn\'t.</string>
<string name="notification_details_usage_event">Power management restrictions</string>
<string name="notification_details_usage_event_label">Android can limit what apps can do in the background. This list is how Pachli has been restricted over the last three days, newest first.</string>
<string name="notification_details_ago">%1$s ago @ %2$s</string>
<string name="notification_details_standby_bucket_exempted">Exempted</string>
<string name="notification_details_standby_bucket_active">Active. Unrestricted background jobs</string>
<string name="notification_details_standby_bucket_working_set">Working set. Limited to 10 minutes every 2 hours</string>
<string name="notification_details_standby_bucket_frequent">Frequent. Limited to 10 minutes every 2 hours</string>
<string name="notification_details_standby_bucket_rare">Rare. Limited to 10 minutes every 24 hours</string>
<string name="notification_details_standby_bucket_restricted">Restricted. Once per day</string>
<string name="notification_details_standby_bucket_never">Never</string>
<string name="notification_details_standby_bucket_unknown">Unknown</string>
<string name="notification_log_title">Log</string>
<string name="notification_log_filter_content_description">Filter logs by tag</string>
<string name="notification_log_filter">Filter</string>
<string name="notification_log_download_content_description">Download logs</string>
<string name="notification_log_download">Download</string>
<string name="notification_log_sort">Sort newest first</string>
<string name="notification_log_previous_attempts">Previous attempts: %1$d</string>
<string name="notification_log_scheduled_in">Scheduled in %1$s @ %2$s</string>
<string name="notification_log_method_push">Notifications are being pushed using UnifiedPush, and should be immediate</string>
<string name="notification_log_method_pull">Notifications are being pulled periodically, approximately every 15 minutes</string>
<string name="notification_log_method_unknown">Notification mechanism has not been determined</string>
<string name="notification_log_method_pusherror">UnifiedPush is enabled but had an error: %1$s</string>
<string name="notification_log_download_failed">Download failed: %1$s</string>
<string name="notitication_log_filter_dialog_title">Show log priorities</string>
<string name="action_refresh">Refresh</string>
</resources>