diff --git a/app/build.gradle b/app/build.gradle index 7312a550aa083c48c58241e44e88308437c3b156..2f73584ad6261dda9b6c4aeda04c958e9ccf51d5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.google.devtools.ksp' alias(libs.plugins.hilt.android) alias(libs.plugins.detekt) + alias(libs.plugins.kotlin.compose) } repositories { @@ -98,6 +99,10 @@ android { '-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785 ] } + + buildFeatures { + compose true + } } // Disables GoogleServices tasks for F-Droid variant @@ -130,6 +135,7 @@ dependencies { implementation(libs.dagger.hilt.android) ksp(libs.dagger.hilt.compiler) implementation(libs.eos.telemetry) + implementation(libs.androidx.lifecycle.runtime.compose) // AndroidX, The Basics implementation "androidx.appcompat:appcompat:1.6.1" @@ -198,9 +204,7 @@ detekt { tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { jvmTarget = "17" - // exclude("**/io/heckel/ntfy/**") } tasks.withType(io.gitlab.arturbosch.detekt.DetektCreateBaselineTask).configureEach { jvmTarget = "17" - //exclude("**/io/heckel/ntfy/**") } diff --git a/app/src/main/java/foundation/e/notificationsreceiver/repositories/MessagesRepositoryImpl.kt b/app/src/main/java/foundation/e/notificationsreceiver/repositories/MessagesRepositoryImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..633231ef4832ac4ede335602b466f95fc098edda --- /dev/null +++ b/app/src/main/java/foundation/e/notificationsreceiver/repositories/MessagesRepositoryImpl.kt @@ -0,0 +1,57 @@ +package foundation.e.notificationsreceiver.repositories + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.entities.Message.IconType +import foundation.e.notificationsreceiver.domain.entities.Topic +import foundation.e.notificationsreceiver.domain.repositories.MessagesRepository +import foundation.e.notificationsreceiver.domain.utils.runSuspendCatching +import io.heckel.ntfy.db.Database +import io.heckel.ntfy.db.NotificationDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MessagesRepositoryImpl @Inject constructor( + @ApplicationContext private val appContext: Context, + +) : MessagesRepository { + private val notificationsDao: NotificationDao + + init { + val database = Database.getInstance(appContext) + notificationsDao = database.notificationDao() + } + + override fun messages(subscriptions: List): Flow> { + return notificationsDao.listFlow(subscriptions.map { it.localId }).map { notifications -> + notifications.mapNotNull { + with(it) { + runSuspendCatching { + Message( + id = id, + timestamp = Instant.ofEpochSecond(timestamp), + title = title, + content = message, + icon = IconType.EOS, + read = deleted, + notificationId = notificationId, + ) + }.getOrNull() + } + } + } + } + + override suspend fun markAsRead(localId: String) { + withContext(Dispatchers.IO) { + notificationsDao.markAsDeleted(notificationId = localId) + } + } +} diff --git a/app/src/main/java/foundation/e/notificationsreceiver/repositories/RepositoryModule.kt b/app/src/main/java/foundation/e/notificationsreceiver/repositories/RepositoryModule.kt index 8b0db3aff412c49088014c1fe10ae49595d4fce1..073a04aae6e18670c1f4837dd9cf9502d0f4ebd7 100644 --- a/app/src/main/java/foundation/e/notificationsreceiver/repositories/RepositoryModule.kt +++ b/app/src/main/java/foundation/e/notificationsreceiver/repositories/RepositoryModule.kt @@ -21,6 +21,7 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import foundation.e.notificationsreceiver.domain.repositories.MessagesRepository import foundation.e.notificationsreceiver.domain.repositories.SubscriptionsRepository @InstallIn(SingletonComponent::class) @@ -28,4 +29,7 @@ import foundation.e.notificationsreceiver.domain.repositories.SubscriptionsRepos abstract class RepositoryModule { @Binds abstract fun bindSubscriptionsRepository(impl: SubscriptionsRepositoryImpl): SubscriptionsRepository + + @Binds + abstract fun bindMessagesRepository(impl: MessagesRepositoryImpl): MessagesRepository } diff --git a/app/src/main/java/foundation/e/notificationsreceiver/repositories/SubscriptionsRepositoryImpl.kt b/app/src/main/java/foundation/e/notificationsreceiver/repositories/SubscriptionsRepositoryImpl.kt index 0493d9d66234679ab1442da05f07d84b009b84d0..00b86f191f3a795126c488fe33057ac76010c6a6 100644 --- a/app/src/main/java/foundation/e/notificationsreceiver/repositories/SubscriptionsRepositoryImpl.kt +++ b/app/src/main/java/foundation/e/notificationsreceiver/repositories/SubscriptionsRepositoryImpl.kt @@ -26,17 +26,22 @@ import io.heckel.ntfy.db.Database import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.SubscriptionDao +import io.heckel.ntfy.db.SubscriptionWithMetadata import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.randomSubscriptionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Date import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.map @Singleton class SubscriptionsRepositoryImpl @Inject constructor( @@ -57,15 +62,19 @@ class SubscriptionsRepositoryImpl @Inject constructor( private var willRefreshSubscriptionJob: Job? = null + override val subscriptions = subscriptionDao.listFlow().map { list -> + list.map(::toTopic) + }.shareIn(scope = backgroundScope, started = SharingStarted.Lazily) + override fun getBaseUrl(): String { return repository.getDefaultBaseUrl() ?: appContext.getString(R.string.app_base_url) } override suspend fun getSubscriptions(): List = withContext(Dispatchers.IO) { - subscriptionDao.list().map { Topic(localId = it.id, baseUrl = it.baseUrl, topic = it.topic) } + subscriptionDao.list().map(::toTopic) } - override suspend fun subscribe(topic: Topic) = withContext(Dispatchers.IO) { + override suspend fun createSubscription(topic: Topic) = withContext(Dispatchers.IO) { val subscription = Subscription( id = randomSubscriptionId(), baseUrl = topic.baseUrl, @@ -74,7 +83,7 @@ class SubscriptionsRepositoryImpl @Inject constructor( dedicatedChannels = false, mutedUntil = 0, minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, - autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + autoDelete = Repository.AUTO_DELETE_NEVER, insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, lastNotificationId = null, icon = null, @@ -90,11 +99,31 @@ class SubscriptionsRepositoryImpl @Inject constructor( debouncedRefreshSubscriptions() } - override suspend fun unsubscribe(topic: Topic): Unit = withContext(Dispatchers.IO) { - subscriptionDao.remove(topic.localId) + override suspend fun deactivateSubscription(topic: Topic) { + withContext(Dispatchers.IO) { + subscriptionDao.setEnabled(topic.localId, false) + } debouncedRefreshSubscriptions() } + override suspend fun reactivateSubscription(topic: Topic) { + withContext(Dispatchers.IO) { + subscriptionDao.setEnabled(topic.localId, true) + } + debouncedRefreshSubscriptions() + } + + private fun toTopic(subscription: SubscriptionWithMetadata): Topic { + with(subscription) { + return Topic( + localId = id, + baseUrl = baseUrl, + topic = topic, + enabled = instant, + ) + } + } + private fun debouncedRefreshSubscriptions() { willRefreshSubscriptionJob?.cancel() diff --git a/app/src/main/java/io/heckel/ntfy/app/Application.kt b/app/src/main/java/io/heckel/ntfy/app/Application.kt index 8f146fa94cf002a925f6a362a0679f92410ab2bc..d6b499fd26d175bde03d5f5978054e6d020672dc 100644 --- a/app/src/main/java/io/heckel/ntfy/app/Application.kt +++ b/app/src/main/java/io/heckel/ntfy/app/Application.kt @@ -2,9 +2,11 @@ package io.heckel.ntfy.app import android.app.Application import dagger.hilt.android.HiltAndroidApp +import foundation.e.notificationsreceiver.ui.NotificationsPresenter import foundation.e.notificationsreceiver.utils.ETelemetryLogger import io.heckel.ntfy.db.Repository import io.heckel.ntfy.util.Log +import javax.inject.Inject @HiltAndroidApp class Application : Application() { @@ -16,8 +18,11 @@ class Application : Application() { repository } + @Inject lateinit var notificationsPresenter: NotificationsPresenter + override fun onCreate() { super.onCreate() ETelemetryLogger().initializeAsLoggerImplementation(this) + notificationsPresenter.listen() } } diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 665f210c77341762939bdc6b808d5d6fd40fd504..af6055962962218074bdfda4feaacfa877809dd6 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -422,6 +422,9 @@ interface SubscriptionDao { @Query("DELETE FROM subscription WHERE id = :subscriptionId") fun remove(subscriptionId: Long) + + @Query("UPDATE subscription SET instant = :enabled WHERE id = :subscriptionId") + fun setEnabled(subscriptionId: Long, enabled: Boolean) } @Dao @@ -473,6 +476,9 @@ interface NotificationDao { @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId") fun removeAll(subscriptionId: Long) + + @Query("SELECT * FROM notification WHERE subscriptionId IN (:subscriptionIds) ORDER BY timestamp DESC") + fun listFlow(subscriptionIds: List): Flow> } @Dao diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 3afabdb52512c045fb30d37219ac7ccdc98b3d76..dc4a4e6061494175f1a078ab412ac51eab3eac7d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -25,12 +25,14 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { fun dispatch(subscription: Subscription, notification: Notification) { Log.d(TAG, "Dispatching $notification for subscription $subscription") + // Block all notifications, unless UnifiedPush. + // eOS broadcast notifications follow there own path from the database. val muted = getMuted(subscription) - val notify = shouldNotify(subscription, notification, muted) - val broadcast = shouldBroadcast(subscription) + val notify = false //shouldNotify(subscription, notification, muted) + val broadcast = false // shouldBroadcast(subscription) val distribute = shouldDistribute(subscription) - val downloadAttachment = shouldDownloadAttachment(notification) - val downloadIcon = shouldDownloadIcon(notification) + val downloadAttachment = false //shouldDownloadAttachment(notification) + val downloadIcon = false //shouldDownloadIcon(notification) if (notify) { notifier.display(subscription, notification) } @@ -51,6 +53,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } + @Suppress("UnusedPrivateMember", "ReturnCount") private fun shouldDownloadAttachment(notification: Notification): Boolean { if (notification.attachment == null) { return false @@ -71,10 +74,13 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } } + + @Suppress("UnusedPrivateMember") private fun shouldDownloadIcon(notification: Notification): Boolean { return notification.icon != null } + @Suppress("UnusedPrivateMember") private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { if (subscription.upAppId != null) { return false @@ -88,6 +94,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { return !detailsVisible && !muted } + @Suppress("UnusedPrivateMember") private fun shouldBroadcast(subscription: Subscription): Boolean { if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions return false diff --git a/build.gradle b/build.gradle index 4a76393971daf734f4f1382fdcf51170a767ba80..a6735237f753d6ac80b0658f526237bbddfba55b 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,7 @@ plugins { alias(libs.plugins.spotless) alias(libs.plugins.hilt.android) apply false alias(libs.plugins.detekt) apply false + alias(libs.plugins.kotlin.serialization) apply false } allprojects { diff --git a/elibcompose/.gitignore b/elibcompose/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..42afabfd2abebf31384ca7797186a27a4b7dbee8 --- /dev/null +++ b/elibcompose/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/elibcompose/build.gradle.kts b/elibcompose/build.gradle.kts new file mode 100644 index 0000000000000000000000000000000000000000..cf7cd6b1d431754fcbce0aa161c174581475db24 --- /dev/null +++ b/elibcompose/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "foundation.e.elibcompose" + compileSdk = libs.versions.app.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.app.minSdk.get().toInt() + + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material3) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.eos.elib) +} diff --git a/elibcompose/consumer-rules.pro b/elibcompose/consumer-rules.pro new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/elibcompose/proguard-rules.pro b/elibcompose/proguard-rules.pro new file mode 100644 index 0000000000000000000000000000000000000000..481bb434814107eb79d7a30b676d344b0df2f8ce --- /dev/null +++ b/elibcompose/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/elibcompose/src/main/AndroidManifest.xml b/elibcompose/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..a5918e68abcdde7f61ccae4f0ad4885b764573fd --- /dev/null +++ b/elibcompose/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/elibcompose/src/main/java/foundation/e/elibcompose/theme/ETheme.kt b/elibcompose/src/main/java/foundation/e/elibcompose/theme/ETheme.kt new file mode 100644 index 0000000000000000000000000000000000000000..cb263b179b578f8b49515637fa38f6207d5c038d --- /dev/null +++ b/elibcompose/src/main/java/foundation/e/elibcompose/theme/ETheme.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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. + * + * This program 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 this program. If not, see . + * + */ +package foundation.e.elibcompose.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.colorResource +import foundation.e.elib.R as eR + +@Composable +fun ETheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colorScheme = + if (darkTheme) { + darkColorScheme( + primary = colorResource(eR.color.e_action_bar_dark), + secondary = colorResource(eR.color.e_action_bar_dark), + tertiary = colorResource(eR.color.e_accent_dark), + background = colorResource(eR.color.e_background_dark), + surface = colorResource(eR.color.e_floating_background_dark), + onPrimary = colorResource(eR.color.e_primary_text_color_dark), + onSecondary = colorResource(eR.color.e_primary_text_color_light), + onBackground = colorResource(eR.color.e_primary_text_color_dark), + onSurface = colorResource(eR.color.e_primary_text_color_dark), + surfaceContainerHigh = colorResource(eR.color.e_floating_background), + + ) + } else { + lightColorScheme( + primary = colorResource(eR.color.e_action_bar_light), + secondary = colorResource(eR.color.e_action_bar_light), + tertiary = colorResource(eR.color.e_accent_light), + background = colorResource(eR.color.e_background_light), + surface = colorResource(eR.color.e_floating_background_light), + onPrimary = colorResource(eR.color.e_primary_text_color_light), + onSecondary = colorResource(eR.color.e_primary_text_color_dark), + onBackground = colorResource(eR.color.e_primary_text_color_light), + onSurface = colorResource(eR.color.e_primary_text_color_light), + surfaceContainerHigh = colorResource(eR.color.e_floating_background), + ) + } + + MaterialTheme(colorScheme = colorScheme, typography = ETypography, content = content) +} diff --git a/elibcompose/src/main/java/foundation/e/elibcompose/theme/ETypography.kt b/elibcompose/src/main/java/foundation/e/elibcompose/theme/ETypography.kt new file mode 100644 index 0000000000000000000000000000000000000000..7a9b8f2590f81ee9c0af570db9034ff34e636f11 --- /dev/null +++ b/elibcompose/src/main/java/foundation/e/elibcompose/theme/ETypography.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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. + * + * This program 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 this program. If not, see . + * + */ +package foundation.e.elibcompose.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +val ETypography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + ) + +val titleStyle = + TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, + lineHeight = 24.sp, + letterSpacing = 0.02.em, + hyphens = Hyphens.Auto, + ) + +val summaryStyle = + TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.01.em, + hyphens = Hyphens.Auto, + ) diff --git a/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Preference.kt b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Preference.kt new file mode 100644 index 0000000000000000000000000000000000000000..7bafddc711a5f5be9bd8732d98c6043a153f1ac0 --- /dev/null +++ b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Preference.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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. + * + * This program 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 this program. If not, see . + * + */ +package foundation.e.elibcompose.widgets.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun Preference( + modifier: Modifier = Modifier, + title: String? = null, + summary: String? = null, + onClick: () -> Unit = {}, + enabled: Boolean = true, +) { + Row( + modifier = modifier.fillMaxWidth() + .padding(end = 16.dp) + .then(if (enabled) Modifier.clickable { onClick() } else Modifier), + ) { + Spacer(modifier = Modifier.width(24.dp)) + Column(modifier = Modifier.padding(vertical = 16.dp)) { + if (title != null) { + Title(text = title, modifier = Modifier.padding(vertical = 2.dp), enabled = enabled) + } + if (summary != null) { + Summary(text = summary, enabled = enabled) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreferencePreview() { + Preference(Modifier, "title", "this is below title", onClick = {}) +} + +@Preview(showBackground = true) +@Composable +fun PreferenceDisabledPreview() { + Preference(Modifier, "title", "this is below title", onClick = {}, false) +} diff --git a/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/SettingsAppBar.kt b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/SettingsAppBar.kt new file mode 100644 index 0000000000000000000000000000000000000000..2408159b7fcab8a956ade83f7b4a34d59ff2a6ea --- /dev/null +++ b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/SettingsAppBar.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ +package foundation.e.elibcompose.widgets.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.sp +import foundation.e.elib.R as eR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsAppBar( + title: String, + onBackPressed: () -> Unit?, + scrollBehavior: TopAppBarScrollBehavior, +) { + val collapsedFraction = scrollBehavior.state.collapsedFraction + val textSize = lerp(36.sp, 24.sp, collapsedFraction) + LargeTopAppBar( + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = colorResource(eR.color.e_action_bar), + titleContentColor = colorResource(eR.color.e_primary_text_color), + scrolledContainerColor = colorResource(eR.color.e_action_bar), + ), + title = { Text(title, style = TextStyle(fontSize = textSize)) }, + scrollBehavior = scrollBehavior, + navigationIcon = { + IconButton(onClick = { onBackPressed() }) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "") + } + }, + ) +} diff --git a/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Summary.kt b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Summary.kt new file mode 100644 index 0000000000000000000000000000000000000000..89f701af3709d67487e8342e07854ab7bf98aa7e --- /dev/null +++ b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Summary.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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. + * + * This program 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 this program. If not, see . + * + */ +package foundation.e.elibcompose.widgets.settings + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import foundation.e.elibcompose.theme.summaryStyle +import foundation.e.elib.R as eR + +@Composable +fun Summary(text: String, modifier: Modifier = Modifier, enabled: Boolean = true) { + Text( + text = text, + modifier = modifier, + color = + colorResource( + if (enabled) { + eR.color.e_secondary_text_color + } else { + eR.color.e_disabled_color + }, + ), + style = summaryStyle, + ) +} + +@Preview(showBackground = true) +@Composable +fun SummaryPreview() { + Summary("Summary") +} + +@Preview(showBackground = true) +@Composable +fun SummaryDisabledPreview() { + Summary(text = "Summary", enabled = false) +} diff --git a/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Title.kt b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Title.kt new file mode 100644 index 0000000000000000000000000000000000000000..d2a8a025de054d2db61321313a022dcba2e2c052 --- /dev/null +++ b/elibcompose/src/main/java/foundation/e/elibcompose/widgets/settings/Title.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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. + * + * This program 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 this program. If not, see . + * + */ +package foundation.e.elibcompose.widgets.settings + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import foundation.e.elib.R +import foundation.e.elibcompose.theme.titleStyle + +@Composable +fun Title(text: String, modifier: Modifier = Modifier, enabled: Boolean = true) { + Text( + text = text, + modifier = modifier, + color = + colorResource(if (enabled) R.color.e_primary_text_color else R.color.e_disabled_color), + style = titleStyle, + ) +} + +@Preview(showBackground = true) +@Composable +fun TitlePreview() { + Title("Title") +} + +@Preview(showBackground = true) +@Composable +fun TitleDisabledPreview() { + Title(text = "Title", enabled = false) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0c4975207278dbf6c7942f0fb05e43c7d04a5e5..539b21ed414d93a40059f26e3087c6d89969d515 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,81 +1,62 @@ [versions] app-compileSdk = "35" app-minSdk = "31" -agp = "8.8.0" -coreKtx = "1.16.0" -coroutines = "1.10.2" -hilt = "2.53.1" -hiltAndroidx = "1.2.0" -kotlin = "2.0.0" -ksp = "2.0.0-1.0.23" -androidxLifecycle = "2.9.2" -activityCompose = "1.10.1" -composeBom = "2025.06.01" -jetbrainsKotlinJvm = "2.0.0" +androidx-activity-compose = "1.10.1" +androidx-compose-bom = "2025.06.01" +androidx-hilt = "1.2.0" +androidx-lifecycle-runtime-compose = "2.9.2" +androidx-navigation-compose = "2.9.0" +eos-elib = "0.0.2-alpha12" +eos-telemetry = "1.0.1-release" +kotlin = "2.0.21" +kotlinx-coroutines = "1.10.2" +markwon = "4.6.2" +markwon-compose = "0.5.7" # Tests +junit = "4.13.2" mockk = "1.13.12" -junit = "1.2.1" -espressoCore = "3.6.1" -appcompat = "1.7.1" -material = "1.12.0" # Build detekt = "1.23.8" +hilt = "2.53.1" ktlint = "1.6.0" +spotless = "8.0.0" [libraries] -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version = "1.1.7" } -androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltAndroidx" } -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltAndroidx" } -androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltAndroidx" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } -androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidx-hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidx-hilt" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation-compose" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.9.0" } -androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version = "2.10.2" } dagger-hilt-android = { group = "com.google.dagger", name = "hilt-android" , version.ref = "hilt"} dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler" , version.ref = "hilt"} dagger-hilt-core = { group = "com.google.dagger", name = "hilt-core" , version.ref = "hilt"} -eos-telemetry = {group = "foundation.e.lib", name = "telemetry", version = "1.0.1-release" } -kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name ="kotlinx-coroutines-core", version.ref = "coroutines" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.8.1" } -markwon = { group = "io.noties.markwon", name = "core", version = "4.6.2" } -markwon-strikethrough = {group = "io.noties.markwon", name = "ext-strikethrough", version = "4.6.2" } -markwon-html = {group = "io.noties.markwon", name = "html", version = "4.6.2" } -markwon-compose = { group = "com.github.jeziellago", name = "compose-markdown", version = "0.5.7" } -retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version = "3.0.0" } -retrofit-json = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version = "1.0.0" } -timber = { group = "com.jakewharton.timber", name = "timber", version = "5.0.1" } -unifiedpush-connector = { group = "org.unifiedpush.android", name = "connector", version = "3.0.9" } +eos-elib = { group = "foundation.e", name = "elib", version.ref = "eos-elib" } +eos-telemetry = {group = "foundation.e.lib", name = "telemetry", version.ref = "eos-telemetry" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name ="kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +markwon = { group = "io.noties.markwon", name = "core", version.ref = "markwon" } +markwon-compose = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "markwon-compose" } +markwon-html = {group = "io.noties.markwon", name = "html", version.ref = "markwon" } +markwon-strikethrough = {group = "io.noties.markwon", name = "ext-strikethrough", version.ref = "markwon" } # Test libraries -junit = { group = "junit", name = "junit", version = "4.13.2" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name ="kotlinx-coroutines-test", version.ref = "coroutines" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name ="kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-agent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockk" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] -android-application = { id = "com.android.application", version.ref = "agp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } -spotless = { id = "com.diffplug.spotless", version = "8.0.0" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref ="hilt" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.0" } -ksp = { id = "com.google.devtools.ksp", version.ref ="ksp" } -jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } -android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/notificationsreceiver-domain/entitiesfixtures/src/main/java/foundation/e/notificationreceiver/domain/entities/fixtures/MessageFixtures.kt b/notificationsreceiver-domain/entitiesfixtures/src/main/java/foundation/e/notificationreceiver/domain/entities/fixtures/MessageFixtures.kt new file mode 100644 index 0000000000000000000000000000000000000000..50dddb7505f3ca89c3bebaf49899c0755990365b --- /dev/null +++ b/notificationsreceiver-domain/entitiesfixtures/src/main/java/foundation/e/notificationreceiver/domain/entities/fixtures/MessageFixtures.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationreceiver.domain.entities.fixtures + +import foundation.e.notificationsreceiver.domain.entities.Message +import java.time.Instant + +val messageFixture = Message( + id = "base_fixture_id", + timestamp = Instant.parse("2025-06-27T10:30:00Z"), + title = "Message Fixture", + content = "Simple content of the base fixture of a Message\n", + read = false, + notificationId = 36 +) diff --git a/notificationsreceiver-domain/entitiesfixtures/src/main/java/foundation/e/notificationreceiver/domain/entities/fixtures/TopicFixtures.kt b/notificationsreceiver-domain/entitiesfixtures/src/main/java/foundation/e/notificationreceiver/domain/entities/fixtures/TopicFixtures.kt new file mode 100644 index 0000000000000000000000000000000000000000..77fd550aacf651619ae313a09185bd77ed93ab2a --- /dev/null +++ b/notificationsreceiver-domain/entitiesfixtures/src/main/java/foundation/e/notificationreceiver/domain/entities/fixtures/TopicFixtures.kt @@ -0,0 +1,9 @@ +package foundation.e.notificationreceiver.domain.entities.fixtures + +import foundation.e.notificationsreceiver.domain.entities.Topic + +val topicFixture = Topic( + localId = 123, + baseUrl = "https://test.push.murena.com", + topic = "eOS-all-en" +) \ No newline at end of file diff --git a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Message.kt b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Message.kt new file mode 100644 index 0000000000000000000000000000000000000000..985862daaddebfd1203259a5e0429b4d1a0a0822 --- /dev/null +++ b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Message.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.domain.entities + +import java.time.Instant + +data class Message( + val id: String, + val timestamp: Instant = Instant.now(), + val title: String? = null, + val content: String, + val icon: IconType = IconType.EOS, + val read: Boolean = false, + val notificationId: Int, +) { + enum class IconType { + EOS, + MURENA, + } +} diff --git a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Topic.kt b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Topic.kt index 293ff2c3a55e10d6113cc7a1e28e923fb105ef7d..67396776a52ee181984111148346f6c4e83dbcee 100644 --- a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Topic.kt +++ b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/entities/Topic.kt @@ -18,7 +18,12 @@ package foundation.e.notificationsreceiver.domain.entities data class Topic( - val localId: Long = -1, val baseUrl: String, val topic: String, -) + val localId: Long = -1, + val enabled: Boolean = false, +) { + override fun equals(other: Any?): Boolean { + return other is Topic && other.baseUrl == baseUrl && other.topic == topic + } +} diff --git a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEosNotifications.kt b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEosNotifications.kt index b4edcababd1e2526f24e8b55c0f4ad5bc6fea26d..edf1398ee1f03b5caa23fb0f20196a59dddc3c5b 100644 --- a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEosNotifications.kt +++ b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEosNotifications.kt @@ -34,7 +34,7 @@ class RegisterToEosNotifications @Inject constructor( @AppBackgroundScope private val backgroundScope: CoroutineScope, ) { companion object { - private const val EOS_BROADCAST_TOPIC_PREFIX = "eOS" + const val EOS_BROADCAST_TOPIC_PREFIX = "eOS" private const val ALL_DEVICES_TOPIC_BASE = "all" private const val DEFAULT_LANG = "en" private val SUPPORTED_LANGUAGES = setOf("de", "en", "es", "fr", "it") @@ -49,13 +49,17 @@ class RegisterToEosNotifications @Inject constructor( internal suspend fun updateRegistration() { val toSubscribeTopics = buildTopicsList() - val subscribed = subscriptionRepository.getSubscriptions() + val savedEOSTopics = subscriptionRepository.getSubscriptions() .filter { it.topic.startsWith(EOS_BROADCAST_TOPIC_PREFIX) } - subscribed.minus(toSubscribeTopics).forEach { - subscriptionRepository.unsubscribe(it) + savedEOSTopics.minus(toSubscribeTopics).forEach { + subscriptionRepository.deactivateSubscription(it) } - (toSubscribeTopics - subscribed).forEach { - subscriptionRepository.subscribe(it) + savedEOSTopics.filter { !it.enabled }.intersect(toSubscribeTopics).forEach { + subscriptionRepository.reactivateSubscription(it) + } + + (toSubscribeTopics - savedEOSTopics).forEach { + subscriptionRepository.createSubscription(it) } } diff --git a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/MessagesRepository.kt b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/MessagesRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..75680f18b974be2e7d95f4c43be99131f990b1d1 --- /dev/null +++ b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/MessagesRepository.kt @@ -0,0 +1,10 @@ +package foundation.e.notificationsreceiver.domain.repositories + +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.entities.Topic +import kotlinx.coroutines.flow.Flow + +interface MessagesRepository { + fun messages(subscriptions: List): Flow> + suspend fun markAsRead(localId: String) +} diff --git a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/SubscriptionsRepository.kt b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/SubscriptionsRepository.kt index 348be91e517295d5d4778bb677731eda156e65ef..e85adf022ee532143b5c7f8a8617974d8a86db27 100644 --- a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/SubscriptionsRepository.kt +++ b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/repositories/SubscriptionsRepository.kt @@ -18,12 +18,16 @@ package foundation.e.notificationsreceiver.domain.repositories import foundation.e.notificationsreceiver.domain.entities.Topic +import kotlinx.coroutines.flow.Flow interface SubscriptionsRepository { + val subscriptions: Flow> + fun getBaseUrl(): String suspend fun getSubscriptions(): List - suspend fun subscribe(topicSubscription: Topic) - suspend fun unsubscribe(topic: Topic) + suspend fun createSubscription(topic: Topic) + suspend fun deactivateSubscription(topic: Topic) + suspend fun reactivateSubscription(topic: Topic) } diff --git a/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/usecases/GetSetNotificationsUseCase.kt b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/usecases/GetSetNotificationsUseCase.kt new file mode 100644 index 0000000000000000000000000000000000000000..72ec86d4c660e8858ae99b8af81e77ecf2462980 --- /dev/null +++ b/notificationsreceiver-domain/src/main/java/foundation/e/notificationsreceiver/domain/usecases/GetSetNotificationsUseCase.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.domain.usecases + +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.procedures.RegisterToEosNotifications.Companion.EOS_BROADCAST_TOPIC_PREFIX +import foundation.e.notificationsreceiver.domain.repositories.MessagesRepository +import foundation.e.notificationsreceiver.domain.repositories.SubscriptionsRepository +import foundation.e.notificationsreceiver.domain.utils.AppBackgroundScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GetSetNotificationsUseCase @Inject constructor( + private val subscriptionsRepository: SubscriptionsRepository, + private val messagesRepository: MessagesRepository, + @AppBackgroundScope private val backgroundScope: CoroutineScope, +) { + @OptIn(ExperimentalCoroutinesApi::class) + val messagesHistory: StateFlow> = subscriptionsRepository.subscriptions + .flatMapLatest { subscriptions -> + val eOSSubscriptions = subscriptions.filter { + it.topic.startsWith(EOS_BROADCAST_TOPIC_PREFIX) + } + if (eOSSubscriptions.isEmpty()) { + emptyFlow() + } else { + messagesRepository.messages(eOSSubscriptions) + } + }.stateIn(scope = backgroundScope, started = SharingStarted.Lazily, initialValue = emptyList()) + + val unreadMessages = messagesHistory.map { messages -> + messages.filter { message -> !message.read } + }.shareIn(scope = backgroundScope, started = SharingStarted.Lazily) + + fun observeMessage(messageId: String): Flow { + return messagesHistory.mapNotNull { messages -> + messages.find { message -> message.id == messageId } + }.distinctUntilChanged() + } + + suspend fun markAsRead(localId: String) { + messagesRepository.markAsRead(localId) + } +} diff --git a/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/entities/TopicTest.kt b/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/entities/TopicTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..014c93486584c0ccb583bccb456cc733e5dcbbc7 --- /dev/null +++ b/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/entities/TopicTest.kt @@ -0,0 +1,24 @@ +package foundation.e.notificationsreceiver.domain.entities + +import foundation.e.notificationreceiver.domain.entities.fixtures.topicFixture +import org.junit.Assert.assertEquals +import org.junit.Test + +class TopicTest { + + @Test + fun equals_should_ignore_enabled() { + assertEquals( + topicFixture.copy(enabled = false), + topicFixture.copy(enabled = true), + ) + } + + @Test + fun equals_should_ignore_localId() { + assertEquals( + topicFixture.copy(localId = 123), + topicFixture.copy(localId = 456), + ) + } +} diff --git a/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEOSBroadcastProcedureTest.kt b/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEOSBroadcastProcedureTest.kt index 92c36543312793d05e2aefdb5318f48065ec5375..4012fcb53d4164ad3f95614accafc8af0b28eac7 100644 --- a/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEOSBroadcastProcedureTest.kt +++ b/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/procedures/RegisterToEOSBroadcastProcedureTest.kt @@ -17,6 +17,7 @@ package foundation.e.notificationsreceiver.domain.procedures +import foundation.e.notificationreceiver.domain.entities.fixtures.topicFixture import foundation.e.notificationsreceiver.domain.bridges.androidinterfaces.DeviceConfiguration import foundation.e.notificationsreceiver.domain.entities.Topic import foundation.e.notificationsreceiver.domain.repositories.SubscriptionsRepository @@ -45,8 +46,9 @@ class RegisterToEOSBroadcastProcedureTest { @Before fun setup() { every { subscriptionRepository.getBaseUrl() } returns baseUrlFixture - coEvery { subscriptionRepository.subscribe(any()) } returns Unit - coEvery { subscriptionRepository.unsubscribe(any()) } returns Unit + coEvery { subscriptionRepository.createSubscription(any()) } returns Unit + coEvery { subscriptionRepository.deactivateSubscription(any()) } returns Unit + coEvery { subscriptionRepository.reactivateSubscription(any()) } returns Unit every { deviceConfiguration.version } returns "2.9-t-20250321478215-official-FP3" every { deviceConfiguration.androidVersion } returns "13" @@ -70,7 +72,7 @@ class RegisterToEOSBroadcastProcedureTest { // Then coVerify(exactly = 1) { - subscriptionRepository.subscribe( + subscriptionRepository.createSubscription( withArg { assertEquals(baseUrlFixture, it.baseUrl) assertEquals("eOS-all-en", it.topic) @@ -80,9 +82,9 @@ class RegisterToEOSBroadcastProcedureTest { } @Test - fun register_should_remove_outdated_eOS_topics() = runTest { + fun register_should_deactivte_outdated_eOS_topics() = runTest { coEvery { subscriptionRepository.getSubscriptions() } returns listOf( - Topic(localId = 123, "https://outdated.push.murena.com", "eOS-all-en"), + Topic(localId = 123, baseUrl = "https://outdated.push.murena.com", topic = "eOS-all-en"), ) // When @@ -90,7 +92,7 @@ class RegisterToEOSBroadcastProcedureTest { // Then coVerify(exactly = 1) { - subscriptionRepository.unsubscribe( + subscriptionRepository.deactivateSubscription( withArg { assertEquals("https://outdated.push.murena.com", it.baseUrl) assertEquals("eOS-all-en", it.topic) @@ -98,7 +100,7 @@ class RegisterToEOSBroadcastProcedureTest { ) } coVerify(exactly = 1) { - subscriptionRepository.subscribe( + subscriptionRepository.createSubscription( withArg { assertEquals(baseUrlFixture, it.baseUrl) assertEquals("eOS-all-en", it.topic) @@ -107,19 +109,39 @@ class RegisterToEOSBroadcastProcedureTest { } } + @Test + fun register_should_reactivte_outdated_eOS_topics() = runTest { + coEvery { subscriptionRepository.getSubscriptions() } returns listOf( + topicFixture.copy(localId = 123, enabled = false), + ) + + // When + useCase.updateRegistration() + + // Then + coVerify(exactly = 1) { + subscriptionRepository.reactivateSubscription( + withArg { + assertEquals(topicFixture.baseUrl, it.baseUrl) + assertEquals(topicFixture.topic, it.topic) + }, + ) + } + } + @Test fun register_keep_no_eOS_topics() = runTest { coEvery { subscriptionRepository.getSubscriptions() } returns listOf( - Topic(123, "https://ntfy.sh", "up983127639"), - Topic(234, "https://push.murena.com", "upAPOZIEUA"), - Topic(345, "https://ntfy.sh", "zoeriuoiuoiu"), + Topic(localId = 123, topic = "https://ntfy.sh", baseUrl = "up983127639"), + Topic(localId = 234, topic = "https://push.murena.com", baseUrl = "upAPOZIEUA"), + Topic(localId = 345, topic = "https://ntfy.sh", baseUrl = "zoeriuoiuoiu"), ) // When useCase.updateRegistration() // Then - coVerify(exactly = 0) { subscriptionRepository.unsubscribe(any()) } + coVerify(exactly = 0) { subscriptionRepository.deactivateSubscription(any()) } } @Test @@ -138,7 +160,7 @@ class RegisterToEOSBroadcastProcedureTest { "eOS-test_device-3_1_4-en", ).forEach { topic -> coVerify { - subscriptionRepository.subscribe( + subscriptionRepository.createSubscription( withArg { assertEquals(baseUrlFixture, it.baseUrl) assertEquals(topic, it.topic) @@ -164,7 +186,7 @@ class RegisterToEOSBroadcastProcedureTest { "eOS-FP3-fr", ).forEach { topic -> coVerify { - subscriptionRepository.subscribe( + subscriptionRepository.createSubscription( withArg { assertEquals(baseUrlFixture, it.baseUrl) assertEquals(topic, it.topic) @@ -180,7 +202,7 @@ class RegisterToEOSBroadcastProcedureTest { "eOS-FP3-en", ).forEach { topic -> coVerify(exactly = 0) { - subscriptionRepository.subscribe( + subscriptionRepository.createSubscription( withArg { assertEquals(baseUrlFixture, it.baseUrl) assertEquals(topic, it.topic) diff --git a/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/usecases/GetSetNotificationsUseCaseTest.kt b/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/usecases/GetSetNotificationsUseCaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b2fd6682f56f8e34f7d0311e9c42f1011df33439 --- /dev/null +++ b/notificationsreceiver-domain/src/test/java/foundation/e/notificationsreceiver/domain/usecases/GetSetNotificationsUseCaseTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.domain.usecases + +import foundation.e.notificationreceiver.domain.entities.fixtures.messageFixture +import foundation.e.notificationreceiver.domain.entities.fixtures.topicFixture +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.entities.Topic +import foundation.e.notificationsreceiver.domain.repositories.MessagesRepository +import foundation.e.notificationsreceiver.domain.repositories.SubscriptionsRepository +import foundation.e.notificationsreceiver.domain.utils.d +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.collections.isNotEmpty + +class GetSetNotificationsUseCaseTest { + lateinit var useCase: GetSetNotificationsUseCase + + private val subscriptionsRepository: SubscriptionsRepository = mockk() + private val messagesRepository: MessagesRepository = mockk() + + private val subscriptions = MutableStateFlow(emptyList()) + private val allEnMessages = MutableStateFlow(emptyList()) + private val testScheduler = UnconfinedTestDispatcher() + private val testScope = TestScope(testScheduler) + + @Before + fun setup() { + every { subscriptionsRepository.subscriptions } returns subscriptions + every { messagesRepository.messages(any()) } returns emptyFlow() + every { messagesRepository.messages(listOf(topicFixture)) } returns allEnMessages + + useCase = GetSetNotificationsUseCase( + subscriptionsRepository, + messagesRepository, + testScope, + ) + } + + @Test + fun messageHistory_should_collect_messages_from_all_subscribed_eos_topics() = runTest(testScheduler) { + val topics = listOf( + topicFixture.copy(localId = 123, topic = "eOS-all-en"), + topicFixture.copy(localId = 234, topic = "eOS-FP3-en"), + topicFixture.copy(localId = 456, topic = "up201938EZAIE"), + topicFixture.copy(localId = 567, topic = "toto"), + ) + subscriptions.value = topics + // When + useCase.messagesHistory.firstOrNull() + + // Then + coVerify { + messagesRepository.messages( + listOf( + topicFixture.copy(localId = 123, topic = "eOS-all-en"), + topicFixture.copy(localId = 234, topic = "eOS-FP3-en"), + ), + ) + } + } + + @Test + fun unreadMessages_should_filter_read_notifications() = runTest(testScheduler) { + var passed = 0 + testScope.launch { + useCase.unreadMessages.collect { + d("unreadMessages: $it") + if (it.isNotEmpty()) { + assertEquals( + listOf( + messageFixture.copy(id = "1", read = false), + messageFixture.copy(id = "3", read = false), + ), + it, + ) + passed++ + } + } + } + + // When + subscriptions.value = listOf(topicFixture) + allEnMessages.emit( + listOf( + messageFixture.copy(id = "1", read = false), + messageFixture.copy(id = "2", read = true), + messageFixture.copy(id = "3", read = false), + ), + ) + + advanceUntilIdle() + + assertEquals(1, passed) + } + + @Test + fun observeMessage_should_listen_message_changes() = runTest(testScheduler) { + var passed = 0 + testScope.launch { + useCase.observeMessage("234").collect { + d("unreadMessages: $it") + assertEquals("234", it.id) + passed++ + } + } + + // When + subscriptions.value = listOf(topicFixture) + allEnMessages.emit( + listOf( + messageFixture.copy(id = "234", read = false), + messageFixture.copy(id = "345", read = false), + ), + ) + + advanceUntilIdle() + + assertEquals(1, passed) + } + + @Test + fun markAsRead_should_call_repository_with_localId() = runTest(testScheduler) { + coEvery { messagesRepository.markAsRead(any()) } returns Unit + + val message = messageFixture + + // When + useCase.markAsRead(message.id) + + // Then + coVerify { + messagesRepository.markAsRead(message.id) + } + } +} diff --git a/notificationsreceiver/build.gradle.kts b/notificationsreceiver/build.gradle.kts index a79d15beea1dc21ad1f58431ccfb32bb62fa5847..454e5f866390ca94a15193fce715421944295454 100644 --- a/notificationsreceiver/build.gradle.kts +++ b/notificationsreceiver/build.gradle.kts @@ -4,6 +4,8 @@ plugins { id("com.google.devtools.ksp") alias(libs.plugins.hilt.android) alias(libs.plugins.detekt) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -33,9 +35,27 @@ android { dependencies { implementation(project(":notificationsreceiver-domain")) + implementation(project(":elibcompose")) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + ksp(libs.androidx.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) implementation(libs.dagger.hilt.android) ksp(libs.dagger.hilt.compiler) + implementation(libs.markwon) + implementation(libs.markwon.strikethrough) + implementation(libs.markwon.html) + implementation(libs.markwon.compose) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) testImplementation(libs.junit) } diff --git a/notificationsreceiver/src/main/AndroidManifest.xml b/notificationsreceiver/src/main/AndroidManifest.xml index e8ed0581a3fde9e1c26c3db510fe29052cbd96a1..061c92777d43190c3e7001ebaf2f310336ae04a6 100644 --- a/notificationsreceiver/src/main/AndroidManifest.xml +++ b/notificationsreceiver/src/main/AndroidManifest.xml @@ -1,6 +1,39 @@ + + + + + + + + + + + + + + + + + + diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/bridges/broadcastreceivers/NotificationActionsReceiver.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/bridges/broadcastreceivers/NotificationActionsReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..7503487b751cc9195bf825ac9429b1f0e4168608 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/bridges/broadcastreceivers/NotificationActionsReceiver.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.bridges.broadcastreceivers + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.notificationsreceiver.bridges.utils.goAsync +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.usecases.GetSetNotificationsUseCase +import foundation.e.notificationsreceiver.domain.utils.AppBackgroundScope +import foundation.e.notificationsreceiver.domain.utils.e +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationActionsReceiver : BroadcastReceiver() { + companion object { + const val ACTION_MARK_AS_READ = "foundation.e.notificationsreceiver.intent.action.MARK_AS_READ" + const val KEY_MESSAGE_ID = "message_id" + + fun buildMarkAsReadPendingIntent(appContext: Context, message: Message): PendingIntent { + val markAsReadIntent = Intent(appContext, NotificationActionsReceiver::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(KEY_MESSAGE_ID, message.id) + } + + return PendingIntent.getBroadcast( + appContext, + message.notificationId, + markAsReadIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + } + } + + @Inject lateinit var getSetNotificationsUseCase: GetSetNotificationsUseCase + + @Inject @AppBackgroundScope + lateinit var backgroundScope: CoroutineScope + + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_MARK_AS_READ -> goAsync(backgroundScope) { + intent.getStringExtra(KEY_MESSAGE_ID)?.let { + getSetNotificationsUseCase.markAsRead(it) + } + } + else -> {} + } + } +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/HistoryNavHost.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/HistoryNavHost.kt new file mode 100644 index 0000000000000000000000000000000000000000..94074504d57b759db9161af42143cbb38592ecc6 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/HistoryNavHost.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +@file:Suppress("MatchingDeclarationName") + +package foundation.e.notificationsreceiver.ui + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import foundation.e.notificationsreceiver.ui.messagedetails.MessageDetailsActivity +import foundation.e.notificationsreceiver.ui.messageshistory.MessagesListDestination +import foundation.e.notificationsreceiver.ui.messageshistory.MessagesListViewModel +import kotlinx.serialization.Serializable + +sealed class HistoryRoutes { + @Serializable + object NotificationsList : HistoryRoutes() +} + +@Composable +fun HistoryNavHost() { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = HistoryRoutes.NotificationsList, + ) { + composable { + MessagesListDestination( + navController = navController, + viewModel = hiltViewModel(), + ) + } + } +} + +fun NavController.navigateMessageDetails(activity: Activity, messageId: String) { + activity.startActivity(MessageDetailsActivity.buildIntent(activity, messageId)) +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/MainActivity.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/MainActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..7f2795ab2bf5f0fd7c6a7bc564cc0d3007ed4704 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/MainActivity.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.elibcompose.theme.ETheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ETheme { + HistoryNavHost() + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_DENIED + ) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) + } + } +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/NotificationsPresenter.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/NotificationsPresenter.kt new file mode 100644 index 0000000000000000000000000000000000000000..9290c75463e4c18df98e98af63fa854317a7f182 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/NotificationsPresenter.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui + +import android.annotation.SuppressLint +import android.app.ActivityOptions +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.notificationsreceiver.R +import foundation.e.notificationsreceiver.bridges.broadcastreceivers.NotificationActionsReceiver +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.usecases.GetSetNotificationsUseCase +import foundation.e.notificationsreceiver.domain.utils.AppBackgroundScope +import foundation.e.notificationsreceiver.ui.messagedetails.MessageDetailsActivity +import io.noties.markwon.Markwon +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationsPresenter @Inject constructor( + @ApplicationContext private val appContext: Context, + private val getSetNotificationsUseCase: GetSetNotificationsUseCase, + private val markwon: Markwon, + @AppBackgroundScope private val backgroundScope: CoroutineScope, +) { + companion object { + private const val CHANNEL_EOS_NOTIFICATIONS = "eOS_broadcasting_notifications" + } + + private val notificationManager = NotificationManagerCompat.from(appContext) + + fun listen() { + createNotificationFirstBootChannel() + + getSetNotificationsUseCase.unreadMessages.map(::displayNotifications).launchIn(backgroundScope) + } + + private fun createNotificationFirstBootChannel() { + val channel = NotificationChannel( + CHANNEL_EOS_NOTIFICATIONS, + appContext.getString(R.string.settings_notifications_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ) + // TODO: handle lights ! + + notificationManager.createNotificationChannel(channel) + } + + private suspend fun displayNotifications(messages: List) = withContext(Dispatchers.Default) { + val notificationIdsToDisplay = messages.map { it.notificationId }.toSet() + notificationManager.activeNotifications.filter { statusBarNotification -> + statusBarNotification.id !in notificationIdsToDisplay + }.forEach { + notificationManager.cancel(it.id) + } + + messages.forEach(::displayNotification) + } + + @SuppressLint("MissingPermission") + private fun displayNotification(message: Message): Message { + val notificationBuilder = NotificationCompat.Builder(appContext, CHANNEL_EOS_NOTIFICATIONS) + notificationBuilder.setOnlyAlertOnce(true) + notificationBuilder.setOngoing(true) + notificationBuilder.setColor(ContextCompat.getColor(appContext, R.color.green_notification_icon)) + notificationBuilder.setSmallIcon( + when (message.icon) { + Message.IconType.EOS -> R.drawable.ic_eos + Message.IconType.MURENA -> R.drawable.ic_murena + }, + ) + + notificationBuilder.setContentTitle(message.title) + notificationBuilder.setContentText( + runCatching { markwon.toMarkdown(message.content) }.getOrDefault(message.content), + ) + notificationBuilder.setStyle( + NotificationCompat.BigTextStyle().bigText(markwon.toMarkdown(message.content)), + ) + notificationBuilder.addAction( + R.drawable.ic_check, + appContext.getString(R.string.notification_action_mark_as_read), + NotificationActionsReceiver.buildMarkAsReadPendingIntent(appContext, message), + ) + + val openDetailIntent = PendingIntent.getActivity( + appContext, + message.notificationId, + MessageDetailsActivity.buildIntent(appContext, message.id), + PendingIntent.FLAG_IMMUTABLE, + + ActivityOptions.makeBasic().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED, + ) + } + }.toBundle(), + ) + + notificationBuilder.setDeleteIntent(openDetailIntent) + notificationBuilder.setContentIntent(openDetailIntent) + notificationManager.notify(message.notificationId, notificationBuilder.build()) + return message + } +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/UiModule.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/UiModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..2144387e5e97a65f32bb7dfa74e966a6b5be413a --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/UiModule.kt @@ -0,0 +1,60 @@ +package foundation.e.notificationsreceiver.ui + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.style.BackgroundColorSpan +import android.text.style.BulletSpan +import android.text.style.QuoteSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.TypefaceSpan +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonSpansFactory +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.node.BlockQuote +import org.commonmark.node.Code +import org.commonmark.node.Emphasis +import org.commonmark.node.ListItem +import org.commonmark.node.StrongEmphasis +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object UiModule { + @Singleton + @Provides + fun provideMarkwon(@ApplicationContext appContext: Context): Markwon { + // https://github.com/noties/Markwon/blob/master/app-sample/src/main/java/io/noties/markwon/app/samples/notification/NotificationSample.java#L60 + val notificationPlugin = object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder + .setFactory(Emphasis::class.java) { configuration, props -> StyleSpan(Typeface.ITALIC) } + .setFactory(StrongEmphasis::class.java) { configuration, props -> StyleSpan(Typeface.BOLD) } + .setFactory(BlockQuote::class.java) { configuration, props -> QuoteSpan() } + .setFactory(Strikethrough::class.java) { configuration, props -> StrikethroughSpan() } + // NB! notification does not handle background color + .setFactory(Code::class.java) { configuration, props -> + arrayOf( + BackgroundColorSpan(Color.GRAY), + TypefaceSpan("monospace"), + ) + } + // NB! both ordered and bullet list items + .setFactory(ListItem::class.java) { configuration, props -> BulletSpan() } + } + } + + return Markwon.builder(appContext) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(notificationPlugin) + .build() + } +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetails.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetails.kt new file mode 100644 index 0000000000000000000000000000000000000000..129831410a14c0f2d01522eea49227e7c80516f9 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetails.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messagedetails + +data class MessageDetails( + val title: String = "", + val content: String = "", + val receivedAt: String = "", +) diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsActivity.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..98871088570f46cc98fda50c738bbab2be0baecf --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsActivity.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messagedetails + +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.elibcompose.theme.ETheme + +@AndroidEntryPoint +class MessageDetailsActivity : ComponentActivity() { + companion object { + const val KEY_MESSAGE_ID = "message_id" + + fun buildIntent(context: Context, messageId: String): Intent { + return Intent(context, MessageDetailsActivity::class.java).apply { + flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_MULTIPLE_TASK + putExtra(KEY_MESSAGE_ID, messageId) + } + } + } + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ETheme { + val uiState by viewModel.message.collectAsStateWithLifecycle() + MessageDetailsView( + uiState, + markAsRead = { + viewModel.markAsRead() + finish() + }, + ) + } + } + } +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsScreen.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..c92314172ba36d1bb500bdad6f57b2ac902bdcf2 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsScreen.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messagedetails + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import dev.jeziellago.compose.markdowntext.MarkdownText +import foundation.e.notificationsreceiver.R + + +@Composable +fun MessageDetailsView( + message: MessageDetails, + markAsRead: () -> Unit, +) { + SimplifiedAlertDialogContent( + title = message.title, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + MarkdownText( + modifier = Modifier.weight(weight = 1f, fill = false) + .align(Alignment.Start), + markdown = message.content, + linkColor = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodyMedium, + isTextSelectable = true, + ) + } + }, + confirmButton = { + TextButton(onClick = markAsRead) { + Text( + text = stringResource(R.string.message_mark_as_read), + color = MaterialTheme.colorScheme.tertiary, + ) + } + }, + ) +} + +@Preview +@Composable +private fun previewOnMessage() { + MessageDetailsView( + MessageDetails( + "Great notification", + "Hello\nThis is a notification !", + receivedAt = "Aujourd'hui", + ), + {}, + ) +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsViewModel.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..53ca6799d752da5e032b9073850c785fa884a51f --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/MessageDetailsViewModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messagedetails + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.usecases.GetSetNotificationsUseCase +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MessageDetailsViewModel @Inject constructor( + private val getSetNotificationsUseCase: GetSetNotificationsUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val messageId: String = savedStateHandle.get(MessageDetailsActivity.KEY_MESSAGE_ID) ?: "" + + val message: StateFlow = getSetNotificationsUseCase.observeMessage(messageId) + .map(::formatMessage) + .stateIn( + viewModelScope, + started = SharingStarted.Eagerly, + initialValue = MessageDetails(), + ) + + fun markAsRead() = viewModelScope.launch { + getSetNotificationsUseCase.markAsRead(messageId) + } + + private fun formatMessage(message: Message): MessageDetails = MessageDetails( + title = message.title ?: "", + content = message.content, + receivedAt = message.timestamp.toString(), + ) +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/SimplifiedAlertDialogContent.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/SimplifiedAlertDialogContent.kt new file mode 100644 index 0000000000000000000000000000000000000000..d033be038eab4901d004ed3ef575c13ad51878cd --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messagedetails/SimplifiedAlertDialogContent.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messagedetails + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +// Adaptation of the compose sdk internal AlertDialogContent, to obtain a dialog aspect in a +// dedicated activity (instea of the Dialog composable). +@Composable +fun SimplifiedAlertDialogContent( + modifier: Modifier = Modifier, + title: String, + content: @Composable () -> Unit, + confirmButton: @Composable () -> Unit, + dismissButton: @Composable (() -> Unit)? = null, +) { + Box( + modifier = modifier + .sizeIn(minWidth = DialogMinWidth, maxWidth = DialogMaxWidth), + propagateMinConstraints = true, + ) { + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column(modifier = Modifier.padding(DialogPadding)) { + Text( + modifier = Modifier.padding(TitlePadding), + textAlign = TextAlign.Center, + text = title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + Box( + Modifier.weight(weight = 1f, fill = false) + .padding(TextPadding) + .align(Alignment.Start), + ) { + content() + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + dismissButton?.invoke() + Spacer(Modifier.width(ButtonsMainAxisSpacing)) + confirmButton() + } + } + } + } +} + +private val DialogMinWidth = 280.dp +private val DialogMaxWidth = 560.dp +private val ButtonsMainAxisSpacing = 8.dp + +// Paddings for each of the dialog's parts. +private val DialogPadding = PaddingValues(all = 24.dp) +private val TitlePadding = PaddingValues(bottom = 16.dp) +private val TextPadding = PaddingValues(bottom = 24.dp) diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessageItem.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessageItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..c5bbfe1015774df1ae8f6c3ea94edcf98c195ecf --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessageItem.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messageshistory + +data class MessageItem( + val id: String, + val title: String, + val receivedSince: String, + val content: String, + val read: Boolean, +) diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListDestination.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListDestination.kt new file mode 100644 index 0000000000000000000000000000000000000000..42943f1bec026b7b001ed1338055eb3a68b057b8 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListDestination.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messageshistory + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import foundation.e.notificationsreceiver.ui.navigateMessageDetails + +@Composable +fun MessagesListDestination( + navController: NavController, + viewModel: MessagesListViewModel, +) { + val uiState by viewModel.messages.collectAsStateWithLifecycle(emptyList()) + + val activity = LocalActivity.current + BackHandler { activity?.finish() } + + MessagesListScreen( + uiState = uiState, + onClickBack = { activity?.finish() }, + showMessageDetails = { messageId -> + activity?.let { + navController.navigateMessageDetails(activity, messageId) + } + }, + ) +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListScreen.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..410fc48763a444d26052f684d63af1034258b8c1 --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListScreen.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messageshistory + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import foundation.e.elibcompose.theme.titleStyle +import foundation.e.elibcompose.widgets.settings.SettingsAppBar +import foundation.e.elibcompose.widgets.settings.Summary +import foundation.e.notificationsreceiver.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessagesListScreen( + uiState: List, + onClickBack: () -> Unit, + showMessageDetails: (String) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + SettingsAppBar( + title = stringResource(R.string.history_list_title), + onBackPressed = onClickBack, + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + if (uiState.isEmpty()) { + Box( + modifier = Modifier.padding(innerPadding).fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(R.string.history_list_empty), + style = titleStyle, + ) + } + } else { + LazyColumn( + modifier = Modifier.padding( + top = innerPadding.calculateTopPadding(), + start = innerPadding.calculateStartPadding(layoutDirection = LocalLayoutDirection.current), + end = innerPadding.calculateEndPadding(layoutDirection = LocalLayoutDirection.current), + ), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = uiState, + key = { message -> message.id }, + ) { message -> + MessageItem( + message = message, + onClick = { showMessageDetails(message.id) }, + ) + HorizontalDivider(Modifier.padding(horizontal = 32.dp)) + } + item { + Spacer(Modifier.padding(bottom = innerPadding.calculateBottomPadding())) + } + } + } + } +} + +@Composable +private fun MessageItem( + modifier: Modifier = Modifier, + message: MessageItem, + onClick: () -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth() + .padding(vertical = 16.dp) + .padding(end = 32.dp) + .clickable { onClick() } + ) { + if (message.read) + Spacer(modifier = Modifier.width(32.dp)) + else + Spacer( + modifier = Modifier + .padding(top = 6.dp) + .padding(start = 12.dp, end = 8.dp) + .size(12.dp) + .background( + color = colorResource(R.color.green_notification_icon), + shape = CircleShape + ) + ) + + Column(modifier = Modifier) { + Text( + text = message.title, + modifier = modifier, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = if (message.read) FontWeight.Normal else FontWeight.Medium, + style = titleStyle, + ) + + Summary(text = message.receivedSince) + } + } +} + +@Preview +@Composable +private fun DisplayLotOfItems() { + MessagesListScreen( + uiState = (0..10).map { i -> + MessageItem( + id = "id_$i", + title = "Great notifications $i", + receivedSince = "$i minutes", + content = "toto", + read = false, + ) + }, + onClickBack = {}, + showMessageDetails = {}, + ) +} + +@Preview +@Composable +private fun DisplayNoItems() { + MessagesListScreen( + uiState = emptyList(), + onClickBack = {}, + showMessageDetails = {}, + ) +} diff --git a/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListViewModel.kt b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7ee05a97cb2fd51fcb0a5e420c85b08d664bd0a --- /dev/null +++ b/notificationsreceiver/src/main/java/foundation/e/notificationsreceiver/ui/messageshistory/MessagesListViewModel.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 E FOUNDATION + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package foundation.e.notificationsreceiver.ui.messageshistory + +import android.text.format.DateUtils +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.notificationsreceiver.domain.entities.Message +import foundation.e.notificationsreceiver.domain.usecases.GetSetNotificationsUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class MessagesListViewModel @Inject constructor( + private val getSetNotificationsUseCase: GetSetNotificationsUseCase, +) : ViewModel() { + companion object { + private const val TITLE_MAX_LENGTH = 20 + } + + val messages: Flow> = getSetNotificationsUseCase.messagesHistory.map { + it.map(::formatItem) + } + + private fun formatItem(message: Message): MessageItem = MessageItem( + id = message.id, + title = message.title ?: message.content.slice(0..TITLE_MAX_LENGTH), + receivedSince = DateUtils.getRelativeTimeSpanString(message.timestamp.toEpochMilli()).toString(), + content = message.content, + read = message.read, + ) +} diff --git a/notificationsreceiver/src/main/res/drawable/ic_check.xml b/notificationsreceiver/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000000000000000000000000000000000..280f0bd8046d84c0f1ec385457c3d67109c58029 --- /dev/null +++ b/notificationsreceiver/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/notificationsreceiver/src/main/res/drawable/ic_eos.xml b/notificationsreceiver/src/main/res/drawable/ic_eos.xml new file mode 100644 index 0000000000000000000000000000000000000000..ca85e1ca5d2edda166e103cf6e80d61a9d0862c6 --- /dev/null +++ b/notificationsreceiver/src/main/res/drawable/ic_eos.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/notificationsreceiver/src/main/res/drawable/ic_murena.xml b/notificationsreceiver/src/main/res/drawable/ic_murena.xml new file mode 100644 index 0000000000000000000000000000000000000000..2386a0261981baeacd06d8e795996866ddb7542a --- /dev/null +++ b/notificationsreceiver/src/main/res/drawable/ic_murena.xml @@ -0,0 +1,12 @@ + + + + diff --git a/notificationsreceiver/src/main/res/values/colors.xml b/notificationsreceiver/src/main/res/values/colors.xml new file mode 100644 index 0000000000000000000000000000000000000000..47946f22bd2b4d500b84699c4a8614413cdbdc49 --- /dev/null +++ b/notificationsreceiver/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #44B04C + \ No newline at end of file diff --git a/notificationsreceiver/src/main/res/values/strings.xml b/notificationsreceiver/src/main/res/values/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e2196a3928e2fb3436240bf8bb8b9240a44c228 --- /dev/null +++ b/notificationsreceiver/src/main/res/values/strings.xml @@ -0,0 +1,32 @@ + + + + eOS Notifications Receiver + + eOS Broadcasting Notifications + /e/OS Notifications Receiver + + OK + + Murena Notifications + No notifications received + + OK + + Murena Notifications + diff --git a/notificationsreceiver/src/main/res/values/themes.xml b/notificationsreceiver/src/main/res/values/themes.xml new file mode 100644 index 0000000000000000000000000000000000000000..a937a9ea71e730a784a93bd69bf347e3cc8f21e5 --- /dev/null +++ b/notificationsreceiver/src/main/res/values/themes.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/settings.gradle b/settings.gradle index b10c51f02e79e538a316da46e58f59acd3d56f52..08deda39fd37ec187f90a5094bbefd0ef4f367e1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,3 +3,4 @@ include ':app' include ':notificationsreceiver-domain' include ":notificationsreceiver-domain:entitiesfixtures" include ':notificationsreceiver' +include ":elibcompose"