From b0b4f5264cff9bd696caa47ce7950fa81d385c66 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 27 Feb 2026 13:51:57 -0500 Subject: [PATCH] Better update page --- app/build.gradle.kts | 2 + .../damontecres/stashapp/UpdateAppFragment.kt | 13 +- .../navigation/NavigationManagerCompose.kt | 5 - .../stashapp/ui/components/Dialogs.kt | 28 ++ .../stashapp/ui/nav/DestinationContent.kt | 9 + .../stashapp/ui/pages/UpdateAppPage.kt | 440 ++++++++++++++++++ .../stashapp/ui/util/DataLoadingState.kt | 21 + .../stashapp/util/UpdateChecker.kt | 23 +- gradle/libs.versions.toml | 3 + 9 files changed, 536 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/stashapp/ui/pages/UpdateAppPage.kt create mode 100644 app/src/main/java/com/github/damontecres/stashapp/ui/util/DataLoadingState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ec015b7f..33d0a3bff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -296,6 +296,8 @@ dependencies { implementation(libs.reword) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) + implementation(libs.multiplatform.markdown.renderer) + implementation(libs.multiplatform.markdown.renderer.m3) if (ffmpegModuleExists || isCI) { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) diff --git a/app/src/main/java/com/github/damontecres/stashapp/UpdateAppFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/UpdateAppFragment.kt index 995c84f94..84c349cf3 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/UpdateAppFragment.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/UpdateAppFragment.kt @@ -9,6 +9,7 @@ import androidx.leanback.widget.GuidanceStylist import androidx.leanback.widget.GuidedAction import androidx.lifecycle.lifecycleScope import com.github.damontecres.stashapp.navigation.Destination +import com.github.damontecres.stashapp.ui.pages.DownloadCallback import com.github.damontecres.stashapp.util.Release import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler import com.github.damontecres.stashapp.util.StashServer @@ -94,7 +95,17 @@ class UpdateAppFragment : GuidedStepSupportFragment() { ) }, ) { - UpdateChecker.installRelease(requireActivity(), release) + UpdateChecker.installRelease( + requireActivity(), + release, + object : DownloadCallback { + override fun contentLength(contentLength: Long) { + } + + override fun bytesDownloaded(bytes: Long) { + } + }, + ) } } else if (action.id == 1000L) { serverViewModel.navigationManager.navigate(Destination.ReleaseChangelog(release)) diff --git a/app/src/main/java/com/github/damontecres/stashapp/navigation/NavigationManagerCompose.kt b/app/src/main/java/com/github/damontecres/stashapp/navigation/NavigationManagerCompose.kt index f11d481ef..a0e37186f 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/navigation/NavigationManagerCompose.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/navigation/NavigationManagerCompose.kt @@ -9,7 +9,6 @@ import com.github.damontecres.stashapp.LicenseFragment import com.github.damontecres.stashapp.PinFragment import com.github.damontecres.stashapp.R import com.github.damontecres.stashapp.RootActivity -import com.github.damontecres.stashapp.UpdateAppFragment import com.github.damontecres.stashapp.UpdateChangelogFragment import com.github.damontecres.stashapp.setup.readonly.SettingsPinEntryFragment import com.github.damontecres.stashapp.ui.NavDrawerFragment @@ -81,10 +80,6 @@ class NavigationManagerCompose( // Destination.Setup -> SetupFragment() - is Destination.UpdateApp -> { - UpdateAppFragment() - } - is Destination.ReleaseChangelog -> { UpdateChangelogFragment() } diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/Dialogs.kt index 54b966b57..75420db5d 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/Dialogs.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.height @@ -27,6 +28,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -36,6 +38,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -303,3 +306,28 @@ fun MarkerDurationDialog( properties = DialogProperties(), ) } + +@Composable +fun BasicDialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + elevation: Dp = 8.dp, + content: @Composable () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Box( + modifier = + Modifier + .shadow(elevation = elevation, shape = RoundedCornerShape(8.dp)) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), + shape = RoundedCornerShape(8.dp), + ), + ) { + content() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/nav/DestinationContent.kt index f79795d1a..845d382f4 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/nav/DestinationContent.kt @@ -29,6 +29,7 @@ import com.github.damontecres.stashapp.ui.pages.SearchPage import com.github.damontecres.stashapp.ui.pages.SettingsPage import com.github.damontecres.stashapp.ui.pages.StudioPage import com.github.damontecres.stashapp.ui.pages.TagPage +import com.github.damontecres.stashapp.ui.pages.UpdateAppPage import com.github.damontecres.stashapp.util.StashServer import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -85,6 +86,14 @@ fun DestinationContent( // modifier = Modifier.fillMaxSize(), // ) + is Destination.UpdateApp -> { + UpdateAppPage( + composeUiConfig = composeUiConfig, + navigationManager = navManager, + modifier = modifier, + ) + } + is Destination.Settings -> { SettingsPage( server = server, diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/pages/UpdateAppPage.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/pages/UpdateAppPage.kt new file mode 100644 index 000000000..3a9abb9f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/pages/UpdateAppPage.kt @@ -0,0 +1,440 @@ +package com.github.damontecres.stashapp.ui.pages + +import android.Manifest +import android.content.Context +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.stashapp.navigation.NavigationManager +import com.github.damontecres.stashapp.ui.ComposeUiConfig +import com.github.damontecres.stashapp.ui.Material3AppTheme +import com.github.damontecres.stashapp.ui.compat.Button +import com.github.damontecres.stashapp.ui.components.BasicDialog +import com.github.damontecres.stashapp.ui.components.CircularProgress +import com.github.damontecres.stashapp.ui.util.DataLoadingState +import com.github.damontecres.stashapp.ui.util.ifElse +import com.github.damontecres.stashapp.util.Release +import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler +import com.github.damontecres.stashapp.util.UpdateChecker +import com.github.damontecres.stashapp.util.Version +import com.github.damontecres.stashapp.util.findActivity +import com.github.damontecres.stashapp.views.formatBytes +import com.mikepenz.markdown.m3.Markdown +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream + +class UpdateViewModel : + ViewModel(), + DownloadCallback { + val release = MutableLiveData>(DataLoadingState.Pending) + + val downloading = MutableLiveData(false) + val contentLength = MutableLiveData(-1) + val bytesDownloaded = MutableLiveData(-1) + + val currentVersion = MutableLiveData(null) + + fun init( + context: Context, + updateUrl: String, + ) { + release.value = DataLoadingState.Loading + viewModelScope.launch(Dispatchers.IO) { + try { + withContext(Dispatchers.Main) { + currentVersion.value = UpdateChecker.getInstalledVersion(context) + } + val release = UpdateChecker.getLatestRelease(context, updateUrl) + if (release != null) { + withContext(Dispatchers.Main) { + contentLength.value = -1 + bytesDownloaded.value = -1 + this@UpdateViewModel.release.value = DataLoadingState.Success(release) + } + } + } catch (ex: Exception) { + Log.e(TAG, "Exception during release check", ex) + this@UpdateViewModel.release.value = DataLoadingState.Error(ex) + } + } + } + + private var downloadJob: Job? = null + + fun installRelease( + context: Context, + release: Release, + ) { + downloadJob = + viewModelScope.launch( + Dispatchers.IO, + ) { + withContext(Dispatchers.Main) { + downloading.value = true + } + try { + UpdateChecker.installRelease( + context.findActivity()!!, + release, + this@UpdateViewModel, + ) + } catch (ex: Exception) { + Log.e(TAG, "Exception during install", ex) + withContext(Dispatchers.Main) { + this@UpdateViewModel.release.value = DataLoadingState.Error(ex) + } + } + withContext(Dispatchers.Main) { + downloading.value = false + } + } + } + + fun cancelDownload() { + viewModelScope.launch(Dispatchers.IO) { + downloadJob?.cancel() + withContext(Dispatchers.Main) { + downloading.value = false + contentLength.value = -1 + bytesDownloaded.value = -1 + } + } + } + + override fun contentLength(contentLength: Long) { + this@UpdateViewModel.contentLength.value = contentLength + } + + override fun bytesDownloaded(bytes: Long) { + this@UpdateViewModel.bytesDownloaded.value = bytes + } +} + +interface DownloadCallback { + fun contentLength(contentLength: Long) + + fun bytesDownloaded(bytes: Long) +} + +suspend fun copyTo( + input: InputStream, + out: OutputStream, + bufferSize: Int = 64 * 1024, + callback: DownloadCallback, +): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = input.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + withContext(Dispatchers.Main) { + callback.bytesDownloaded(bytesCopied) + } + bytes = input.read(buffer) + } + return bytesCopied +} + +private const val TAG = "UpdateAppPage" + +@Composable +fun UpdateAppPage( + composeUiConfig: ComposeUiConfig, + navigationManager: NavigationManager, + modifier: Modifier = Modifier, + viewModel: UpdateViewModel = viewModel(), +) { + val context = LocalContext.current + val release by viewModel.release.observeAsState(DataLoadingState.Pending) + val currentVersion by viewModel.currentVersion.observeAsState() + + val isDownloading by viewModel.downloading.observeAsState(false) + val contentLength by viewModel.contentLength.observeAsState(-1L) + val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1) + + LaunchedEffect(Unit) { + viewModel.init(context, composeUiConfig.preferences.updatePreferences.updateUrl) + } + var permissions by remember { mutableStateOf(UpdateChecker.hasPermissions(context)) } + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + if (isGranted) { + permissions = true + } else { + // TODO + } + } + when (val state = release) { + is DataLoadingState.Error -> { + Text( + "Error: ${state.localizedMessage}", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + CircularProgress() + } + + is DataLoadingState.Success -> { + InstallUpdatePageContent( + currentVersion = currentVersion, + release = state.data, + onInstallRelease = { + if (!permissions) { + launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + viewModel.installRelease(context, state.data) + } + }, + onCancel = { + navigationManager.goBack() + }, + modifier = + modifier.ifElse( + isDownloading, + Modifier + .alpha(.5f) + .blur(16.dp), + ), + ) + + if (isDownloading) { + DownloadDialog( + contentLength = contentLength, + bytesDownloaded = bytesDownloaded, + onDismissRequest = { + viewModel.cancelDownload() + }, + ) + } + } + } +} + +@Composable +fun InstallUpdatePageContent( + currentVersion: Version?, + release: Release, + onInstallRelease: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + val scrollAmount = 100f + val columnState = rememberLazyListState() + val scope = rememberCoroutineScope() + + fun scroll(reverse: Boolean = false) { + scope.launch(StashCoroutineExceptionHandler()) { + columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount) + } + } + val columnInteractionSource = remember { MutableInteractionSource() } + val columnFocused by columnInteractionSource.collectIsFocusedAsState() + val columnColor = + if (columnFocused) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + } + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .focusable(interactionSource = columnInteractionSource) + .fillMaxHeight() + .fillMaxWidth(.6f) + .background( + columnColor, + shape = RoundedCornerShape(16.dp), + ).onKeyEvent { + if (it.type == KeyEventType.KeyUp) { + return@onKeyEvent false + } + if (it.key == Key.DirectionDown) { + scroll(false) + return@onKeyEvent true + } + if (it.key == Key.DirectionUp) { + scroll(true) + return@onKeyEvent true + } + return@onKeyEvent false + }, + ) { + item { + Material3AppTheme { + Markdown( + (release.notes.joinToString("\n\n") + (release.body ?: "")) + .replace( + Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"), + "#$1", + ) + // Remove the last line for full changelog since its just a link + .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""), + ) + } + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).padding(16.dp), + ) { + Text( + text = "Update available", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "$currentVersion => " + release.version.toString(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Button( + onClick = onInstallRelease, + ) { + Text( + text = "Download and update", + ) + } + Button( + onClick = onCancel, + ) { + Text( + text = "Cancel", + ) + } + } + } +} + +@Composable +fun DownloadDialog( + contentLength: Long, + bytesDownloaded: Long, + onDismissRequest: () -> Unit, +) { + val progress = + if (contentLength > 0) { + bytesDownloaded.toFloat() / contentLength + } else { + null + } + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 6.dp, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + Text( + text = "Downloading", + fontSize = 24.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + if (progress != null) { + CircularProgressIndicator( + progress = { progress }, + color = MaterialTheme.colorScheme.border, + modifier = + Modifier + .size(48.dp) + .padding(8.dp), + ) + } else { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier + .size(48.dp) + .padding(8.dp), + ) + } + } + if (progress != null) { + val bytes = formatBytes(bytesDownloaded) + val size = formatBytes(contentLength) + Text( + text = "$bytes / $size", + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/util/DataLoadingState.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/util/DataLoadingState.kt new file mode 100644 index 000000000..c5cec1e93 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/util/DataLoadingState.kt @@ -0,0 +1,21 @@ +package com.github.damontecres.stashapp.ui.util + +sealed interface DataLoadingState { + data object Pending : DataLoadingState + + data object Loading : DataLoadingState + + data class Success( + val data: T, + ) : DataLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : DataLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/stashapp/util/UpdateChecker.kt index 2c1b3bf71..22d12edd4 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/util/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/util/UpdateChecker.kt @@ -18,6 +18,8 @@ import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager import com.github.damontecres.stashapp.R +import com.github.damontecres.stashapp.ui.pages.DownloadCallback +import com.github.damontecres.stashapp.ui.pages.copyTo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -168,6 +170,7 @@ class UpdateChecker { suspend fun installRelease( activity: Activity, release: Release, + callback: DownloadCallback, ) { withContext(Dispatchers.IO) { cleanup(activity) @@ -181,6 +184,9 @@ class UpdateChecker { client.newCall(request).execute().use { if (it.isSuccessful && it.body != null) { Log.v(TAG, "Request successful for ${release.downloadUrl}") + withContext(Dispatchers.Main) { + callback.contentLength(it.body!!.contentLength()) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = ContentValues().apply { @@ -200,7 +206,7 @@ class UpdateChecker { if (uri != null) { it.body!!.byteStream().use { input -> resolver.openOutputStream(uri).use { output -> - input.copyTo(output!!) + copyTo(input, output!!, callback = callback) } } @@ -240,7 +246,7 @@ class UpdateChecker { downloadDir.mkdirs() val targetFile = File(downloadDir, ASSET_NAME) targetFile.outputStream().use { output -> - it.body!!.byteStream().copyTo(output) + copyTo(it.body!!.byteStream(), output, callback = callback) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) @@ -314,6 +320,19 @@ class UpdateChecker { Log.e(TAG, "Exception during cleanup", ex) } } + + fun hasPermissions(context: Context): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87510bae1..2f24e1960 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ material3-adaptive = "1.0.0-alpha06" material3-android = "1.4.0" mockito-core = "5.21.0" mockito-kotlin = "6.2.2" +multiplatformMarkdownRenderer = "0.39.2" navigation-reimagined = "1.5.0" parcelable-core = "0.9.0" preference-ktx-version = "1.2.1" @@ -120,6 +121,8 @@ reword = { module = "dev.b3nedikt.reword:reword", version.ref = "reword" } viewpump = { module = "dev.b3nedikt.viewpump:viewpump", version.ref = "viewpump" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } zoomlayout = { module = "com.otaliastudios:zoomlayout", version.ref = "zoomlayout" } +multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } +multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }