From ddf88c9db2576c06379c5d611206db7f8e8b5a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Wed, 17 Jul 2024 01:52:33 +0200 Subject: [PATCH 1/9] Upgrade Retrofit dependency to version 2.9.0 --- client/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/build.gradle b/client/build.gradle index 939a9258..1d380270 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -11,7 +11,7 @@ targetCompatibility = JavaVersion.VERSION_1_7 ext { oltu_version = "1.0.2" - retrofit_version = "2.5.0" + retrofit_version = "2.9.0" swagger_annotations_version = "1.5.15" junit_version = "4.13" threetenbp_version = "1.4.4" From b337a7170318413e92ffe3349aeafd8fbf115eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Wed, 17 Jul 2024 01:53:15 +0200 Subject: [PATCH 2/9] Add coroutine dependency --- app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle b/app/build.gradle index 57f4dc0e..4c8d041a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,6 +77,7 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.vectordrawable:vectordrawable:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1' implementation 'com.github.cyb3rko:QuickPermissions-Kotlin:1.1.3' implementation "io.coil-kt:coil:$coil_version" From 0ca9b12cc13d0a75d00d4ada16f81e23b4547ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Mon, 30 Sep 2024 23:34:55 +0200 Subject: [PATCH 3/9] Refactor `Settings` class --- .../com/github/gotify/GotifyApplication.kt | 2 +- .../main/kotlin/com/github/gotify/Settings.kt | 67 +++++++++++-------- .../github/gotify/service/WebSocketService.kt | 3 +- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt b/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt index 11553710..70874e1f 100644 --- a/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt +++ b/app/src/main/kotlin/com/github/gotify/GotifyApplication.kt @@ -36,7 +36,7 @@ class GotifyApplication : Application() { try { val legacyCert = settings.legacyCert settings.legacyCert = null - val caCertFile = File(settings.filesDir, CertUtils.CA_CERT_NAME) + val caCertFile = File(filesDir.absolutePath, CertUtils.CA_CERT_NAME) FileOutputStream(caCertFile).use { it.write(legacyCert?.encodeToByteArray()) } diff --git a/app/src/main/kotlin/com/github/gotify/Settings.kt b/app/src/main/kotlin/com/github/gotify/Settings.kt index 0592a3cf..bf45088a 100644 --- a/app/src/main/kotlin/com/github/gotify/Settings.kt +++ b/app/src/main/kotlin/com/github/gotify/Settings.kt @@ -4,19 +4,17 @@ import android.content.Context import android.content.SharedPreferences import com.github.gotify.client.model.User -internal class Settings(context: Context) { - private val sharedPreferences: SharedPreferences - val filesDir: String +internal class Settings(private val sharedPreferences: SharedPreferences) { var url: String - get() = sharedPreferences.getString("url", "")!! - set(value) = sharedPreferences.edit().putString("url", value).apply() + get() = sharedPreferences.getString(KEY_URL, "")!! + set(value) = sharedPreferences.edit().putString(KEY_URL, value).apply() var token: String? - get() = sharedPreferences.getString("token", null) - set(value) = sharedPreferences.edit().putString("token", value).apply() + get() = sharedPreferences.getString(KEY_TOKEN, null) + set(value) = sharedPreferences.edit().putString(KEY_TOKEN, value).apply() var user: User? = null get() { - val username = sharedPreferences.getString("username", null) - val admin = sharedPreferences.getBoolean("admin", false) + val username = sharedPreferences.getString(KEY_USERNAME, null) + val admin = sharedPreferences.getBoolean(KEY_ADMIN, false) return if (username != null) { User().name(username).admin(admin) } else { @@ -25,28 +23,26 @@ internal class Settings(context: Context) { } private set var serverVersion: String - get() = sharedPreferences.getString("version", "UNKNOWN")!! - set(value) = sharedPreferences.edit().putString("version", value).apply() + get() = sharedPreferences.getString(KEY_VERSION, "UNKNOWN")!! + set(value) = sharedPreferences.edit().putString(KEY_VERSION, value).apply() var legacyCert: String? - get() = sharedPreferences.getString("cert", null) - set(value) = sharedPreferences.edit().putString("cert", value).commit().toUnit() + get() = sharedPreferences.getString(KEY_CERTIFICATE, null) + set(value) = sharedPreferences.edit().putString(KEY_CERTIFICATE, value).commit().toUnit() var caCertPath: String? - get() = sharedPreferences.getString("caCertPath", null) - set(value) = sharedPreferences.edit().putString("caCertPath", value).commit().toUnit() + get() = sharedPreferences.getString(KEY_CA_CERTIFICATE_PATH, null) + set(value) = sharedPreferences.edit().putString( + KEY_CA_CERTIFICATE_PATH, + value + ).commit().toUnit() var validateSSL: Boolean - get() = sharedPreferences.getBoolean("validateSSL", true) - set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply() + get() = sharedPreferences.getBoolean(KEY_VALIDATE_SSL, true) + set(value) = sharedPreferences.edit().putBoolean(KEY_VALIDATE_SSL, value).apply() var clientCertPath: String? - get() = sharedPreferences.getString("clientCertPath", null) - set(value) = sharedPreferences.edit().putString("clientCertPath", value).apply() + get() = sharedPreferences.getString(KEY_CLIENT_CERTIFICATE_PATH, null) + set(value) = sharedPreferences.edit().putString(KEY_CLIENT_CERTIFICATE_PATH, value).apply() var clientCertPassword: String? - get() = sharedPreferences.getString("clientCertPass", null) - set(value) = sharedPreferences.edit().putString("clientCertPass", value).apply() - - init { - sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE) - filesDir = context.filesDir.absolutePath - } + get() = sharedPreferences.getString(KEY_CLIENT_CERTIFICATE_PASS, null) + set(value) = sharedPreferences.edit().putString(KEY_CLIENT_CERTIFICATE_PASS, value).apply() fun tokenExists(): Boolean = !token.isNullOrEmpty() @@ -61,7 +57,7 @@ internal class Settings(context: Context) { } fun setUser(name: String?, admin: Boolean) { - sharedPreferences.edit().putString("username", name).putBoolean("admin", admin).apply() + sharedPreferences.edit().putString(KEY_USERNAME, name).putBoolean(KEY_ADMIN, admin).apply() } fun sslSettings(): SSLSettings { @@ -75,4 +71,21 @@ internal class Settings(context: Context) { @Suppress("UnusedReceiverParameter") private fun Any?.toUnit() = Unit + + companion object { + private const val KEY_URL = "url" + private const val KEY_TOKEN = "token" + private const val KEY_USERNAME = "username" + private const val KEY_ADMIN = "admin" + private const val KEY_VERSION = "version" + private const val KEY_CERTIFICATE = "cert" + private const val KEY_CA_CERTIFICATE_PATH = "caCertPath" + private const val KEY_VALIDATE_SSL = "validateSSL" + private const val KEY_CLIENT_CERTIFICATE_PATH = "clientCertPath" + private const val KEY_CLIENT_CERTIFICATE_PASS = "clientCertPass" + + operator fun invoke(context: Context): Settings = Settings( + sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE) + ) + } } diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt index 3bfc6984..42acbcb4 100644 --- a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt @@ -47,7 +47,7 @@ internal class WebSocketService : Service() { private const val NOT_LOADED = -2L } - private lateinit var settings: Settings + private val settings: Settings by lazy { Settings(this) } private var connection: WebSocketConnection? = null private val networkCallback: ConnectivityManager.NetworkCallback = object : ConnectivityManager.NetworkCallback() { @@ -66,7 +66,6 @@ internal class WebSocketService : Service() { override fun onCreate() { super.onCreate() - settings = Settings(this) val client = ClientFactory.clientToken(settings) missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java)) Logger.info("Create ${javaClass.simpleName}") From 2fb1e991b2205d1d107cd23de4e24614b539995a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Wed, 17 Jul 2024 02:15:40 +0200 Subject: [PATCH 4/9] Create a repository class to provide reactive data --- .../kotlin/com/github/gotify/Repository.kt | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 app/src/main/kotlin/com/github/gotify/Repository.kt diff --git a/app/src/main/kotlin/com/github/gotify/Repository.kt b/app/src/main/kotlin/com/github/gotify/Repository.kt new file mode 100644 index 00000000..f30004f8 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/Repository.kt @@ -0,0 +1,320 @@ +package com.github.gotify + +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.api.ApplicationApi +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Application +import com.github.gotify.client.model.Message +import com.github.gotify.client.model.Paging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import retrofit2.awaitResponse + +/** + * A class that represents the repository. + * + * @property scope The coroutine scope where the repository will run. + * @property baseUrl The base url of the server used to create the full url of the images. + * @property applicationApi The application api used to interact with the server. + * @property messageApi The message api used to interact with the server. + */ +class Repository +private constructor( + private val scope: CoroutineScope, + private val baseUrl: String, + private val applicationApi: ApplicationApi, + private val messageApi: MessageApi +) { + private val applications = MutableStateFlow>(emptyList()) + private val messages = MutableStateFlow>>(emptyMap()) + private val paging = MutableStateFlow>(emptyMap()) + private var allApplicationsPaging: PagingState = PagingState() + + /** + * Get all applications. + * It returns a [StateFlow] object that emits the list of all applications with their states + * whenever the list changes. + */ + val applicationsState: StateFlow> = + combine(applications, messages, paging) { apps, messages, paging -> + apps.map { app -> + val unreadCount = messages[app.id]?.size ?: 0 + val hasMoreMessages = paging[app.id]?.hasMore ?: true + ApplicationState( + app, + unreadCount, + hasMoreMessages, + app.image?.let { + ( + it.toHttpUrlOrNull() ?: baseUrl.toHttpUrlOrNull()?.newBuilder() + ?.addPathSegment(it)?.build()?.toString() + )?.toString() + } + ) + }.also { println("JcLog: ${this@Repository} number of applications -> ${it.size}") } + }.stateIn(scope = scope, started = SharingStarted.Lazily, initialValue = emptyList()) + + /** + * Get all messages of all applications. + * It returns a [StateFlow] object that emits the list of all messages whenever the list changes. + */ + val allMessages: StateFlow> = + messages.map { it.values.flatten() }.distinctUntilChanged() + .stateIn(scope = scope, started = SharingStarted.Lazily, initialValue = emptyList()) + + /** + * Get messages of an application. + * It returns a [StateFlow] object that emits the list of messages of the application + * whenever the list changes. + * + * @param application The application. + * + * @return The list of messages of the application. + */ + fun getMessages(application: Application): StateFlow> = + messages.map { it[application.id] ?: emptyList() }.distinctUntilChanged() + .stateIn(scope = scope, started = SharingStarted.Lazily, initialValue = emptyList()) + + /** + * Fetch all applications. + * + * @return The list of the fetched applications. + */ + suspend fun fetchApps(): List = + applicationApi.getApps().awaitResponse().takeIf { it.isSuccessful }?.body() + ?.also { applications.value = it } ?: emptyList() + + /** + * Fetch messages from an application. + * + * @param application The application. + * @param limit The number of messages to fetch. + * + * @return The list of the fetched messages. + */ + suspend fun fetchMessages(application: Application, limit: Int = LIMIT): List { + val currentPagingState = paging.value[application.id] ?: PagingState() + return when (currentPagingState.hasMore) { + false -> emptyList() + true -> messageApi.getAppMessages(application.id, limit, currentPagingState.since) + .awaitResponse().takeIf { it.isSuccessful }?.body() + ?.also { paging.value += (application.id to it.paging.toPagingState()) }?.also { + println("JcLog: pagin.since -> ${it.paging.since}") + println("JcLog: pagin.next -> ${it.paging.next}") + println("JcLog: lastMessage -> ${it.messages.lastOrNull()?.id}") + }?.also { storeMessages(it.messages, currentPagingState.since == 0L) }?.messages + ?: emptyList() + } + } + + /** + * Fetch messages from all applications. + * + * @param limit The number of messages to fetch. + * + * @return The list of the fetched messages. + */ + suspend fun fetchMessages(limit: Int = LIMIT): List { + return when (allApplicationsPaging.hasMore) { + false -> return emptyList() + true -> messageApi.getMessages(limit, allApplicationsPaging.since).awaitResponse() + .takeIf { it.isSuccessful }?.body() + ?.also { allApplicationsPaging = it.paging.toPagingState() } + ?.also { storeMessages(it.messages, false) }?.messages ?: emptyList() + } + } + + /** + * Delete a message. + * + * @param message The message to delete. + * + * @return A boolean value that indicates if the message was deleted. + */ + suspend fun deleteMessage(message: Message): Boolean = + messageApi.deleteMessage(message.id).awaitResponse().isSuccessful.also { + if (it) { + storeMessages( + messages.value.flatMap { + it.value.filterNot { it.id == message.id } + }, + true + ) + } + } + + /** + * Delete all messages of all applications. + * + * @return A boolean value that indicates if the messages were deleted. + */ + suspend fun deleteAllMessages(): Boolean = + messageApi.deleteMessages().awaitResponse().isSuccessful.also { + if (it) messages.value = emptyMap() + } + + /** + * Delete all messages of an application. + * + * @param application The application. + * + * @return A boolean value that indicates if the messages were deleted. + */ + suspend fun deleteAllMessages(application: Application): Boolean = + messageApi.deleteAppMessages(application.id) + .awaitResponse().isSuccessful.also { if (it) messages.value -= application.id } + + /** + * Store a message in the repository. + * + * @param newMessage The message to store. + */ + fun storeMessage(newMessage: Message) { + storeMessages(listOf(newMessage), false) + } + + /** + * Store messages in the repository. + * + * @param newMessages The list of messages to store. + * @param overrideOld A boolean value that indicates if the old messages should be overridden. + */ + private fun storeMessages(newMessages: List, overrideOld: Boolean) { + fetchMissingApplications(newMessages) + val previousMessages = messages.value.takeUnless { overrideOld } ?: emptyMap() + val mappedMessages: Map> = + newMessages.groupBy { it.appid }.map { (appId, messages) -> + val previous = previousMessages[appId] ?: emptyList() + ( + appId to ( + ( + previous.associateBy { + it.id + } + messages.associateBy { + it.id + } + ).values.sortedByDescending { + it.id + } + ) + ) + }.toMap() + messages.value += mappedMessages + } + + /** + * Fetch applications that are missing from the list of messages. + * + * @param messages The list of messages. + */ + private fun fetchMissingApplications(messages: List) { + scope.launch { + val appIds = applications.value.map { it.id } + this@Repository.takeUnless { messages.all { appIds.contains(it.id) } }?.fetchApps() + } + } + + /** + * Refresh all messages of all applications. + * + * @return The list of refreshed messages. + */ + suspend fun refreshAllMessages() { + applications.value.forEach { app -> + refreshMessages(app) + } + allApplicationsPaging = PagingState() + fetchMessages() + } + + /** + * Refresh the messages of an application. + * + * @param application The application to refresh. + * + * @return The list of refreshed messages. + */ + suspend fun refreshMessages(application: Application): List { + paging.value -= application.id + return fetchMessages(application, messages.value[application.id]?.size ?: LIMIT) + } + + /** + * Initialize the repository. Fetch all applications and their last messages. + */ + private suspend fun initialize() { + fetchApps().forEach { app -> + fetchMessages(app) + } + fetchMessages() + } + + /** + * Delete an application. + * + * @param app The application to delete. + * + * @return A boolean value that indicates if the application was deleted. + */ + suspend fun deleteApp(app: Application): Boolean = applicationApi.deleteApp(app.id) + .awaitResponse().isSuccessful.also { if (it) applications.value -= app } + + companion object { + + private const val LIMIT = 20 + + /** + * Create a new instance of the repository. The repository will be initialized after creation. + * + * @param scope The coroutine scope. + * @param settings The settings. + */ + internal fun create(scope: CoroutineScope, settings: Settings): Repository { + val apiClient = ClientFactory.clientToken(settings) + val applicationApi = apiClient.createService(ApplicationApi::class.java) + val messageApi = apiClient.createService(MessageApi::class.java) + return Repository(scope, settings.url, applicationApi, messageApi).also { + scope.launch { it.initialize() } + } + } + } +} + +/** + * A function that converts a [Paging] object to a [PagingState] object. + */ +private fun Paging.toPagingState(): PagingState = PagingState(since, next != null) + +/** + * A class that represents the state of the paging. + * + * @property since The older message id value of the paging. + * @property hasMore A boolean value that indicates if there are more messages. + */ +private data class PagingState( + val since: Long = 0, + val hasMore: Boolean = true +) + +/** + * A class that represents the state of the application. + * + * @property application The application. + * @property unreadCount The number of unread messages. + * @property hasMoreMessages A boolean value that indicates if there are more messages. + * @property iconUrl The icon url of the application. + */ +data class ApplicationState( + val application: Application, + val unreadCount: Int, + val hasMoreMessages: Boolean, + val iconUrl: String? +) From 8d59a90671b57f5697156c815bf93bfa8033b987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Mon, 30 Sep 2024 23:40:59 +0200 Subject: [PATCH 5/9] Keep a weak reference to the Repository within `WebSocketService` to update data --- .../github/gotify/service/WebSocketService.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt index 42acbcb4..dc022bfa 100644 --- a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt @@ -22,6 +22,7 @@ import com.github.gotify.MarkwonFactory import com.github.gotify.MissedMessageUtil import com.github.gotify.NotificationSupport import com.github.gotify.R +import com.github.gotify.Repository import com.github.gotify.Settings import com.github.gotify.Utils import com.github.gotify.api.Callback @@ -36,8 +37,11 @@ import com.github.gotify.messages.Extras import com.github.gotify.messages.IntentUrlDialogActivity import com.github.gotify.messages.MessagesActivity import io.noties.markwon.Markwon +import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty import org.tinylog.kotlin.Logger internal class WebSocketService : Service() { @@ -45,6 +49,8 @@ internal class WebSocketService : Service() { private val castAddition = if (BuildConfig.DEBUG) ".DEBUG" else "" val NEW_MESSAGE_BROADCAST = "${WebSocketService::class.java.name}.NEW_MESSAGE$castAddition" private const val NOT_LOADED = -2L + + var repository: Repository? by weakReference() } private val settings: Settings by lazy { Settings(this) } @@ -243,6 +249,7 @@ internal class WebSocketService : Service() { } private fun broadcast(message: Message) { + repository?.storeMessage(message) val intent = Intent() intent.action = NEW_MESSAGE_BROADCAST intent.putExtra("message", Utils.JSON.toJson(message)) @@ -440,3 +447,12 @@ internal class WebSocketService : Service() { notificationManager.notify(-5, builder.build()) } } + +private fun weakReference(tIn: T? = null): ReadWriteProperty = + object : ReadWriteProperty { + var t = WeakReference(tIn) + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = t.get() + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + t = WeakReference(value) + } + } From 799d150cfcd321bdd0108120388a2b64efae4241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Mon, 30 Sep 2024 22:45:27 +0200 Subject: [PATCH 6/9] Create a new ViewModel to be used on the Messages Screen --- app/build.gradle | 2 +- .../gotify/messages/MessagesViewModel.kt | 210 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/com/github/gotify/messages/MessagesViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 4c8d041a..00de55d5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,6 +77,7 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.vectordrawable:vectordrawable:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1' implementation 'com.github.cyb3rko:QuickPermissions-Kotlin:1.1.3' @@ -95,6 +96,5 @@ dependencies { configurations { configureEach { exclude group: 'org.json', module: 'json' - exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx' } } diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesViewModel.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesViewModel.kt new file mode 100644 index 00000000..7f8fc0e2 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesViewModel.kt @@ -0,0 +1,210 @@ +package com.github.gotify.messages + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.gotify.ApplicationState +import com.github.gotify.Repository +import com.github.gotify.client.model.Application +import com.github.gotify.client.model.Message +import com.github.gotify.messages.provider.MessageWithImage +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * ViewModel for the messages screen. + * + * @param repository The repository to get the data from and to send the data to. + */ +internal class MessagesViewModel(private val repository: Repository) : ViewModel() { + private val loadingMore = AtomicBoolean(false) + private val shadowDeletedMessages: MutableStateFlow> = MutableStateFlow(emptySet()) + private val currentApp = MutableStateFlow(null) + private val _refreshing = MutableStateFlow(false) + + /** + * Whether the messages are currently being refreshed. + */ + val refreshing = _refreshing + + /** + * The state of the applications. + */ + val applicationsState: StateFlow> = repository.applicationsState + + /** + * The current menu mode. + */ + val menuMode: StateFlow = currentApp.map { app -> + app?.let { AppMenuMode(it) } ?: AllAppMenuMode + }.distinctUntilChanged().stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = AllAppMenuMode + ) + + /** + * An observable list of messages. The messages are filtered by the current application. + */ + private val messages: StateFlow> = currentApp.flatMapLatest { app -> + when (app) { + null -> repository.allMessages + else -> repository.getMessages(app) + } + }.distinctUntilChanged().onEach { loadingMore.set(false) }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) + + /** + * An observable list of messages with images. + * The messages are filtered by the current application and any shadow deleted messages. + * The messages are sorted by id in descending order. + */ + internal val messagesWithImage: StateFlow> = combine( + messages, + applicationsState, + shadowDeletedMessages + ) { messages, apps, shadowDeletedMessages -> + val appsImages = + apps.associateBy { it.application.id }.mapValues { it.value.application.image } + messages.filterNot { shadowDeletedMessages.contains(it.id) }.sortedByDescending { it.id } + .map { message -> + MessageWithImage(message, appsImages[message.appid]) + } + }.distinctUntilChanged().stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) + + /** + * Refreshes all messages and applications. + */ + fun onRefreshAll() { + viewModelScope.launch { + repository.fetchApps() + repository.refreshAllMessages() + } + } + + /** + * Refreshes the list of messages. + */ + fun onRefreshMessages() { + viewModelScope.launch { + _refreshing.value = true + currentApp.value?.let { repository.refreshMessages(it) } + ?: repository.refreshAllMessages() + _refreshing.value = false + } + } + + /** + * Selects an application. The messages will be filtered by the selected application. + */ + fun onSelectApplication(app: Application) { + currentApp.value = app + } + + /** + * Deselects the application. The messages will not be filtered by any application. + */ + fun onDeselectApplication() { + currentApp.value = null + } + + /** + * Shadows a message. The message will not be displayed in the list of messages. + */ + fun onShadowDeleteMessage(message: Message) { + shadowDeletedMessages.value += message.id + } + + /** + * Unshadows a message. The message will be displayed in the list of messages. + */ + fun onUnShadowDeleteMessage(message: Message) { + shadowDeletedMessages.value -= message.id + } + + /** + * Deletes a message. The message will be removed from the list of messages and the server. + * If the message could not be deleted from the server, the message will be unshadowed. + */ + fun onDeleteMessage(message: Message) { + viewModelScope.launch { + if (!repository.deleteMessage(message)) { + onUnShadowDeleteMessage(message) + } + } + } + + /** + * Deletes all messages. The messages will be removed from the list of messages and the server. + * + * @param menuMode The menu mode to delete the messages from. + * If it is an [AppMenuMode], only the messages of the selected application will be deleted. + */ + fun deleteAllMessages(menuMode: MenuMode) { + viewModelScope.launch { + when (menuMode) { + is AppMenuMode -> repository.deleteAllMessages(menuMode.app) + is AllAppMenuMode -> repository.deleteAllMessages() + } + } + } + + /** + * Deletes an application. + * The application will be removed from the list of applications and the server. + * + * @param app The application to delete. + */ + fun deleteApp(app: Application) { + viewModelScope.launch { + if (repository.deleteApp(app)) { + onDeselectApplication() + } + } + } + + /** + * Loads more messages. + * If the current application is null, messages from all applications will be loaded. + * If the messages are currently being loaded, this method does nothing. + */ + fun onLoadMore() { + viewModelScope.launch { + if (loadingMore.compareAndSet(false, true)) { + currentApp.value?.let { repository.fetchMessages(it) } ?: repository.fetchMessages() + } + } + } +} + +/** + * A class representing the menu mode should be displayed in the messages screen. + */ +sealed class MenuMode + +/** + * A class representing the menu mode should be displayed in the messages screen. + */ +data object AllAppMenuMode : MenuMode() + +/** + * A class representing the menu mode should be displayed in the messages screen. + * + * @param app The application to display the messages of. + */ +data class AppMenuMode(val app: Application) : MenuMode() From 84d3edf4b646efe7986bc3e3dcf6b2b2a65fa406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Mon, 30 Sep 2024 23:43:16 +0200 Subject: [PATCH 7/9] Create a view to show number of unread messages --- .../main/res/drawable/nav_counter_background.xml | 5 +++++ app/src/main/res/layout/action_menu_counter.xml | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 app/src/main/res/drawable/nav_counter_background.xml create mode 100644 app/src/main/res/layout/action_menu_counter.xml diff --git a/app/src/main/res/drawable/nav_counter_background.xml b/app/src/main/res/drawable/nav_counter_background.xml new file mode 100644 index 00000000..2759d514 --- /dev/null +++ b/app/src/main/res/drawable/nav_counter_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/action_menu_counter.xml b/app/src/main/res/layout/action_menu_counter.xml new file mode 100644 index 00000000..e236feb2 --- /dev/null +++ b/app/src/main/res/layout/action_menu_counter.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file From 940b6efd58fc9e30d0d5b8089ab49cac025a7951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Mon, 30 Sep 2024 23:43:55 +0200 Subject: [PATCH 8/9] Refactor `MessageActivity` to use the Repository and show number of unread messages --- .../gotify/messages/MessagesActivity.kt | 440 ++++++------------ 1 file changed, 151 insertions(+), 289 deletions(-) diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt index 999577f1..8154235b 100644 --- a/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt @@ -1,15 +1,11 @@ package com.github.gotify.messages import android.app.NotificationManager -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.graphics.Canvas import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -17,37 +13,37 @@ import android.view.View import android.widget.ImageButton import android.widget.TextView import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener +import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import coil.request.ImageRequest +import com.github.gotify.ApplicationState import com.github.gotify.BuildConfig import com.github.gotify.CoilInstance -import com.github.gotify.MissedMessageUtil import com.github.gotify.R +import com.github.gotify.Repository +import com.github.gotify.Settings import com.github.gotify.Utils import com.github.gotify.Utils.launchCoroutine import com.github.gotify.api.Api import com.github.gotify.api.ApiException -import com.github.gotify.api.Callback import com.github.gotify.api.ClientFactory -import com.github.gotify.client.api.ApplicationApi import com.github.gotify.client.api.ClientApi -import com.github.gotify.client.api.MessageApi -import com.github.gotify.client.model.Application import com.github.gotify.client.model.Client import com.github.gotify.client.model.Message import com.github.gotify.databinding.ActivityMessagesBinding -import com.github.gotify.init.InitializationActivity import com.github.gotify.log.LogsActivity import com.github.gotify.login.LoginActivity import com.github.gotify.messages.provider.MessageState @@ -59,38 +55,60 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback import com.google.android.material.snackbar.Snackbar +import kotlin.math.min import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.tinylog.kotlin.Logger internal class MessagesActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { - private lateinit var binding: ActivityMessagesBinding - private lateinit var viewModel: MessagesModel - private var isLoadMore = false + private val binding: ActivityMessagesBinding by lazy { + ActivityMessagesBinding.inflate(layoutInflater) + } + private val settings: Settings by lazy { + Settings(this) + } + private val messagesViewModel: MessagesViewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T = MessagesViewModel( + Repository.create( + scope = lifecycleScope, + settings = settings + ).also { WebSocketService.repository = it } + ) as T + } + } private var updateAppOnDrawerClose: Long? = null - private lateinit var listMessageAdapter: ListMessageAdapter - private lateinit var onBackPressedCallback: OnBackPressedCallback - - private val receiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val messageJson = intent.getStringExtra("message") - val message = Utils.JSON.fromJson( - messageJson, - Message::class.java - ) - launchCoroutine { - addSingleMessage(message) + private val listMessageAdapter: ListMessageAdapter by lazy { + ListMessageAdapter( + this, + settings, + CoilInstance.get(this) + ) { message -> + shadowDelete(message) + } + } + private val onBackPressedCallback: OnBackPressedCallback by lazy { + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START) + } } + }.also { + onBackPressedDispatcher.addCallback(this, it) } } - + private val swipeRefreshLayout by lazy { + binding.swipeRefresh + } + private var menuMode: MenuMode = AllAppMenuMode override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityMessagesBinding.inflate(layoutInflater) setContentView(binding.root) - viewModel = ViewModelProvider(this, MessagesModelFactory(this))[MessagesModel::class.java] + Logger.info("Entering " + javaClass.simpleName) initDrawer() @@ -100,14 +118,8 @@ internal class MessagesActivity : messagesView.context, layoutManager.orientation ) - listMessageAdapter = ListMessageAdapter( - this, - viewModel.settings, - CoilInstance.get(this) - ) { message -> - scheduleDeletion(message) - } - addBackPressCallback() + + bindViewModel() messagesView.addItemDecoration(dividerItemDecoration) messagesView.setHasFixedSize(true) @@ -115,14 +127,9 @@ internal class MessagesActivity : messagesView.addOnScrollListener(MessageListOnScrollListener()) messagesView.adapter = listMessageAdapter - val appsHolder = viewModel.appsHolder - appsHolder.onUpdate { onUpdateApps(appsHolder.get()) } - if (appsHolder.wasRequested()) onUpdateApps(appsHolder.get()) else appsHolder.request() - val itemTouchHelper = ItemTouchHelper(SwipeToDeleteCallback(listMessageAdapter)) itemTouchHelper.attachToRecyclerView(messagesView) - val swipeRefreshLayout = binding.swipeRefresh swipeRefreshLayout.setOnRefreshListener { onRefresh() } binding.drawerLayout.addDrawerListener( object : SimpleDrawerListener() { @@ -131,20 +138,11 @@ internal class MessagesActivity : } override fun onDrawerClosed(drawerView: View) { - updateAppOnDrawerClose?.let { selectApp -> - updateAppOnDrawerClose = null - viewModel.appId = selectApp - launchCoroutine { - updateMessagesForApplication(true, selectApp) - } - invalidateOptionsMenu() - } onBackPressedCallback.isEnabled = false } } ) - swipeRefreshLayout.isEnabled = false messagesView .viewTreeObserver .addOnScrollChangedListener { @@ -159,8 +157,40 @@ internal class MessagesActivity : val excludeFromRecent = PreferenceManager.getDefaultSharedPreferences(this) .getBoolean(getString(R.string.setting_key_exclude_from_recent), false) Utils.setExcludeFromRecent(this, excludeFromRecent) - launchCoroutine { - updateMessagesForApplication(true, viewModel.appId) + } + + private fun bindViewModel() { + lifecycleScope.launch { + messagesViewModel.messagesWithImage.collectLatest { messages -> + listMessageAdapter.updateList(messages) + when (messages.isEmpty()) { + true -> binding.flipper.displayedChild = 1 + false -> binding.flipper.displayedChild = 0 + } + } + } + lifecycleScope.launch { + messagesViewModel.refreshing.collectLatest { refreshing -> + println("JcLog: refreshing $refreshing") + swipeRefreshLayout.isRefreshing = refreshing + } + } + lifecycleScope.launch { + messagesViewModel.applicationsState.collect { applications -> + println("JcLog: $applications") + println("JcLog: applications (${applications.size})") + lifecycleScope.launch(Dispatchers.Main) { onUpdateApps(applications) } + } + } + lifecycleScope.launch { + messagesViewModel.menuMode.collect { + menuMode = it + binding.appBarDrawer.toolbar.subtitle = when (it) { + is AllAppMenuMode -> "" + is AppMenuMode -> it.app.name + } + invalidateMenu() + } } } @@ -171,20 +201,12 @@ internal class MessagesActivity : private fun refreshAll() { CoilInstance.evict(this) - startActivity(Intent(this, InitializationActivity::class.java)) - finish() + messagesViewModel.onRefreshAll() } private fun onRefresh() { CoilInstance.evict(this) - viewModel.messages.clear() - launchCoroutine { - loadMore(viewModel.appId).forEachIndexed { index, message -> - if (message.image != null) { - listMessageAdapter.notifyItemChanged(index) - } - } - } + messagesViewModel.onRefreshMessages() } private fun openDocumentation() { @@ -192,31 +214,43 @@ internal class MessagesActivity : startActivity(browserIntent) } - private fun onUpdateApps(applications: List) { + private fun onUpdateApps(applicationsState: List) { val menu: Menu = binding.navView.menu menu.removeGroup(R.id.apps) - viewModel.targetReferences.clear() - updateMessagesAndStopLoading(viewModel.messages[viewModel.appId]) - var selectedItem = menu.findItem(R.id.nav_all_messages) - applications.indices.forEach { index -> - val app = applications[index] - val item = menu.add(R.id.apps, index, APPLICATION_ORDER, app.name) - item.isCheckable = true - if (app.id == viewModel.appId) selectedItem = item - val t = Utils.toDrawable { icon -> item.icon = icon } - viewModel.targetReferences.add(t) - val request = ImageRequest.Builder(this) - .data(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image)) - .error(R.drawable.ic_alarm) - .placeholder(R.drawable.ic_placeholder) - .size(100, 100) - .target(t) - .build() - CoilInstance.get(this).enqueue(request) + applicationsState.forEachIndexed { index, applicationState -> + menu.add( + R.id.apps, + index, + APPLICATION_ORDER, + applicationState.application.name + ).apply { + setOnMenuItemClickListener { + messagesViewModel.onSelectApplication(applicationState.application) + binding.drawerLayout.closeDrawer(GravityCompat.START) + true + } + if (applicationState.unreadCount > 0) { + setActionView(R.layout.action_menu_counter) + actionView?.findViewById( + R.id.counter + )?.text = applicationState.counterLabel + } + val request = ImageRequest.Builder(this@MessagesActivity) + .data(applicationState.iconUrl) + .error(R.drawable.ic_alarm) + .placeholder(R.drawable.ic_placeholder) + .size(100, 100) + .target(Utils.toDrawable { icon -> this.icon = icon }) + .build() + CoilInstance.get(this@MessagesActivity).enqueue(request) + } } - selectAppInMenu(selectedItem) } + private val ApplicationState.counterLabel: String + get() = min(unreadCount, 99) + .toString() + ("+".takeIf { hasMoreMessages || unreadCount > 99 } ?: "") + private fun initDrawer() { setSupportActionBar(binding.appBarDrawer.toolbar) binding.navView.itemIconTintList = null @@ -233,8 +267,6 @@ internal class MessagesActivity : binding.navView.setNavigationItemSelectedListener(this) val headerView = binding.navView.getHeaderView(0) - val settings = viewModel.settings - val user = headerView.findViewById(R.id.header_user) user.text = settings.user?.name @@ -249,29 +281,11 @@ internal class MessagesActivity : refreshAll.setOnClickListener { refreshAll() } } - private fun addBackPressCallback() { - onBackPressedCallback = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - binding.drawerLayout.closeDrawer(GravityCompat.START) - } - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - override fun onNavigationItemSelected(item: MenuItem): Boolean { - // Handle navigation view item clicks here. val id = item.itemId - if (item.groupId == R.id.apps) { - val app = viewModel.appsHolder.get()[id] - updateAppOnDrawerClose = app.id - startLoading() - binding.appBarDrawer.toolbar.subtitle = item.title - } else if (id == R.id.nav_all_messages) { + if (id == R.id.nav_all_messages) { updateAppOnDrawerClose = MessageState.ALL_MESSAGES - startLoading() - binding.appBarDrawer.toolbar.subtitle = "" + messagesViewModel.onDeselectApplication() } else if (id == R.id.logout) { MaterialAlertDialogBuilder(this) .setTitle(R.string.logout) @@ -298,98 +312,35 @@ internal class MessagesActivity : } } - private fun startLoading() { - binding.swipeRefresh.isRefreshing = true - binding.messagesView.visibility = View.GONE - } - - private fun stopLoading() { - binding.swipeRefresh.isRefreshing = false - binding.messagesView.visibility = View.VISIBLE - } - override fun onResume() { val context = applicationContext val nManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager nManager.cancelAll() - val filter = IntentFilter() - filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(receiver, filter, RECEIVER_EXPORTED) - } else { - registerReceiver(receiver, filter) - } - launchCoroutine { - updateMissedMessages(viewModel.messages.getLastReceivedMessage()) - } - var selectedIndex = R.id.nav_all_messages - val appId = viewModel.appId - if (appId != MessageState.ALL_MESSAGES) { - val apps = viewModel.appsHolder.get() - apps.indices.forEach { index -> - if (apps[index].id == appId) { - selectedIndex = index - } - } - } - // Force re-render of all items to update relative date-times on app resume. - listMessageAdapter.notifyDataSetChanged() - selectAppInMenu(binding.navView.menu.findItem(selectedIndex)) super.onResume() } - override fun onPause() { - unregisterReceiver(receiver) - super.onPause() - } - - private fun selectAppInMenu(appItem: MenuItem?) { - if (appItem != null) { - appItem.isChecked = true - if (appItem.itemId != R.id.nav_all_messages) { - binding.appBarDrawer.toolbar.subtitle = appItem.title - } - } - } - - private fun scheduleDeletion(message: Message) { - val adapter = binding.messagesView.adapter as ListMessageAdapter - val messages = viewModel.messages - messages.deleteLocal(message) - adapter.updateList(messages[viewModel.appId]) - showDeletionSnackbar() - } - - private fun undoDelete() { - val messages = viewModel.messages - val deletion = messages.undoDeleteLocal() - if (deletion != null) { - val adapter = binding.messagesView.adapter as ListMessageAdapter - val appId = viewModel.appId - adapter.updateList(messages[appId]) - } + private fun shadowDelete(message: Message) { + messagesViewModel.onShadowDeleteMessage(message) + showDeletionSnackbar(message) } - private fun showDeletionSnackbar() { + private fun showDeletionSnackbar(message: Message) { val view: View = binding.swipeRefresh val snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG) - snackbar.setAction(R.string.snackbar_undo) { undoDelete() } - snackbar.addCallback(SnackbarCallback()) + snackbar.setAction(R.string.snackbar_undo) { + messagesViewModel.onUnShadowDeleteMessage(message) + } + snackbar.addCallback( + SnackbarCallback(message) + ) snackbar.show() } - private inner class SnackbarCallback : BaseCallback() { + private inner class SnackbarCallback(private val message: Message) : BaseCallback() { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { super.onDismissed(transientBottomBar, event) - if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) { - // Execute deletion when the snackbar disappeared without pressing the undo button - // DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the - // deletion to be sent to the server twice, since the deletion is sent to the server - // in MessageFacade if a message is deleted while another message was already - // waiting for deletion. - launchCoroutine { - commitDeleteMessage() - } + if (event != DISMISS_EVENT_ACTION) { + messagesViewModel.onDeleteMessage(message) } } } @@ -399,7 +350,6 @@ internal class MessagesActivity : ) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { private var icon: Drawable? private val background: ColorDrawable - init { val backgroundColorId = ContextCompat.getColor(this@MessagesActivity, R.color.swipeBackground) @@ -422,7 +372,7 @@ internal class MessagesActivity : override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val position = viewHolder.adapterPosition val message = adapter.currentList[position] - scheduleDeletion(message.message) + shadowDelete(message.message) } override fun onChildDraw( @@ -486,36 +436,17 @@ internal class MessagesActivity : val lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition() val totalItemCount = view.adapter!!.itemCount if (lastVisibleItem > totalItemCount - 15 && - totalItemCount != 0 && - viewModel.messages.canLoadMore(viewModel.appId) + totalItemCount != 0 ) { - if (!isLoadMore) { - isLoadMore = true - launchCoroutine { - loadMore(viewModel.appId) - } - } + messagesViewModel.onLoadMore() } } } } - private suspend fun updateMissedMessages(id: Long) { - if (id == -1L) return - - val newMessages = MissedMessageUtil(viewModel.client.createService(MessageApi::class.java)) - .missingMessages(id).filterNotNull() - viewModel.messages.addMessages(newMessages) - - if (newMessages.isNotEmpty()) { - updateMessagesForApplication(true, viewModel.appId) - } - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.messages_action, menu) - menu.findItem(R.id.action_delete_app).isVisible = - viewModel.appId != MessageState.ALL_MESSAGES + menu.findItem(R.id.action_delete_app).isVisible = menuMode != AllAppMenuMode return super.onCreateOptionsMenu(menu) } @@ -525,84 +456,28 @@ internal class MessagesActivity : .setTitle(R.string.delete_all) .setMessage(R.string.ack) .setPositiveButton(R.string.yes) { _, _ -> - launchCoroutine { - deleteMessages(viewModel.appId) - } + messagesViewModel.deleteAllMessages(menuMode) } .setNegativeButton(R.string.no, null) .show() } - if (item.itemId == R.id.action_delete_app) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.delete_app) - .setMessage(R.string.ack) - .setPositiveButton(R.string.yes) { _, _ -> deleteApp(viewModel.appId) } - .setNegativeButton(R.string.no, null) - .show() - } - return super.onContextItemSelected(item) - } - - private fun deleteApp(appId: Long) { - val settings = viewModel.settings - val client = ClientFactory.clientToken(settings) - client.createService(ApplicationApi::class.java) - .deleteApp(appId) - .enqueue( - Callback.callInUI( - this, - onSuccess = { refreshAll() }, - onError = { Utils.showSnackBar(this, getString(R.string.error_delete_app)) } - ) - ) - } - - private suspend fun loadMore(appId: Long): List { - val messagesWithImages = viewModel.messages.loadMore(appId) - withContext(Dispatchers.Main) { - updateMessagesAndStopLoading(messagesWithImages) - } - return messagesWithImages - } - - private suspend fun updateMessagesForApplication(withLoadingSpinner: Boolean, appId: Long) { - if (withLoadingSpinner) { - withContext(Dispatchers.Main) { - startLoading() - } - } - viewModel.messages.loadMoreIfNotPresent(appId) - withContext(Dispatchers.Main) { - updateMessagesAndStopLoading(viewModel.messages[appId]) - } - } - - private suspend fun addSingleMessage(message: Message) { - viewModel.messages.addMessages(listOf(message)) - updateMessagesForApplication(false, viewModel.appId) - } - - private suspend fun commitDeleteMessage() { - viewModel.messages.commitDelete() - updateMessagesForApplication(false, viewModel.appId) - } - - private suspend fun deleteMessages(appId: Long) { - withContext(Dispatchers.Main) { - startLoading() - } - val success = viewModel.messages.deleteAll(appId) - if (success) { - updateMessagesForApplication(false, viewModel.appId) - } else { - withContext(Dispatchers.Main) { - Utils.showSnackBar(this@MessagesActivity, "Delete failed :(") + (menuMode as? AppMenuMode) + ?.let { appMenuMode -> + if (item.itemId == R.id.action_delete_app) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_app) + .setMessage(R.string.ack) + .setPositiveButton(R.string.yes) { _, _ -> + messagesViewModel.deleteApp(appMenuMode.app) + } + .setNegativeButton(R.string.no, null) + .show() + } } - } + return super.onContextItemSelected(item) } private fun deleteClientAndNavigateToLogin() { - val settings = viewModel.settings val api = ClientFactory.clientToken(settings).createService(ClientApi::class.java) stopService(Intent(this@MessagesActivity, WebSocketService::class.java)) try { @@ -623,24 +498,11 @@ internal class MessagesActivity : } catch (e: ApiException) { Logger.error(e, "Could not delete client") } - - viewModel.settings.clear() + settings.clear() startActivity(Intent(this@MessagesActivity, LoginActivity::class.java)) finish() } - private fun updateMessagesAndStopLoading(messageWithImages: List) { - isLoadMore = false - stopLoading() - if (messageWithImages.isEmpty()) { - binding.flipper.displayedChild = 1 - } else { - binding.flipper.displayedChild = 0 - } - val adapter = binding.messagesView.adapter as ListMessageAdapter - adapter.updateList(messageWithImages) - } - private fun ListMessageAdapter.updateList(list: List) { this.submitList(if (this.currentList == list) list.toList() else list) { val topChild = binding.messagesView.getChildAt(0) From c32c34cd026ec7da05e51780c9021ffc1b6e2ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Mon, 30 Sep 2024 23:52:41 +0200 Subject: [PATCH 9/9] Remove unneeded classes --- .../github/gotify/messages/MessagesModel.kt | 23 --- .../gotify/messages/MessagesModelFactory.kt | 19 --- .../messages/provider/MessageDeletion.kt | 9 -- .../gotify/messages/provider/MessageFacade.kt | 77 ---------- .../messages/provider/MessageImageCombiner.kt | 19 --- .../messages/provider/MessageRequester.kt | 49 ------- .../messages/provider/MessageStateHolder.kt | 131 ------------------ 7 files changed, 327 deletions(-) delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt delete mode 100644 app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt deleted file mode 100644 index e4ae8e4c..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.gotify.messages - -import android.app.Activity -import androidx.lifecycle.ViewModel -import coil.target.Target -import com.github.gotify.Settings -import com.github.gotify.api.ClientFactory -import com.github.gotify.client.api.MessageApi -import com.github.gotify.messages.provider.ApplicationHolder -import com.github.gotify.messages.provider.MessageFacade -import com.github.gotify.messages.provider.MessageState - -internal class MessagesModel(parentView: Activity) : ViewModel() { - val settings = Settings(parentView) - val client = ClientFactory.clientToken(settings) - val appsHolder = ApplicationHolder(parentView, client) - val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder) - - // we need to keep the target references otherwise they get gc'ed before they can be called. - val targetReferences = mutableListOf() - - var appId = MessageState.ALL_MESSAGES -} diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt deleted file mode 100644 index d22e8c4c..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.gotify.messages - -import android.app.Activity -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider - -internal class MessagesModelFactory( - var modelParameterActivity: Activity -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass == MessagesModel::class.java) { - @Suppress("UNCHECKED_CAST") - return modelClass.cast(MessagesModel(modelParameterActivity)) as T - } - throw IllegalArgumentException( - "modelClass parameter must be of type ${MessagesModel::class.java.name}" - ) - } -} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt deleted file mode 100644 index 14cc1e4c..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.gotify.messages.provider - -import com.github.gotify.client.model.Message - -internal class MessageDeletion( - val message: Message, - val allPosition: Int, - val appPosition: Int -) diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt deleted file mode 100644 index 3152f79e..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.github.gotify.messages.provider - -import com.github.gotify.client.api.MessageApi -import com.github.gotify.client.model.Message - -internal class MessageFacade(api: MessageApi, private val applicationHolder: ApplicationHolder) { - private val requester = MessageRequester(api) - private val state = MessageStateHolder() - - @Synchronized - operator fun get(appId: Long): List { - return MessageImageCombiner.combine(state.state(appId).messages, applicationHolder.get()) - } - - @Synchronized - fun addMessages(messages: List) { - messages.forEach { - state.newMessage(it) - } - } - - @Synchronized - fun loadMore(appId: Long): List { - val state = state.state(appId) - if (state.hasNext || !state.loaded) { - val pagedMessages = requester.loadMore(state) - if (pagedMessages != null) { - this.state.newMessages(appId, pagedMessages) - } - } - return get(appId) - } - - @Synchronized - fun loadMoreIfNotPresent(appId: Long) { - val state = state.state(appId) - if (!state.loaded) { - loadMore(appId) - } - } - - @Synchronized - fun clear() { - state.clear() - } - - fun getLastReceivedMessage(): Long = state.lastReceivedMessage - - @Synchronized - fun deleteLocal(message: Message) { - // If there is already a deletion pending, that one should be executed before scheduling the - // next deletion. - if (state.deletionPending()) commitDelete() - state.deleteMessage(message) - } - - @Synchronized - fun commitDelete() { - if (state.deletionPending()) { - val deletion = state.purgePendingDeletion() - requester.asyncRemoveMessage(deletion!!.message) - } - } - - @Synchronized - fun undoDeleteLocal(): MessageDeletion? = state.undoPendingDeletion() - - @Synchronized - fun deleteAll(appId: Long): Boolean { - val success = requester.deleteAll(appId) - state.deleteAll(appId) - return success - } - - @Synchronized - fun canLoadMore(appId: Long): Boolean = state.state(appId).hasNext -} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt deleted file mode 100644 index ae2b8bf7..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.gotify.messages.provider - -import com.github.gotify.client.model.Application -import com.github.gotify.client.model.Message - -internal object MessageImageCombiner { - fun combine(messages: List, applications: List): List { - val appIdToImage = appIdToImage(applications) - return messages.map { MessageWithImage(message = it, image = appIdToImage[it.appid]) } - } - - private fun appIdToImage(applications: List): Map { - val map = mutableMapOf() - applications.forEach { - map[it.id] = it.image - } - return map - } -} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt deleted file mode 100644 index d2eb3f28..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.gotify.messages.provider - -import com.github.gotify.api.Api -import com.github.gotify.api.ApiException -import com.github.gotify.api.Callback -import com.github.gotify.client.api.MessageApi -import com.github.gotify.client.model.Message -import com.github.gotify.client.model.PagedMessages -import org.tinylog.kotlin.Logger - -internal class MessageRequester(private val messageApi: MessageApi) { - fun loadMore(state: MessageState): PagedMessages? { - return try { - Logger.info("Loading more messages for ${state.appId}") - if (MessageState.ALL_MESSAGES == state.appId) { - Api.execute(messageApi.getMessages(LIMIT, state.nextSince)) - } else { - Api.execute(messageApi.getAppMessages(state.appId, LIMIT, state.nextSince)) - } - } catch (apiException: ApiException) { - Logger.error(apiException, "failed requesting messages") - null - } - } - - fun asyncRemoveMessage(message: Message) { - Logger.info("Removing message with id ${message.id}") - messageApi.deleteMessage(message.id).enqueue(Callback.call()) - } - - fun deleteAll(appId: Long): Boolean { - return try { - Logger.info("Deleting all messages for $appId") - if (MessageState.ALL_MESSAGES == appId) { - Api.execute(messageApi.deleteMessages()) - } else { - Api.execute(messageApi.deleteAppMessages(appId)) - } - true - } catch (e: ApiException) { - Logger.error(e, "Could not delete messages") - false - } - } - - companion object { - private const val LIMIT = 100 - } -} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt deleted file mode 100644 index d8966ba4..00000000 --- a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.github.gotify.messages.provider - -import com.github.gotify.client.model.Message -import com.github.gotify.client.model.PagedMessages -import kotlin.math.max - -internal class MessageStateHolder { - @get:Synchronized - var lastReceivedMessage = -1L - private set - private var states = mutableMapOf() - private var pendingDeletion: MessageDeletion? = null - - @Synchronized - fun clear() { - states = mutableMapOf() - } - - @Synchronized - fun newMessages(appId: Long, pagedMessages: PagedMessages) { - val state = state(appId) - - if (!state.loaded && pagedMessages.messages.size > 0) { - lastReceivedMessage = max(pagedMessages.messages[0].id, lastReceivedMessage) - } - - state.apply { - loaded = true - messages.addAll(pagedMessages.messages) - hasNext = pagedMessages.paging.next != null - nextSince = pagedMessages.paging.since - this.appId = appId - } - states[appId] = state - - // If there is a message with pending deletion, it should not reappear in the list in case - // it is added again. - if (deletionPending()) { - deleteMessage(pendingDeletion!!.message) - } - } - - @Synchronized - fun newMessage(message: Message) { - // If there is a message with pending deletion, its indices are going to change. To keep - // them consistent the deletion is undone first and redone again after adding the new - // message. - val deletion = undoPendingDeletion() - addMessage(message, 0, 0) - lastReceivedMessage = message.id - if (deletion != null) deleteMessage(deletion.message) - } - - @Synchronized - fun state(appId: Long): MessageState = states[appId] ?: emptyState(appId) - - @Synchronized - fun deleteAll(appId: Long) { - clear() - val state = state(appId) - state.loaded = true - states[appId] = state - } - - private fun emptyState(appId: Long): MessageState { - return MessageState().apply { - loaded = false - hasNext = false - nextSince = 0 - this.appId = appId - } - } - - @Synchronized - fun deleteMessage(message: Message) { - val allMessages = state(MessageState.ALL_MESSAGES) - val appMessages = state(message.appid) - var pendingDeletedAllPosition = -1 - var pendingDeletedAppPosition = -1 - - if (allMessages.loaded) { - val allPosition = allMessages.messages.indexOf(message) - if (allPosition != -1) allMessages.messages.removeAt(allPosition) - pendingDeletedAllPosition = allPosition - } - if (appMessages.loaded) { - val appPosition = appMessages.messages.indexOf(message) - if (appPosition != -1) appMessages.messages.removeAt(appPosition) - pendingDeletedAppPosition = appPosition - } - pendingDeletion = MessageDeletion( - message, - pendingDeletedAllPosition, - pendingDeletedAppPosition - ) - } - - @Synchronized - fun undoPendingDeletion(): MessageDeletion? { - if (pendingDeletion != null) { - addMessage( - pendingDeletion!!.message, - pendingDeletion!!.allPosition, - pendingDeletion!!.appPosition - ) - } - return purgePendingDeletion() - } - - @Synchronized - fun purgePendingDeletion(): MessageDeletion? { - val result = pendingDeletion - pendingDeletion = null - return result - } - - @Synchronized - fun deletionPending(): Boolean = pendingDeletion != null - - private fun addMessage(message: Message, allPosition: Int, appPosition: Int) { - val allMessages = state(MessageState.ALL_MESSAGES) - val appMessages = state(message.appid) - - if (allMessages.loaded && allPosition != -1) { - allMessages.messages.add(allPosition, message) - } - if (appMessages.loaded && appPosition != -1) { - appMessages.messages.add(appPosition, message) - } - } -}