From 68e91e405ae731cbe3887b47c71c6234c4200c83 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 18 Oct 2023 17:07:26 +0100 Subject: [PATCH] Move NotificationScreen to different flavors to integrate Distributor dialog selection. Make some adjustments for handling changes to push distributors, and other fixes. --- .../notifications/PushDistributorHandler.kt | 41 +- .../notifications/PushMessageReceiver.kt | 26 +- .../notifications/PushNotificationUtils.kt | 11 +- .../ui/screen/loggedIn/NotificationScreen.kt | 370 ++++++++++++++++++ .../service/notifications/RegisterAccounts.kt | 7 +- .../ui/screen/loggedIn/NotificationScreen.kt | 0 6 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 app/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt rename app/src/{main => play}/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt (100%) diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt index cad25f904..0219c6c55 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushDistributorHandler.kt @@ -1,5 +1,9 @@ package com.vitorpamplona.amethyst.service.notifications +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log import com.vitorpamplona.amethyst.Amethyst import org.unifiedpush.android.connector.UnifiedPush @@ -9,9 +13,9 @@ interface PushDistributorActions { fun saveDistributor(distributor: String) fun removeSavedDistributor() } -class PushDistributorHandler() : PushDistributorActions { +object PushDistributorHandler : PushDistributorActions { private val appContext = Amethyst.instance.applicationContext - private val unifiedPush: UnifiedPush = UnifiedPush() + private val unifiedPush: UnifiedPush = UnifiedPush private var endpointInternal = "" val endpoint = endpointInternal @@ -19,6 +23,11 @@ class PushDistributorHandler() : PushDistributorActions { fun getSavedEndpoint() = endpoint fun setEndpoint(newEndpoint: String) { endpointInternal = newEndpoint + Log.d("PushHandler", "New endpoint saved : $endpointInternal") + } + + fun removeEndpoint() { + endpointInternal = "" } override fun getSavedDistributor(): String { @@ -31,13 +40,37 @@ class PushDistributorHandler() : PushDistributorActions { return unifiedPush.getDistributors(appContext) } + fun formattedDistributorNames(): List { + val distributorsArray = getInstalledDistributors().toTypedArray() + val distributorsNameArray = distributorsArray.map { + try { + val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.packageManager.getApplicationInfo( + it, + PackageManager.ApplicationInfoFlags.of( + PackageManager.GET_META_DATA.toLong() + ) + ) + } else { + appContext.packageManager.getApplicationInfo(it, 0) + } + appContext.packageManager.getApplicationLabel(ai) + } catch (e: PackageManager.NameNotFoundException) { + it + } as String + }.toTypedArray() + return distributorsNameArray.toList() + } + override fun saveDistributor(distributor: String) { unifiedPush.saveDistributor(appContext, distributor) + unifiedPush.registerApp(appContext) } override fun removeSavedDistributor() { unifiedPush.safeRemoveDistributor(appContext) } + fun forceRemoveDistributor(context: Context) { + unifiedPush.forceRemoveDistributor(context) + } } - -fun UnifiedPush() = UnifiedPush diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index a232c3ea6..a14b672e9 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -20,17 +20,22 @@ import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver class PushMessageReceiver : MessagingReceiver() { + private val TAG = "Amethyst-OSSPushReceiver" private val appContext = Amethyst.instance.applicationContext private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val eventCache = LruCache(100) - private val pushHandler = PushDistributorHandler() + private val pushHandler = PushDistributorHandler override fun onMessage(context: Context, message: ByteArray, instance: String) { val messageStr = String(message) - Log.d("Amethyst-OSSPush", "New message ${message.decodeToString()} for Instance: $instance") + Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance") scope.launch { - parseMessage(messageStr)?.let { - receiveIfNew(it) + try { + parseMessage(messageStr)?.let { + receiveIfNew(it) + } + } catch (e: Exception) { + Log.d(TAG, "Message could not be parsed: ${e.message}") } } } @@ -50,7 +55,7 @@ class PushMessageReceiver : MessagingReceiver() { } override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - Log.d("Amethyst-OSSPush", "New endpoint provided:- $endpoint for Instance: $instance") + Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance") pushHandler.setEndpoint(endpoint) scope.launch(Dispatchers.IO) { RegisterAccounts(LocalPreferences.allSavedAccounts()).go(endpoint) @@ -62,17 +67,22 @@ class PushMessageReceiver : MessagingReceiver() { override fun onReceive(context: Context, intent: Intent) { val intentData = intent.dataString val intentAction = intent.action.toString() - Log.d("Amethyst-OSSPush", "Intent Data:- $intentData Intent Action: $intentAction") + Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction") super.onReceive(context, intent) } override fun onRegistrationFailed(context: Context, instance: String) { + Log.d(TAG, "Registration failed for Instance: $instance") scope.cancel() - super.onRegistrationFailed(context, instance) + pushHandler.forceRemoveDistributor(context) } override fun onUnregistered(context: Context, instance: String) { - super.onUnregistered(context, instance) + val removedEndpoint = pushHandler.endpoint + Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance") + Log.d(TAG, "App is unregistered. ") + pushHandler.forceRemoveDistributor(context) + pushHandler.removeEndpoint() } fun notificationManager(): NotificationManager { diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index a767a2ae5..6e153d28f 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -2,17 +2,18 @@ package com.vitorpamplona.amethyst.service.notifications import android.util.Log import com.vitorpamplona.amethyst.AccountInfo -import kotlinx.coroutines.Dispatchers object PushNotificationUtils { var hasInit: Boolean = false - private val pushHandler = PushDistributorHandler() - suspend fun init(accounts: List) = with(Dispatchers.IO) { + private val pushHandler = PushDistributorHandler + suspend fun init(accounts: List) { if (hasInit || pushHandler.savedDistributorExists()) { - return@with + return } try { - RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) + if (pushHandler.savedDistributorExists()) { + RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) + } } catch (e: Exception) { Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") } diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt new file mode 100644 index 000000000..15ea069ac --- /dev/null +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -0,0 +1,370 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.Manifest +import android.os.Build +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.halilibo.richtext.markdown.Markdown +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.Material3RichText +import com.halilibo.richtext.ui.resolveDefaults +import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis +import com.patrykandpatrick.vico.compose.axis.vertical.endAxis +import com.patrykandpatrick.vico.compose.axis.vertical.startAxis +import com.patrykandpatrick.vico.compose.chart.Chart +import com.patrykandpatrick.vico.compose.chart.line.lineChart +import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush +import com.patrykandpatrick.vico.compose.style.ProvideChartStyle +import com.patrykandpatrick.vico.core.DefaultAlpha +import com.patrykandpatrick.vico.core.axis.AxisPosition +import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter +import com.patrykandpatrick.vico.core.chart.composed.plus +import com.patrykandpatrick.vico.core.chart.line.LineChart +import com.patrykandpatrick.vico.core.chart.values.ChartValues +import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders +import com.vitorpamplona.amethyst.service.NostrAccountDataSource +import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.OneGiga +import com.vitorpamplona.amethyst.ui.note.OneKilo +import com.vitorpamplona.amethyst.ui.note.OneMega +import com.vitorpamplona.amethyst.ui.note.UserReactionsRow +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel +import com.vitorpamplona.amethyst.ui.note.showCount +import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableCardView +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.RoyalBlue +import com.vitorpamplona.amethyst.ui.theme.chartStyle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import kotlin.math.roundToInt + +@Composable +fun NotificationScreen( + notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + WatchAccountForNotifications(notifFeedViewModel, accountViewModel) + + CheckifItNeedsToRequestNotificationPermission() + + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + NostrAccountDataSource.invalidateFilters() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + val pushHandler = PushDistributorHandler + var distributorPresent by remember { + mutableStateOf(pushHandler.savedDistributorExists()) + } + val list = pushHandler.getInstalledDistributors() + val readableList = pushHandler.formattedDistributorNames() + if (!distributorPresent) { + SelectPushDistributor( + distrbutorList = readableList.toImmutableList(), + onDistributorSelected = { index, name -> + val fullDistributorName = list[index] + pushHandler.saveDistributor(fullDistributorName) + Log.d("Amethyst", "NotificationScreen: Distributor registered.") + }, + onDismiss = { + distributorPresent = true + Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.") + } + ) + } else { + val currentDistributor = pushHandler.getSavedDistributor() + pushHandler.saveDistributor(currentDistributor) + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + SummaryBar( + model = userReactionsStatsModel + ) + + RefresheableCardView( + viewModel = notifFeedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = Route.Notification.base, + scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN + ) + } + } +} + +// TODO: Turn this into an Account flag +var hasAlreadyAskedNotificationPermissions = false + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CheckifItNeedsToRequestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasAlreadyAskedNotificationPermissions) { + val notificationPermissionState = rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS + ) + + if (!notificationPermissionState.status.isGranted) { + hasAlreadyAskedNotificationPermissions = true + + // This will pause the APP, including the connection with relays. + LaunchedEffect(notificationPermissionState) { + notificationPermissionState.launchPermissionRequest() + } + } + } +} + +@Composable +fun WatchAccountForNotifications( + notifFeedViewModel: NotificationViewModel, + accountViewModel: AccountViewModel +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + + LaunchedEffect(accountViewModel, accountState?.account?.defaultNotificationFollowList) { + NostrAccountDataSource.invalidateFilters() + notifFeedViewModel.checkKeysInvalidateDataAndSendToTop() + } +} + +@Composable +fun SelectPushDistributor( + distrbutorList: ImmutableList, + onDistributorSelected: (Int, String) -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .border( + width = Dp.Hairline, + color = MaterialTheme.colorScheme.background, + shape = CircleShape + ) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + ) { + Box(modifier = Modifier.align(CenterHorizontally)) { + Material3RichText( + style = RichTextStyle().resolveDefaults() + ) { + Markdown(content = "### Select a distributor") + } + } + distrbutorList.forEachIndexed { index, distributor -> + TextButton( + onClick = { + onDistributorSelected(index, distributor) + onDismiss() + }, + modifier = Modifier.fillMaxWidth() + ) { + Material3RichText( + style = RichTextStyle().resolveDefaults() + ) { + Markdown(content = distributor) + } + } + } + } + } + } +} + +@Composable +fun SummaryBar(model: UserReactionsViewModel) { + var showChart by remember { + mutableStateOf(false) + } + + UserReactionsRow(model) { + showChart = !showChart + } + + if (showChart) { + val lineChartCount = + lineChart( + lines = listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) + ) + ) + ) + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.Start + ) + + val lineChartZaps = + lineChart( + lines = listOf(BitcoinOrange).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) + ) + ) + ) + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.End + ) + + Row( + modifier = Modifier + .padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }) + ) { + ProvideChartStyle( + chartStyle = MaterialTheme.colorScheme.chartStyle + ) { + ObserveAndShowChart(model, lineChartCount, lineChartZaps) + } + } + } + + Divider( + thickness = 0.25.dp + ) +} + +@Composable +private fun ObserveAndShowChart( + model: UserReactionsViewModel, + lineChartCount: LineChart, + lineChartZaps: LineChart +) { + val axisModel by model.axisLabels.collectAsState() + val chartModel by model.chartModel.collectAsState() + chartModel?.let { + Chart( + chart = remember(lineChartCount, lineChartZaps) { + lineChartCount.plus(lineChartZaps) + }, + model = it, + startAxis = startAxis( + valueFormatter = CountAxisValueFormatter() + ), + endAxis = endAxis( + valueFormatter = AmountAxisValueFormatter() + ), + bottomAxis = bottomAxis( + valueFormatter = LabelValueFormatter(axisModel) + ) + ) + } +} + +@Stable +class LabelValueFormatter(val axisLabels: List) : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return axisLabels[value.roundToInt()] + } +} + +@Stable +class CountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showCount(value.roundToInt()) + } +} + +@Stable +class AmountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showAmountAxis(value.toBigDecimal()) + } +} + +var dfG: DecimalFormat = DecimalFormat("#G") +var dfM: DecimalFormat = DecimalFormat("#M") +var dfK: DecimalFormat = DecimalFormat("#k") +var dfN: DecimalFormat = DecimalFormat("#") + +fun showAmountAxis(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.format(amount) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 9d023231b..d0394692c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -62,7 +62,12 @@ class RegisterAccounts( it.isSuccessful } } catch (e: java.lang.Exception) { - Log.e("FirebaseMsgService", "Unable to register with push server", e) + val tag = if (BuildConfig.FLAVOR == "play") { + "FirebaseMsgService" + } else { + "UnifiedPushService" + } + Log.e(tag, "Unable to register with push server", e) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt similarity index 100% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt rename to app/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt