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:
parent
3fb6994429
commit
23e3cf1035
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>>>()
|
||||
}
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -68,6 +68,9 @@ object DatabaseModule {
|
|||
|
||||
@Provides
|
||||
fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao()
|
||||
|
||||
@Provides
|
||||
fun providesLogEntryDao(appDatabase: AppDatabase) = appDatabase.logEntryDao()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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?,
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue