From 6821ea27b6396c0f6d49b9292ddc7f221f345cf5 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:31:53 +0200 Subject: [PATCH 1/8] Fixed some settings bugs --- .../ui/settings/SettingsFragmentScreen.kt | 209 ++++++++---------- .../ui/settings/SettingsGeneralScreen.kt | 10 + 2 files changed, 105 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt index 39f825aca43..c906b098279 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt @@ -2,12 +2,10 @@ package com.lagradost.cloudstream3.ui.settings import android.annotation.SuppressLint import android.os.Bundle -import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade -import androidx.compose.foundation.border +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -15,10 +13,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll @@ -29,32 +27,36 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.SearchBarState -import androidx.compose.material3.SearchBarValue -import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -69,13 +71,11 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.TV -import com.lagradost.cloudstream4.compose.circle import com.lagradost.cloudstream4.compose.circleBorder import com.lagradost.cloudstream4.compose.focusOutline import com.lagradost.cloudstream4.compose.isLayout import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme import com.mihon.material.padding -import com.mihon.presentation.settings.Preference import com.mihon.presentation.settings.SearchableSettings import com.mihon.presentation.settings.SettingSearchResults import com.mihon.presentation.settings.SettingsData @@ -83,7 +83,6 @@ import com.mihon.presentation.settings.widget.TextPreferenceWidget import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -181,7 +180,6 @@ object SettingsFragmentScreen : Screen { @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") override fun Content() { val textFieldState = rememberTextFieldState() - val searchBarState = rememberSearchBarState() val outerListState = rememberScrollState() @@ -250,7 +248,7 @@ object SettingsFragmentScreen : Screen { } Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) - SettingsSearch(searchBarState = searchBarState, textFieldState = textFieldState) + SettingsSearch(textFieldState = textFieldState) Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) SettingSearchResults( @@ -290,109 +288,92 @@ object SettingsFragmentScreen : Screen { @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable - fun SettingsSearch(searchBarState: SearchBarState, textFieldState: TextFieldState) { - val scope = rememberCoroutineScope() - val inputField = - @Composable { - SearchBarDefaults.InputField( - textFieldState = textFieldState, - searchBarState = searchBarState, - modifier = Modifier.onFocusChanged { newFocus -> - if (newFocus.hasFocus) { - scope.launch { - searchBarState.animateToExpanded() - } - } - }, - onSearch = { scope.launch { searchBarState.animateToCollapsed() } }, - placeholder = { - Text( - modifier = Modifier.clearAndSetSemantics {}, - text = stringResource(R.string.search_hint) - ) - }, - leadingIcon = { - Crossfade( - targetState = searchBarState.targetValue, - label = "leftsearch", - ) { value -> - when (value) { - SearchBarValue.Expanded -> { - IconButton(onClick = { - textFieldState.edit { replace(0, length, "") } - scope.launch { - searchBarState.animateToCollapsed() - } - }) { - Icon( - painter = painterResource(R.drawable.keyboard_arrow_left_24px), - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null - ) - } - } - - SearchBarValue.Collapsed -> { - IconButton(onClick = { - scope.launch { - searchBarState.animateToExpanded() - } - }) { - Icon( - painter = painterResource(R.drawable.search_icon), - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null - ) - } - } - } - } - - /*SampleLeadingIcon(searchBarState, scope)*/ - }, - trailingIcon = { - Crossfade( - targetState = searchBarState.targetValue, - label = "rightsearch", - ) { value -> - when (value) { - SearchBarValue.Expanded -> { - IconButton(onClick = { - textFieldState.edit { replace(0, length, "") } - }) { - Icon( - painter = painterResource(R.drawable.close_24px), - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = null - ) - } - } - - SearchBarValue.Collapsed -> { - } - } - } - }, - ) - } - - Surface( + fun SettingsSearch(textFieldState : TextFieldState) { + var hasFocus by remember { mutableStateOf(false) } + val focusProgress by animateFloatAsState(targetValue = if (hasFocus) 1.0f else 0.0f) + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + TextField( + state = textFieldState, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Search, + // This option is bugged on compose, making it unable to open at all + // showKeyboardOnFocus = false + ), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp - 12.dp * searchBarState.progress) + .padding(horizontal = 24.dp - 12.dp * focusProgress) .focusOutline(enabled = isLayout(TV), CircleShape) - .onGloballyPositioned { searchBarState.collapsedCoords = it }, - shape = SearchBarDefaults.inputFieldShape, - color = MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = SearchBarDefaults.TonalElevation, - shadowElevation = SearchBarDefaults.ShadowElevation, - content = inputField, + .onFocusChanged { newFocus -> + hasFocus = newFocus.hasFocus + }.focusRequester(focusRequester), + placeholder = { + Text(text = stringResource(R.string.search_hint)) + }, + shape = CircleShape, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onBackground, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onBackground, + focusedTrailingIconColor = MaterialTheme.colorScheme.onBackground, + focusedLeadingIconColor = MaterialTheme.colorScheme.onBackground, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + cursorColor = MaterialTheme.colorScheme.onBackground, + ), + leadingIcon = { + Crossfade( + targetState = hasFocus, + label = "leftsearch", + ) { value -> + if (value) { + IconButton(onClick = { + textFieldState.edit { replace(0, length, "") } + focusManager.clearFocus() + }) { + Icon( + painter = painterResource(R.drawable.keyboard_arrow_left_24px), + contentDescription = null + ) + } + } else { + IconButton(onClick = { + focusRequester.requestFocus() + }) { + Icon( + painter = painterResource(R.drawable.search_icon), + contentDescription = null + ) + } + } + } + }, + trailingIcon = { + Crossfade( + targetState = hasFocus, + label = "rightsearch", + ) { value -> + if (value) { + IconButton(onClick = { + textFieldState.edit { replace(0, length, "") } + }) { + Icon( + painter = painterResource(R.drawable.close_24px), + contentDescription = null + ) + } + } + } + }, ) - BackHandler(enabled = searchBarState.targetValue == SearchBarValue.Expanded) { - textFieldState.edit { replace(0, length, "") } - scope.launch { - searchBarState.animateToCollapsed() + val keyboardController = LocalSoftwareKeyboardController.current + DisposableEffect(Unit) { + onDispose { + keyboardController?.hide() } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt index b24413c8de4..4c4bff7aef1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt @@ -43,6 +43,7 @@ import com.mihon.presentation.settings.Preference import com.mihon.presentation.settings.SearchableSettings import com.mihon.presentation.settings.collectAsState import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentMap object SettingsGeneralScreen : SearchableSettings { @Composable @@ -142,6 +143,15 @@ object SettingsGeneralScreen : SearchableSettings { preference = settings.provider.preferredMedia, entries = TvType.entries.associate { it.ordinal.toString() to stringResource(it.toStringRes()) + } + // Ok this looks strange af, but we do this to avoid double movie + .toPersistentMap().remove(TvType.AnimeMovie.ordinal.toString()), onValueChanged = { diff -> + if(diff.contains(TvType.Movie.ordinal.toString())) { + settings.provider.preferredMedia.set(diff + TvType.AnimeMovie.ordinal.toString()) + } else { + settings.provider.preferredMedia.set(diff - TvType.AnimeMovie.ordinal.toString()) + } + return@MultiSelectListPreference false }), )), From 068c099cafae74688b0f19c74c418814d5e758d2 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:52:57 +0200 Subject: [PATCH 2/8] Fixes for cross-platform networking --- desktopApp/build.gradle.kts | 4 +++ gradle/libs.versions.toml | 5 ++++ .../lagradost/cloudstream3/ParCollections.kt | 25 ++++++++++++++++++- shared/build.gradle.kts | 10 ++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0008a854d86..539c51d36f5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -11,11 +11,15 @@ kotlin { sourceSets { jvmMain.dependencies { implementation(libs.bundles.compose) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.kotlinx.collections.immutable) implementation(compose.desktop.currentOs) { // compose.desktop.currentOs imports the wrong material 2, so we exclude it exclude(group = "org.jetbrains.compose.material", module = "material") } implementation(project(":shared")) + implementation(project(":library")) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4652cf938c1..c6f0ee55ae2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,6 +79,7 @@ annotation = { module = "androidx.annotation:annotation", version.ref = "annotat appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroid" } @@ -103,12 +104,16 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIOCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt index 9e6cb99e5be..0f33a6ff6e8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt @@ -122,4 +122,27 @@ suspend fun runAllAsync( } } }.map { it.await() } -} \ No newline at end of file +} + +/** amap with stronger cancellation guarantee, aka will only return when each job is joined */ +@OptIn(DelicateCoroutinesApi::class) +@InternalAPI +@Throws +suspend fun Collection.cmap(f: suspend (A) -> B): List = + with(CoroutineScope(currentCoroutineContext())) { + // 1. Spawn all jobs + map { async { f(it) } }.map { deferred -> + // 2. Await all jobs without throwing, and join if canceled + try { + Result.success(deferred.await()) + } catch (e: CancellationException) { + withContext(NonCancellable) { + deferred.join() + } + Result.failure(e) + } catch (t : Throwable) { + Result.failure(t) + } + // 3. Throw if something goes wrong + }.map { it.getOrThrow() } + } \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 2bc9150d077..6bca3488e24 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { } commonMain.dependencies { + implementation(libs.coil.network.ktor3) implementation(libs.bundles.compose) implementation(libs.kotlinx.collections.immutable) implementation(project(":library")) @@ -41,6 +42,15 @@ kotlin { androidMain.dependencies { implementation(libs.activity.compose) implementation(libs.preference.ktx) + implementation(libs.ktor.client.android) + } + + appleMain.dependencies { + implementation(libs.ktor.client.darwin) + } + + jvmMain.dependencies { + implementation(libs.ktor.client.java) } } } From 07656a128371cdeb183c7dfd210053587b1096ba Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:22:16 +0200 Subject: [PATCH 3/8] Added new update logic --- app/src/main/AndroidManifest.xml | 4 + .../lagradost/cloudstream3/MainActivity.kt | 9 + .../cloudstream3/MainActivityScreen.kt | 250 ++++++++++++++++++ .../PackageInstallerStatusReceiver.kt | 28 ++ .../cloudstream3/ui/settings/ApkUpdater.kt | 170 ++++++++++++ .../ui/settings/GithubReleases.kt | 117 ++++++++ .../ui/settings/GithubViewModel.kt | 235 ++++++++++++++++ .../ui/settings/SettingsUpdatesScreen.kt | 11 +- app/src/main/res/layout/activity_main.xml | 5 + app/src/main/res/layout/activity_main_tv.xml | 5 + .../cloudstream4/DevicePreferenceStore.kt | 2 + .../cloudstream4/compose/Viewmodel.kt | 8 + 12 files changed, 839 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..1012efb56b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -253,6 +253,10 @@ + + Unit) { + if (updaterState.dialog == null) { + return + } + + val title: String + val body: @Composable () -> Unit + val confirmButton: @Composable () -> Unit + val dismissButton: @Composable () -> Unit + val cancelable: Boolean + + when (val state = updaterState.dialog.state) { + is GithubUpdateDialogState.Error -> { + // Only show error if it is initialized by the user + if (!updaterState.dialog.isFromUser) return + + cancelable = true + title = stringResource(R.string.download_failed) + body = { Text(text = state.error.getStackTracePretty()) } + confirmButton = { + WhiteButton(text = stringResource(R.string.check_for_update), onClick = { + onAction(SearchForUpdate) + }) + } + dismissButton = { + BlackButton(text = stringResource(R.string.ok), onClick = { + onAction(Dismiss) + }) + } + } + + GithubUpdateDialogState.Loading -> { + // Only show loading if it is initialized by the user + if (!updaterState.dialog.isFromUser) return + + cancelable = true + title = stringResource(R.string.loading) + body = { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + confirmButton = {} + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(Dismiss) + }) + } + } + + GithubUpdateDialogState.NoUpdateFound -> { + // Only show error if it is initialized by the user + if (!updaterState.dialog.isFromUser) return + + cancelable = true + title = stringResource(R.string.no_update_found) + body = {} + confirmButton = { + WhiteButton(text = stringResource(R.string.ok), onClick = { + onAction(Dismiss) + }) + } + dismissButton = {} + } + + is GithubUpdateDialogState.UpdateFound -> { + cancelable = true + title = stringResource( + R.string.new_update_format, + (state.oldSha ?: BuildConfig.VERSION_NAME), + (state.newSha ?: state.file.displayName) + ) + body = { Text(text = state.file.changeLog) } + confirmButton = { + WhiteButton(text = stringResource(R.string.update), onClick = { + onAction(Update(state.file)) + }) + } + dismissButton = { + BlackButton(text = stringResource(R.string.skip_update), onClick = { + onAction(SkipUpdate(state.file)) + onAction(Dismiss) + }) + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(Dismiss) + }) + } + } + + is GithubUpdateDialogState.DownloadProgress -> { + cancelable = false + title = stringResource(R.string.update_notification_downloading) + body = { + if (state.total != null) { + val progress = + (state.progress.toFloat() / state.total.toFloat()).coerceIn(0.0f, 1.0f) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + // Round down the progress to avoid 100% + text = "${(progress * 100.0f).toInt()}% (${ + formatFileSize( + LocalContext.current, + state.progress + ) + } / ${ + formatFileSize( + LocalContext.current, + state.total + ) + })" + ) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + progress = { progress } + ) + } + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + text = formatFileSize( + LocalContext.current, + state.progress + ) + ) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + confirmButton = {} + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(Dismiss) + }) + } + } + } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { + if (cancelable) { + onAction(Dismiss) + } + }, + title = { Text(text = title) }, + text = body, + confirmButton = confirmButton, + dismissButton = dismissButton + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt b/app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt new file mode 100644 index 00000000000..1739b81478a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import com.lagradost.cloudstream3.utils.getSafeParcelableExtra + +/** https://medium.com/@solrudev/painless-building-of-an-android-package-installer-app-d5a09b5df432 */ +class PackageInstallerStatusReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + intent.getSafeParcelableExtra(Intent.EXTRA_INTENT)?.let { userAction-> + userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(userAction) + } + } + PackageInstaller.STATUS_SUCCESS -> { + // do something on success + } + else -> { + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + println("PackageInstallerStatusReceiver: status=$status, message=$message") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt new file mode 100644 index 00000000000..fb6d1d928dd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -0,0 +1,170 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import androidx.core.content.FileProvider +import com.fleeksoft.io.OutputStream +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.receivers.PackageInstallerStatusReceiver +import com.lagradost.cloudstream4.AppSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import java.io.File +import java.io.InputStream + +object ApkUpdater : AppUpdater { + private const val APP_UPDATE_NAME = "CloudStream" + private const val APP_UPDATE_SUFFIX = "apk" + + @Throws + override suspend fun update( + settings: AppSettings, + url: String, + downloadProgress: (Long, Long?) -> Unit + ) { + val activity = CommonActivity.activity ?: throw ErrorLoadingException("No activity found") + clearOldFiles(activity) + + val request = app.get(url) + val length = request.size + val body = request.body + body.use { body -> + val length = length ?: body.contentLength() + + when (settings.updates.apkInstaller.get()) { + 0 -> { + packageInstallerDownloader(activity, body, length, downloadProgress) + } + + else -> { + legacyDownloader(activity, body, length, downloadProgress) + } + } + } + } + + fun clearOldFiles(activity: Activity) { + // Delete old files + activity.cacheDir.listFiles()?.filter { + it.name.startsWith(APP_UPDATE_NAME) && it.extension == APP_UPDATE_SUFFIX + }?.forEach { + deleteFileOnExit(it) + } + } + /** https://medium.com/@solrudev/painless-building-of-an-android-package-installer-app-d5a09b5df432 */ + @SuppressLint("RequestInstallPackagesPolicy") + @Throws + suspend fun packageInstallerDownloader( + activity: Activity, + body: ResponseBody, + length: Long?, + downloadProgress: (Long, Long?) -> Unit + ) = withContext(Dispatchers.IO) { + var sessionId: Int? = null + val packageInstaller = activity.packageManager.packageInstaller + try { + val installParams = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + if (length != null) { + installParams.setSize(length) + } + + sessionId = packageInstaller.createSession(installParams) + val session = packageInstaller.openSession(sessionId) + val readStream = body.byteStream() + + // We do not need to buffer this because transfer has large writes + session.openWrite(activity.packageName, 0, length ?: -1L) + .use { writeStream -> + transfer(writeStream, readStream, length, downloadProgress) + session.fsync(writeStream) + } + + val receiverIntent = Intent(activity, PackageInstallerStatusReceiver::class.java) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val receiverPendingIntent = PendingIntent.getBroadcast(activity, 0, receiverIntent, flags) + + // Avoid delayed updates, and just commit instantly + session.commit(receiverPendingIntent.intentSender) + session.close() + } catch (t: Throwable) { + sessionId?.let { sessionId -> + packageInstaller.abandonSession(sessionId) + } + throw t + } + } + + @Throws + suspend fun transfer( + writeStream: OutputStream, + readStream: InputStream, + length: Long?, + downloadProgress: (Long, Long?) -> Unit + ) = withContext(Dispatchers.IO) { + val context = currentCoroutineContext() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var transferred: Long = 0 + while ((readStream.read(buffer, 0, DEFAULT_BUFFER_SIZE) + .also { read = it }) >= 0 + ) { + writeStream.write(buffer, 0, read) + transferred += read.toLong() + downloadProgress(transferred, length) + context.ensureActive() + } + writeStream.flush() + } + + @Throws + suspend fun legacyDownloader( + activity: Activity, + body: ResponseBody, + length: Long?, + downloadProgress: (Long, Long?) -> Unit + ) = withContext(Dispatchers.IO) { + val downloadedFile = File.createTempFile(APP_UPDATE_NAME, ".$APP_UPDATE_SUFFIX") + val readStream = body.byteStream() + + // We do not need to buffer this because transfer has large writes + downloadedFile.outputStream().use { writeStream -> + transfer(writeStream, readStream, length, downloadProgress) + } + + openApk(activity, downloadedFile) + } + + fun openApk(context: Context, file: File) { + val contentUri = FileProvider.getUriForFile( + context, BuildConfig.APPLICATION_ID + ".provider", file + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt new file mode 100644 index 00000000000..94e0ebf4a60 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt @@ -0,0 +1,117 @@ +package com.lagradost.cloudstream3.ui.settings + +import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.jvm.Throws + +object GithubReleases { + @Serializable + private data class GithubAsset( + @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("size") @SerialName("size") val size: Int, // Size in bytes + @JsonProperty("browser_download_url") @SerialName("browser_download_url") val browserDownloadUrl: String, + @JsonProperty("content_type") @SerialName("content_type") val contentType: String, // application/vnd.android.package-archive + @JsonProperty("digest") @SerialName("digest") val digest: String? = null, // sha256:..., may be null + ) + + @Serializable + private data class GithubRelease( + @JsonProperty("tag_name") @SerialName("tag_name") val tagName: String, // Version code + @JsonProperty("body") @SerialName("body") val body: String, // Description + @JsonProperty("assets") @SerialName("assets") val assets: List, + @JsonProperty("target_commitish") @SerialName("target_commitish") val targetCommitish: String, // Branch + @JsonProperty("prerelease") @SerialName("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") @SerialName("node_id") val nodeId: String, + @JsonProperty("created_at") @SerialName("created_at") val createdAt: String, // YYYY-MM-DDTHH:MM:SSZ + ) + + @Serializable + private data class GithubObject( + @JsonProperty("sha") @SerialName("sha") val sha: String, // SHA-256 hash + //@JsonProperty("type") @SerialName("type") val type: String, + ///@JsonProperty("url") @SerialName("url") val url: String, + ) + + @Serializable + private data class GithubTag( + //@JsonProperty("node_id") @SerialName("node_id") val nodeId: String, + @JsonProperty("object") @SerialName("object") val githubObject: GithubObject, + ) + + /** GitHub file update package */ + @Immutable + data class GithubFile( + /** File digest, sha:xxx */ + val digest: String?, + /** File url for download */ + val downloadUrl: String, + /** Filename without the extension */ + val displayName: String, + /** Changelog, aka the commit message */ + val changeLog: String, + /** Name of the tag, aka unique release name like vX.X.X or pre-release */ + val tagName: String, + /** Unique node id */ + val nodeId: String, + ) + + private val defaultHeaders = mapOf("Accept" to "application/vnd.github.v3+json") + + @Throws + suspend fun getShaFromTag( + userName: String, + repository: String, + tag: String, + ): String { + return app.get( + url = "https://api.github.com/repos/$userName/$repository/git/ref/tags/$tag", + headers = defaultHeaders + ).parsed().githubObject.sha + } + + @Throws + suspend fun getLatestReleaseFile( + userName: String, + repository: String, + prerelease: Boolean, + prereleaseTag: String, + contentType: String, + ): GithubFile? { + val latestReleaseUrl = if (prerelease) { + // Find the release object + // https://docs.github.com/en/rest/releases/releases?apiVersion=2026-03-10#get-a-release-by-tag-name + "https://api.github.com/repos/$userName/$repository/releases/tags/$prereleaseTag" + } else { + // Just get the latest release + // https://docs.github.com/en/rest/releases/releases?apiVersion=2026-03-10#get-the-latest-release + // The latest release is the most recent non-prerelease, non-draft release, sorted by the created_at attribute + "https://api.github.com/repos/$userName/$repository/releases/latest" + } + + val latestRelease = app.get( + url = latestReleaseUrl, + headers = defaultHeaders + ).parsed() + + // Find the first correct asset, given that we might have other binaries we release in the same version + val foundAsset = latestRelease.assets.firstOrNull { asset -> + asset.contentType == contentType + } + + if (foundAsset == null) { + return null + } + + return GithubFile( + digest = foundAsset.digest, + downloadUrl = foundAsset.browserDownloadUrl, + displayName = foundAsset.name.substringBeforeLast("."), + changeLog = latestRelease.body, + tagName = latestRelease.tagName, + nodeId = latestRelease.nodeId + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt new file mode 100644 index 00000000000..baf1f6fe978 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt @@ -0,0 +1,235 @@ +package com.lagradost.cloudstream3.ui.settings + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.SingleActiveQuery +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.FileNotFoundException + +@Immutable +data class GithubState( + val dialog: GithubDialog? = null, +) + +sealed class GithubUpdateDialogState { + data class Error(val error: Throwable) : GithubUpdateDialogState() + data class DownloadProgress(val progress: Long, val total: Long?) : GithubUpdateDialogState() + object Loading : GithubUpdateDialogState() + object NoUpdateFound : GithubUpdateDialogState() + data class UpdateFound( + val file: GithubReleases.GithubFile, + val newSha: String?, + val oldSha: String?, + ) : GithubUpdateDialogState() +} + +@Immutable +data class GithubDialog( + val isPrerelease: Boolean, + val isFromUser: Boolean, + val state: GithubUpdateDialogState, +) + +sealed class GithubAction { + object AutoSearchForUpdate : GithubAction() + object SearchForUpdate : GithubAction() + object Dismiss : GithubAction() + data class SkipThisUpdate(val file: GithubReleases.GithubFile) : GithubAction() + data class Update(val file: GithubReleases.GithubFile) : GithubAction() + data class SkipUpdate(val file: GithubReleases.GithubFile) : GithubAction() +} + +const val APK_USERNAME = "recloudstream" +const val APK_REPOSITORY = "cloudstream" +const val APK_PRERELEASE = "pre-release" +const val APK_CONTENT_TYPE = "application/vnd.android.package-archive" + +interface AppUpdater { + @Throws + suspend fun update( + settings: AppSettings, + url: String, + downloadProgress: (Long, Long?) -> Unit + ) +} + +class GithubViewModel( + val remoteUserName: String, + val remoteRepository: String, + val remotePrereleaseTag: String, + val remoteContentType: String, + val versionName: String, + val isPrerelease: Boolean, + val isDebug: Boolean, + val buildSha: String, + val settings: AppSettings, + val updater: AppUpdater, +) : ViewModel(), StateContainer by DefaultStateContainer(GithubState()), + ActionHandler { + val updateDispatcher = SingleActiveQuery(Dispatchers.IO) + + override fun onAction(action: GithubAction) { + when (action) { + GithubAction.SearchForUpdate -> { + ioSafe { + searchForUpdate(prerelease = isPrerelease, fromUser = true) + } + } + + GithubAction.Dismiss -> { + viewModelScope.launch { + updateDispatcher.cancel() + updateState { copy(dialog = null) } + } + } + + is GithubAction.SkipThisUpdate -> { + settings.updates.skipUpdate.set(action.file.nodeId) + } + + GithubAction.AutoSearchForUpdate -> { + if (!isDebug && settings.updates.showAppUpdates.get()) { + ioSafe { + searchForUpdate(prerelease = isPrerelease, fromUser = false) + } + } + } + + is GithubAction.SkipUpdate -> { + settings.updates.skipUpdate.set(action.file.nodeId) + } + + is GithubAction.Update -> { + ioSafe { + installUpdate(action.file.downloadUrl) + } + } + } + } + + /** Cancel the old update, and catch possible errors from the block and show as a new state */ + suspend fun dispatchUpdate(block: /* @Throws */ suspend () -> Unit) { + updateDispatcher.launch { + try { + block() + } catch (t: Throwable) { + // If it was canceled ignore it as we probably launched another update check + if (!isActive) { + return@launch + } + // Otherwise we display the error + updateState { + copy(dialog = dialog?.copy(state = GithubUpdateDialogState.Error(t))) + } + } + } + } + + suspend fun installUpdate(url: String) = dispatchUpdate { + updater.update(settings = settings, url = url) { progress, total -> + updateState { + copy( + dialog = dialog?.copy( + state = GithubUpdateDialogState.DownloadProgress( + progress = progress, + total = total + ) + ) + ) + } + } + updateState { + copy( + dialog = null + ) + } + } + + suspend fun searchForUpdate( + prerelease: Boolean, + fromUser: Boolean, + ) = dispatchUpdate { + val baseDialog = GithubDialog( + isPrerelease = prerelease, + isFromUser = fromUser, + state = GithubUpdateDialogState.Loading + ) + + updateState { + copy(dialog = baseDialog) + } + + // If on pre-release check if the sha matches, as we do not look at the version + var oldSha: String? = null + var newSha: String? = null + if (prerelease) { + val sha = getSha(remotePrereleaseTag) + oldSha = buildSha.take(7) + newSha = sha.take(7) + + // Only match the first 7 chars, as that is what is saved + if (oldSha == newSha) { + updateState { + copy(dialog = baseDialog.copy(state = GithubUpdateDialogState.NoUpdateFound)) + } + return@dispatchUpdate + } + } + + val release = getRelease(prerelease) + + // If on stable, only check that the display name matches + if (!prerelease && release.displayName == versionName) { + updateState { + copy(dialog = baseDialog.copy(state = GithubUpdateDialogState.NoUpdateFound)) + } + return@dispatchUpdate + } + + // If this was automated, and we have pressed "skip this update" then check the node-id + if (!fromUser && release.nodeId == settings.updates.skipUpdate.get()) { + updateState { + copy(dialog = baseDialog.copy(state = GithubUpdateDialogState.NoUpdateFound)) + } + return@dispatchUpdate + } + + updateState { + copy( + dialog = baseDialog.copy( + state = GithubUpdateDialogState.UpdateFound( + file = release, + newSha = newSha, + oldSha = oldSha + ) + ) + ) + } + } + + @Throws + private suspend fun getRelease(prerelease: Boolean) = + GithubReleases.getLatestReleaseFile( + prerelease = prerelease, + userName = remoteUserName, + repository = remoteRepository, + prereleaseTag = remotePrereleaseTag, + contentType = remoteContentType + ) ?: throw FileNotFoundException() + + @Throws + private suspend fun getSha(tag: String) = + GithubReleases.getShaFromTag( + tag = tag, + userName = remoteUserName, + repository = remoteRepository, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt index 3c90e8e3684..091a645d6d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Intent import android.net.Uri -import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -18,7 +17,7 @@ import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.activity -import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivityScreen import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PluginManager @@ -26,7 +25,6 @@ import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.AppSettings import com.lagradost.cloudstream4.rememberAppSettings @@ -81,6 +79,8 @@ object SettingsUpdatesScreen : SearchableSettings { } } + val githubViewModel = MainActivityScreen.githubViewModel() + return persistentListOf( Preference.PreferenceGroup( title = stringResource(R.string.pref_category_app_updates), @@ -90,7 +90,8 @@ object SettingsUpdatesScreen : SearchableSettings { subtitle = BuildConfig.VERSION_NAME, icon = painterResource(R.drawable.mobile_arrow_down_24px), onClick = { - ioSafe { + githubViewModel?.onAction(GithubAction.SearchForUpdate) + /*ioSafe { if (activity?.runAutoUpdate(false) == false) { activity?.runOnUiThread { showToast( @@ -99,7 +100,7 @@ object SettingsUpdatesScreen : SearchableSettings { ) } } - } + }*/ } ), Preference.PreferenceItem.TextPreference( diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2483a37145c..eb7c67145b3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -82,4 +82,9 @@ tools:ignore="FragmentTagUsage" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main_tv.xml b/app/src/main/res/layout/activity_main_tv.xml index c51d77d162b..0d81285a62f 100644 --- a/app/src/main/res/layout/activity_main_tv.xml +++ b/app/src/main/res/layout/activity_main_tv.xml @@ -93,6 +93,11 @@ + + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index c0f784fc13f..168b8259028 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -45,6 +45,8 @@ class BackupPreferences(preferences: PreferenceStore) { class UpdatePreferences(preferences: PreferenceStore) { val apkInstaller = preferences.getInt("apk_installer_key",1) val showAppUpdates = preferences.getBoolean("auto_update", true) + // Node id for this update + val skipUpdate = preferences.getString("skip_update_key", "") } class SecurityPreferences(preferences: PreferenceStore) { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt index 8255b426023..1320fcf8e68 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt @@ -106,6 +106,14 @@ data class SingleActiveQuery( private var job: Job? = null, private val mutex: Mutex = Mutex(), ) { + suspend fun cancel() { + mutex.withLock { + job?.cancel() + job?.join() + job = null + } + } + suspend fun launch(block: suspend CoroutineScope.() -> Unit) { val currentScope = CoroutineScope(currentCoroutineContext()) val obj: suspend CoroutineScope.() -> Unit = { From 1cb781e3a3752ddd99cca5cb7b6bd74e4900bc32 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:44:54 +0200 Subject: [PATCH 4/8] Minor fixes + docs --- .../cloudstream3/ui/settings/ApkUpdater.kt | 2 +- .../ui/settings/GithubViewModel.kt | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index fb6d1d928dd..8c321bb7f6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.content.pm.PackageInstaller import android.os.Build import androidx.core.content.FileProvider -import com.fleeksoft.io.OutputStream import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.ErrorLoadingException @@ -23,6 +22,7 @@ import kotlinx.coroutines.withContext import okhttp3.ResponseBody import java.io.File import java.io.InputStream +import java.io.OutputStream object ApkUpdater : AppUpdater { private const val APP_UPDATE_NAME = "CloudStream" diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt index baf1f6fe978..a472751c880 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt @@ -19,6 +19,7 @@ data class GithubState( val dialog: GithubDialog? = null, ) +@Immutable sealed class GithubUpdateDialogState { data class Error(val error: Throwable) : GithubUpdateDialogState() data class DownloadProgress(val progress: Long, val total: Long?) : GithubUpdateDialogState() @@ -38,6 +39,7 @@ data class GithubDialog( val state: GithubUpdateDialogState, ) +@Immutable sealed class GithubAction { object AutoSearchForUpdate : GithubAction() object SearchForUpdate : GithubAction() @@ -61,6 +63,20 @@ interface AppUpdater { ) } +/** + * Cross-platform downloader for updates served by GitHub. + * + * To allow this Cross-platform behavior work we split up the UI from the Viewmodel, + * and the Viewmodel from the installer. + * ``` + * UI: Renders the current viewmodel state + * Viewmodel: Searches for updates on GitHub + * AppUpdater: Installs the update from the raw GitHub url + * + * UI -(onAction)-> Viewmodel -(invokes)-> AppUpdater + * <---(state)---/ <--(callback)--/ + * ``` + * */ class GithubViewModel( val remoteUserName: String, val remoteRepository: String, @@ -74,7 +90,7 @@ class GithubViewModel( val updater: AppUpdater, ) : ViewModel(), StateContainer by DefaultStateContainer(GithubState()), ActionHandler { - val updateDispatcher = SingleActiveQuery(Dispatchers.IO) + private val updateDispatcher = SingleActiveQuery(Dispatchers.IO) override fun onAction(action: GithubAction) { when (action) { @@ -116,7 +132,7 @@ class GithubViewModel( } /** Cancel the old update, and catch possible errors from the block and show as a new state */ - suspend fun dispatchUpdate(block: /* @Throws */ suspend () -> Unit) { + private suspend fun dispatchUpdate(block: /* @Throws */ suspend () -> Unit) { updateDispatcher.launch { try { block() @@ -133,7 +149,7 @@ class GithubViewModel( } } - suspend fun installUpdate(url: String) = dispatchUpdate { + private suspend fun installUpdate(url: String) = dispatchUpdate { updater.update(settings = settings, url = url) { progress, total -> updateState { copy( @@ -153,7 +169,7 @@ class GithubViewModel( } } - suspend fun searchForUpdate( + private suspend fun searchForUpdate( prerelease: Boolean, fromUser: Boolean, ) = dispatchUpdate { From e01ba9f9631ce73c37a21707f153f214077578e6 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:36:19 +0200 Subject: [PATCH 5/8] Added color wheel picker --- .../composeResources/values/strings.xml | 1 + .../cloudstream4/compose/ColorDialog.kt | 108 +++++---- .../colorpicker/CircularColorPicker.kt | 212 ++++++++++++++++++ .../colorpicker/ColorPickerDefaults.kt | 64 ++++++ .../compose/colorpicker/DoubleColorPicker.kt | 208 +++++++++++++++++ .../compose/colorpicker/HsvColor.kt | 170 ++++++++++++++ .../cloudstream4/compose/colorpicker/LICENSE | 21 ++ .../compose/colorpicker/README.md | 1 + .../compose/colorpicker/RingColorPicker.kt | 200 +++++++++++++++++ .../compose/colorpicker/SquareColorPicker.kt | 141 ++++++++++++ 10 files changed, 1087 insertions(+), 39 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 6d81554c1ab..e5eca5dd287 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -7,6 +7,7 @@ Transparency Luminance Color + Color Wheel Navigate up Lorem ipsum Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt index a89085556bb..c1abc206dd9 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -34,9 +35,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import com.lagradost.cloudstream4.compose.colorpicker.DoubleColorPicker +import com.lagradost.cloudstream4.compose.colorpicker.HsvColor import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.cancel import com.lagradost.cloudstream4.generated.resources.color +import com.lagradost.cloudstream4.generated.resources.color_wheel import com.lagradost.cloudstream4.generated.resources.luminance import com.lagradost.cloudstream4.generated.resources.ok import com.lagradost.cloudstream4.generated.resources.transparency @@ -84,7 +88,9 @@ fun ColorDialog( confirm: (Color) -> Unit, ) { var alpha by remember { mutableFloatStateOf(color.alpha) } - var selectedColor by remember { mutableStateOf(color) } + var selectedColor by remember { mutableStateOf(HsvColor(color)) } + val renderedColor = selectedColor.toColor().copy(alpha = alpha) + var colorWheel by remember { mutableStateOf(false) } AlertDialog( properties = DialogProperties(usePlatformDefaultWidth = false), @@ -93,7 +99,7 @@ fun ColorDialog( title = { Row(verticalAlignment = Alignment.CenterVertically) { ColorCircle( - color = selectedColor.copy(alpha = alpha), + color = renderedColor, ) Spacer(modifier = Modifier.size(MaterialTheme.padding.medium)) Text(text = title) @@ -111,50 +117,70 @@ fun ColorDialog( modifier = Modifier.padding(MaterialTheme.padding.extraSmall), style = MaterialTheme.typography.titleMedium ) - FlowRow { - ColorCircle( - color = Color.White.copy(alpha = alpha), + if (colorWheel) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(10.dp) ) { - selectedColor = Color.White + Spacer(modifier = Modifier.weight(1.0f)) + DoubleColorPicker( + color = { selectedColor }, + onColorChange = { selectedColor = it }, + modifier = Modifier.size(200.dp), + ringStrokeWidth = 20.dp, + ) + Spacer(modifier = Modifier.weight(1.0f)) } - - val hsl = floatArrayOf(0.0f, 1.0f, 0.5f) - (0..18).forEach { hue -> - hsl[0] = hue.toFloat() * 18f - val color = hslToColor(hsl) + } else { + FlowRow { ColorCircle( - color = color.copy(alpha = alpha), + color = Color.White.copy(alpha = alpha), ) { - selectedColor = color + selectedColor = HsvColor(Color.White) } - } - colors2.forEach { color -> - ColorCircle( - color = color.copy(alpha = alpha), - ) { - selectedColor = color + + val hsl = floatArrayOf(0.0f, 1.0f, 0.5f) + (0..18).forEach { hue -> + hsl[0] = hue.toFloat() * 18f + val color = hslToColor(hsl) + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = HsvColor(color) + } + } + colors2.forEach { color -> + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = HsvColor(color) + } } } - } + Text( + text = stringResource(Res.string.luminance), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(MaterialTheme.padding.extraSmall), + style = MaterialTheme.typography.titleMedium + ) - Text( - text = stringResource(Res.string.luminance), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(MaterialTheme.padding.extraSmall), - style = MaterialTheme.typography.titleMedium - ) - - // https://github.com/mhssn95/compose-color-picker/blob/main/colorPicker/src/main/java/io/mhssn/colorpicker/ext/drawExt.kt - Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { - val hsl = floatArrayOf(0.0f, 0.0f, 0.0f) - (0..10).forEach { luminance -> - rbgToHSL(selectedColor.red, selectedColor.green, selectedColor.blue, hsl) - hsl[2] = luminance.toFloat() * 0.1f - val color = hslToColor(hsl) - ColorCircle( - color = color.copy(alpha = alpha), - ) { - selectedColor = color + // https://github.com/mhssn95/compose-color-picker/blob/main/colorPicker/src/main/java/io/mhssn/colorpicker/ext/drawExt.kt + Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { + val hsl = floatArrayOf(0.0f, 0.0f, 0.0f) + (0..10).forEach { luminance -> + rbgToHSL( + selectedColor.red, + selectedColor.green, + selectedColor.blue, + hsl + ) + hsl[2] = luminance.toFloat() * 0.1f + val color = hslToColor(hsl) + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = HsvColor(color) + } } } } @@ -180,9 +206,13 @@ fun ColorDialog( confirmButton = { WhiteButton( text = stringResource(Res.string.ok), - onClick = { confirm(selectedColor.copy(alpha = alpha)) }) + onClick = { confirm(renderedColor) }) }, dismissButton = { + BlackButton(text = stringResource(Res.string.color_wheel), onClick = { + colorWheel = !colorWheel + }) + BlackButton(text = stringResource(Res.string.cancel), onClick = dismiss) }) } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt new file mode 100644 index 00000000000..bdae7c0aaa6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt @@ -0,0 +1,212 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.toOffset +import kotlinx.coroutines.launch +import kotlin.math.* + +/** + * Circular color picker that allows the user to select a hue by dragging a thumb around the circle. + * The color is represented in HSV color space with a fixed value. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * @param thumb Composable that is used to draw the thumb + * + * @see RingColorPicker + * @see SquareColorPicker + */ +@Composable +fun CircularColorPicker( + color: () -> HsvColor, + onColorChange: (HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onColorChangeFinished: () -> Unit = {}, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), interactionSource) + } +) { + CircularColorPicker( + hue = { color().hue }, + saturation = { color().saturation }, + value = { color().value }, + onColorChange = { h, s -> onColorChange(color().copy(hue = h, saturation = s)) }, + modifier = modifier, + interactionSource = interactionSource, + onColorChangeFinished = onColorChangeFinished, + thumb = thumb + ) +} + +/** + * Circular color picker that allows the user to select a hue by dragging a thumb around the circle. + * The color is represented in HSV color space with a fixed value. + * + * @param hue The hue of the color + * @param saturation The saturation of the color + * @param value The value of the color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * @param thumb Composable that is used to draw the thumb + * + * @see RingColorPicker + * @see SquareColorPicker + */ +@Composable +fun CircularColorPicker( + hue: () -> Float, + saturation: () -> Float, + modifier: Modifier = Modifier, + value: () -> Float = { 1f }, + onColorChange: (hue: Float, saturation: Float) -> Unit, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onColorChangeFinished: () -> Unit = {}, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(hue(), saturation(), value()), interactionSource) + } +) { + val scope = rememberCoroutineScope() + var radius by remember { mutableFloatStateOf(0f) } + + val currentOnColorChange by rememberUpdatedState(onColorChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { + radius = it.width / 2f + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val downPosition = down.position + val center = size.center.toOffset() + + // Check if initial touch is within the circle + if ((downPosition - center).getDistanceSquared() > radius * radius) return@awaitEachGesture + + // Handle initial tap + updateColorFromPosition(downPosition, center, radius, currentOnColorChange) + + // Start drag interaction + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + val adjustedPosition = clampPositionToRadius(change.position, center, radius) + updateColorFromPosition(adjustedPosition, center, radius, currentOnColorChange) + } + + // Continue dragging + while (change != null && change.pressed) { + change.consume() + val adjustedPosition = clampPositionToRadius(change.position, center, radius) + updateColorFromPosition(adjustedPosition, center, radius, currentOnColorChange) + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .drawWithCache { + val v = value() + val hueBrush = Brush.sweepGradient( + colors = List(7) { i -> + Color.hsv( + hue = i * 60f, + saturation = 1f, + value = v + ) + } + ) + val saturationBrush = Brush.radialGradient( + listOf(Color.hsv(0f, 0f, v), Color.Transparent) + ) + + onDrawBehind { + drawCircle(hueBrush) + drawCircle(saturationBrush) + } + } + ) { + Box( + modifier = Modifier.offset { + val angle = hue() * DEG_TO_RAD + val distance = saturation() * radius + + IntOffset( + x = (radius + distance * cos(angle)).roundToInt(), + y = (radius + distance * sin(angle)).roundToInt() + ) + } + ) { + thumb() + } + } +} + +/** + * Get the color for a given position in the circular color picker. + * + * @return A pair of hue and saturation values, or null if the position is outside the circle. + */ +private inline fun updateColorFromPosition( + position: Offset, + center: Offset, + radius: Float, + onResult: (hue: Float, saturation: Float) -> Unit +) { + val offset = position - center + + val degrees = atan2(offset.y, offset.x) * RAD_TO_DEG + val centerAngle = (degrees + 360) % 360 + val distance = offset.getDistance() + val saturation = (distance / radius).coerceIn(0f, 1f) + + onResult(centerAngle, saturation) +} + +/** + * Clamp the position to the radius of the color picker circle. + * + * @return The clamped position that lies within the circle of the given radius. + */ +private fun clampPositionToRadius(position: Offset, center: Offset, radius: Float): Offset { + val offset = position - center + + // If the position is already within the radius, return it as is + if (offset.getDistanceSquared() <= radius * radius) return position + + // Otherwise, clamp the position to the edge of the circle + val scale = radius / offset.getDistance() + return center + (offset * scale) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt new file mode 100644 index 00000000000..9cc25a0aaf3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt @@ -0,0 +1,64 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.PI + +internal const val DEG_TO_RAD = (PI / 180).toFloat() +internal const val RAD_TO_DEG = (180 / PI).toFloat() + +internal val ThumbRadiusPressed = 14.dp +internal val ThumbRadius = 10.dp + +object ColorPickerDefaults { + /** + * The default size applied to color pickers. Note that you can override it by applying + * Modifier.size directly on a picker component. + */ + val ComponentSize: Dp = 128.dp + + /** + * Default implementation of the thumb component for the color pickers. + * + * @param color The color of the thumb + * @param interactionSource The interaction source for the thumb + * @param modifier The modifier for the thumb + */ + @Composable + fun Thumb(color: Color, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier) { + val isPressed by interactionSource.collectIsPressedAsState() + val isDragged by interactionSource.collectIsDraggedAsState() + val radius by animateDpAsState( + targetValue = if (isPressed || isDragged) ThumbRadiusPressed else ThumbRadius + ) + + Canvas(modifier = modifier) { + drawCircle(color, radius = radius.toPx()) + + // Draw the border + drawCircle( + color = color.contrastingColor, + radius = radius.toPx(), + alpha = 0.5f, + style = Stroke(1.dp.toPx()) + ) + } + } +} + +internal val Color.contrastingColor: Color + get() = if (this.isDark()) Color.White else Color.Black + +internal fun Color.isDark(): Boolean { + return 0.2126 * this.red + 0.7152 * this.green + 0.0722 * this.blue < .5f +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt new file mode 100644 index 00000000000..e5b07d7b125 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt @@ -0,0 +1,208 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.* +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +@Composable +fun DoubleColorPicker( + color: () -> HsvColor, + onColorChange: (color: HsvColor) -> Unit, + modifier: Modifier = Modifier, + ringStrokeWidth: Dp = 16.dp, + innerPadding: Dp = 50.dp, + ringInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ringThumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(color().hue, 1f, 1f), ringInteractionSource) + }, + innerInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + innerThumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), innerInteractionSource) + }, +) { + Box(modifier) { + RingColorPicker( + interactionSource = ringInteractionSource, + thumb = ringThumb, + ringStrokeWidth = ringStrokeWidth, + color = color, + onColorChange = onColorChange, + modifier = Modifier.fillMaxSize() + ) + Box(Modifier.fillMaxSize().padding(innerPadding)) { + CircularSquareColorPicker( + interactionSource = innerInteractionSource, + thumb = innerThumb, + color = color, + onColorChange = onColorChange, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +/** + * The standard circle color picker that allows the user to select a color by dragging a thumb around the color space. + * + * The color is represented in HSV color space with a fixed hue. The saturation and value can be controlled by + * dragging the thumb. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param thumb Composable that is used to draw the thumb + * @param shape The shape of the color picker, note that the corner radius should be kept small, + * to prevent the thumb from visually appearing outside the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see RingColorPicker + */ +@Composable +fun CircularSquareColorPicker( + color: () -> HsvColor, + onColorChange: (color: HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), interactionSource) + }, + onColorChangeFinished: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + var size by remember { mutableStateOf(IntSize.Zero) } + + val currentColor by rememberUpdatedState(color) + val currentOnColorChange by rememberUpdatedState(onColorChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box { + Canvas( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { size = it } + .clip(CircleShape) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + + hsvColorForPosition(down.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + + // Start drag interaction + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + } + + // Continue dragging + while (change != null && change.pressed) { + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + val saturationBrush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + val hueBrush = Brush.horizontalGradient( + listOf( + Color.Transparent, + Color.hsv(currentColor().hue, 1f, 1f) + ) + ) + + drawRect(Color.White) + drawRect(hueBrush) + drawRect(saturationBrush) + } + + Box( + modifier = Modifier.offset { + val x = -1.0 + currentColor().saturation * 2.0 + val y = 1.0 - currentColor().value * 2.0 + + // https://squircular.blogspot.com/2015/09/mapping-circle-to-square.html + val u = x * sqrt(1 - y * y * 0.5) + val v = y * sqrt(1 - x * x * 0.5) + + IntOffset( + x = (u * size.width * 0.5 + size.width * 0.5).roundToInt(),//(currentColor().saturation * size.width).roundToInt(), + y = (v * size.height * 0.5 + size.height * 0.5).roundToInt(), //(size.height - currentColor().value * size.height).roundToInt() + ) + } + ) { + thumb() + } + } +} + +private fun hsvColorForPosition(position: Offset, size: IntSize): Pair { + val clampedX = position.x / size.width + val clampedY = position.y / size.height + + val x = -1.0 + clampedX * 2.0 + val y = 1.0 - clampedY * 2.0 + + // coerce here to prevent drag from feeling like a square + val radius = sqrt(x * x + y * y).coerceIn(0.0, 1.0) + val angle = atan2(y, x) + + val u = cos(angle) * radius + val v = sin(angle) * radius + + val u2 = u * u + val v2 = v * v + val sqrt22 = 2.0 * sqrt(2.0) + + // https://squircular.blogspot.com/2015/09/mapping-circle-to-square.html + val xOut = 0.5 * sqrt(2 + sqrt22 * u + u2 - v2) - 0.5 * sqrt(2 - sqrt22 * u + u2 - v2) + val yOut = 0.5 * sqrt(2 + sqrt22 * v - u2 + v2) - 0.5 * sqrt(2 - sqrt22 * v - u2 + v2) + + return (xOut * 0.5 + 0.5).toFloat() to (yOut * 0.5 + 0.5).toFloat() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt new file mode 100644 index 00000000000..0fcbbda992b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt @@ -0,0 +1,170 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.toArgb +import kotlin.jvm.JvmInline +import kotlin.math.max + +/** + * Represents a color in the HSV (Hue, Saturation, Value) color space. + * + * This class provides properties to access the hue, saturation, and value of the color, + * as well as methods to convert to and from RGB color space. + */ +@Immutable +@JvmInline +value class HsvColor private constructor(val packedValue: ULong) { + /** + * The hue of the color, in degrees (0-360). + */ + @Stable + val hue: Float + get() = (packedValue shr 40 and 0xFFFFu).toFloat() / 100f + + /** + * The saturation of the color (0-1). + */ + @Stable + val saturation: Float + get() = (packedValue shr 20 and 0xFFFFFu).toFloat() / 1000000f + + /** + * The value (brightness) of the color (0-1). + */ + @Stable + val value: Float + get() = (packedValue and 0xFFFFFu).toFloat() / 1000000f + + /** + * The red component of the color (0-1). + */ + @Stable + val red: Float + get() = hsvToRgbComponent(5, hue, saturation, value) + + /** + * The green component of the color (0-1). + */ + @Stable + val green: Float + get() = hsvToRgbComponent(3, hue, saturation, value) + + /** + * The blue component of the color (0-1). + */ + @Stable + val blue: Float + get() = hsvToRgbComponent(1, hue, saturation, value) + + /** + * Creates an HsvColor from the given hue, saturation, and value. + */ + constructor(hue: Float, saturation: Float, value: Float) : this( + ((hue * 100).toULong() and 0xFFFFu shl 40) or + ((saturation * 1000000).toULong() and 0xFFFFFu shl 20) or + ((value * 1000000).toULong() and 0xFFFFFu) + ) + + /** + * Converts the HSV color to a Color object. + * + * **Note**: HSV to RGB conversion is a lossy process, so the resulting color may not be exactly the same as the original HSV color. + * When red, green and blue are equal, hue will be 0 and saturation will be 0. + */ + fun toColor(): Color = Color.hsv(hue, saturation, value) + + @Stable + operator fun component1(): Float = hue + + @Stable + operator fun component2(): Float = saturation + + @Stable + operator fun component3(): Float = value + + /** + * Copies the existing color, changing only the provided values. + */ + @Stable + fun copy( + hue: Float = this.hue, + saturation: Float = this.saturation, + value: Float = this.value + ): HsvColor { + return HsvColor(hue, saturation, value) + } + + /** + * Returns a string representation of the color in HSV format. + */ + @Stable + override fun toString(): String { + return "HsvColor(hue=$hue, saturation=$saturation, value=$value)" + } + + companion object { + val Saver: Saver = Saver( + save = { it.packedValue.toLong() }, + restore = { HsvColor(it.toULong()) } + ) + + private fun hsvToRgbComponent(n: Int, h: Float, s: Float, v: Float): Float { + val k = (n.toFloat() + h / 60f) % 6f + return v - (v * s * max(0f, minOf(k, 4 - k, 1f))) + } + } +} + +/** + * Creates a new [HsvColor] instance from a [Color]. + * + * @param color The Color to create an HsvColor from. + * @return A non-null instance of [HsvColor] + */ +@Stable +fun HsvColor(color: Color): HsvColor = HsvColor(color.toArgb()) + +/** + * Creates a new [Color] instance from an ARGB color int. + * + * @param color The ARGB color int to create a Color from. + * @return A non-null instance of [HsvColor] + */ +@Stable +fun HsvColor(color: Int): HsvColor = HsvColor(color.toLong()) + +/** + * Creates a new [Color] instance from an ARGB color long. + * The resulting color is in the [sRGB][ColorSpaces.Srgb] + * color space. + * + * @param color The ARGB color long to create a Color from. + * @return A non-null instance of [HsvColor] + */ +@Stable +fun HsvColor(color: Long): HsvColor { + val r = ((color shr 16) and 0xFF).toFloat() / 255f + val g = ((color shr 8) and 0xFF).toFloat() / 255f + val b = (color and 0xFF).toFloat() / 255f + + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + + val hue = when { + delta == 0f -> 0f + max == r -> ((g - b) / delta) % 6 + max == g -> ((b - r) / delta) + 2 + max == b -> ((r - g) / delta) + 4 + else -> 0f + } * 60f + + val positiveHue = if (hue < 0f) hue + 360f else hue + val saturation = if (max == 0f) 0f else 1 - min / max + + return HsvColor(positiveHue, saturation, max) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE new file mode 100644 index 00000000000..33e326d058c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 zt64 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md new file mode 100644 index 00000000000..a57df0ca230 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md @@ -0,0 +1 @@ +Taken from https://github.com/zt64/compose-pipette/tree/main \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt new file mode 100644 index 00000000000..602bd3ace0d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt @@ -0,0 +1,200 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* +import kotlinx.coroutines.launch +import kotlin.math.* + +/** + * A ring color picker that allows the user to select a hue by rotating a handle around the ring. The ring is + * a continuous gradient of colors from red to red. + * + * To be able to also control the saturation, use the [CircularColorPicker] composable. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param ringStrokeWidth The width of the ring + * @param thumb The composable that is used to draw the thumb + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see SquareColorPicker + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RingColorPicker( + color: () -> HsvColor, + onColorChange: (HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ringStrokeWidth: Dp = 16.dp, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(color().hue, 1f, 1f), interactionSource) + }, + onColorChangeFinished: () -> Unit = {} +) { + RingColorPicker( + hue = { color().hue }, + onHueChange = { hue -> onColorChange(color().copy(hue = hue)) }, + modifier = modifier, + interactionSource = interactionSource, + ringStrokeWidth = ringStrokeWidth, + thumb = thumb, + onColorChangeFinished = onColorChangeFinished + ) +} + +/** + * A ring color picker that allows the user to select a hue by rotating a handle around the ring. The ring is + * a continuous gradient of colors from red to red. + * + * To be able to also control the saturation, use the [CircularColorPicker] composable. + * + * @param hue The hue of the color + * @param saturation The saturation of the color + * @param value The value of the color + * @param onHueChange Callback that is called when the hue changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param ringStrokeWidth The width of the ring + * @param thumb The composable that is used to draw the thumb + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see SquareColorPicker + */ +@Composable +fun RingColorPicker( + hue: () -> Float, + modifier: Modifier = Modifier, + saturation: () -> Float = { 1f }, + value: () -> Float = { 1f }, + onHueChange: (Float) -> Unit, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ringStrokeWidth: Dp = 16.dp, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(hue(), saturation(), value()), interactionSource) + }, + onColorChangeFinished: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + var radius by remember { mutableFloatStateOf(0f) } + var center by remember { mutableStateOf(Offset.Zero) } + val strokeWidth = with(LocalDensity.current) { ringStrokeWidth.toPx() } + + val currentOnHueChange by rememberUpdatedState(onHueChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { + radius = (it.width - strokeWidth) / 2f + center = Offset(it.width / 2f, it.height / 2f) + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val downPosition = down.position + val center = size.center.toOffset() + + val distanceSquared = (downPosition - center).getDistanceSquared() + val halfStroke = strokeWidth / 2f + val innerRadiusSquared = (radius - halfStroke) * (radius - halfStroke) + val outerRadiusSquared = (radius + halfStroke) * (radius + halfStroke) + + if (distanceSquared !in innerRadiusSquared..outerRadiusSquared) return@awaitEachGesture + + // Handle initial tap + currentOnHueChange(hueForPosition(downPosition, center)) + + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + currentOnHueChange(hueForPosition(change.position, center)) + } + + while (change != null && change.pressed) { + change.consume() + currentOnHueChange(hueForPosition(change.position, center)) + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .drawWithCache { + val brush = Brush.sweepGradient( + List(7) { + Color.hsv( + hue = (it * 60).toFloat(), + saturation = saturation(), + value = value() + ) + } + ) + + onDrawBehind { + drawCircle( + brush = brush, + radius = size.minDimension / 2 - strokeWidth / 2f, + style = Stroke(strokeWidth) + ) + } + } + .progressSemantics( + value = hue(), + valueRange = 0f..360f, + steps = 360 + ) + ) { + Box( + modifier = Modifier.offset { + val rad = hue() * DEG_TO_RAD + val x = center.x + radius * cos(rad) + val y = center.y + radius * sin(rad) + + IntOffset(x.roundToInt(), y.roundToInt()) + } + ) { + thumb() + } + } +} + +private fun hueForPosition(position: Offset, center: Offset): Float { + val (dx, dy) = position - center + val theta = atan2(dy, dx) + var angle = theta * RAD_TO_DEG + + if (angle < 0) angle += 360f + + return angle +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt new file mode 100644 index 00000000000..f409d420114 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt @@ -0,0 +1,141 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.* +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * The standard square color picker that allows the user to select a color by dragging a thumb around the color space. + * + * The color is represented in HSV color space with a fixed hue. The saturation and value can be controlled by + * dragging the thumb. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param thumb Composable that is used to draw the thumb + * @param shape The shape of the color picker, note that the corner radius should be kept small, + * to prevent the thumb from visually appearing outside the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see RingColorPicker + */ +@Composable +fun SquareColorPicker( + color: () -> HsvColor, + onColorChange: (color: HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), interactionSource) + }, + shape: Shape = RectangleShape, + onColorChangeFinished: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + var size by remember { mutableStateOf(IntSize.Zero) } + + val currentColor by rememberUpdatedState(color) + val currentOnColorChange by rememberUpdatedState(onColorChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box { + Canvas( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { size = it } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + + hsvColorForPosition(down.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + + // Start drag interaction + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + } + + // Continue dragging + while (change != null && change.pressed) { + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .clip(shape) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + val saturationBrush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + val hueBrush = Brush.horizontalGradient( + listOf( + Color.Transparent, + Color.hsv(currentColor().hue, 1f, 1f) + ) + ) + + drawRect(Color.White) + drawRect(hueBrush) + drawRect(saturationBrush) + } + + Box( + modifier = Modifier.offset { + IntOffset( + x = (currentColor().saturation * size.width).roundToInt(), + y = (size.height - currentColor().value * size.height).roundToInt() + ) + } + ) { + thumb() + } + } +} + +private fun hsvColorForPosition(position: Offset, size: IntSize): Pair { + val clampedX = position.x.coerceIn(0f, size.width.toFloat()) + val clampedY = position.y.coerceIn(0f, size.height.toFloat()) + + val saturation = clampedX / size.width + val value = 1f - (clampedY / size.height) + + return Pair(saturation, value) +} \ No newline at end of file From c1a18e3c627c8bd4fc1dba1057989746427e9354 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:39:37 +0200 Subject: [PATCH 6/8] Remove auto search for update for now --- .../main/java/com/lagradost/cloudstream3/MainActivity.kt | 2 +- .../java/com/lagradost/cloudstream3/MainActivityScreen.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 7eba4d367f5..253f1023766 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1989,7 +1989,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa handleAppIntent(intent) ioSafe { - runAutoUpdate() + runAutoUpdate() // TODO remove this when we update the updater autosearch } FcastManager().init(this, false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt index 559f5beec65..28553b3200e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt @@ -61,9 +61,9 @@ object MainActivityScreen : Screen { buildSha = activity.currentCommitHash(), settings = settings, updater = ApkUpdater - ).apply { - onAction(AutoSearchForUpdate) - } + )/*.apply { + onAction(AutoSearchForUpdate) // Remove this for now + }*/ } } From 8c4edaf75ff81d0bf5944f3d52a700b65017b1b8 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:29:41 +0200 Subject: [PATCH 7/8] Minor cleanup --- .../cloudstream3/ui/settings/ApkUpdater.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index 8c321bb7f6a..5bc14e34b23 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext -import okhttp3.ResponseBody import java.io.File import java.io.InputStream import java.io.OutputStream @@ -42,14 +41,15 @@ object ApkUpdater : AppUpdater { val body = request.body body.use { body -> val length = length ?: body.contentLength() + val readStream = body.byteStream() when (settings.updates.apkInstaller.get()) { 0 -> { - packageInstallerDownloader(activity, body, length, downloadProgress) + packageInstallerDownloader(activity, readStream, length, downloadProgress) } else -> { - legacyDownloader(activity, body, length, downloadProgress) + legacyDownloader(activity, readStream, length, downloadProgress) } } } @@ -63,12 +63,13 @@ object ApkUpdater : AppUpdater { deleteFileOnExit(it) } } + /** https://medium.com/@solrudev/painless-building-of-an-android-package-installer-app-d5a09b5df432 */ @SuppressLint("RequestInstallPackagesPolicy") @Throws suspend fun packageInstallerDownloader( activity: Activity, - body: ResponseBody, + readStream: InputStream, length: Long?, downloadProgress: (Long, Long?) -> Unit ) = withContext(Dispatchers.IO) { @@ -87,7 +88,6 @@ object ApkUpdater : AppUpdater { sessionId = packageInstaller.createSession(installParams) val session = packageInstaller.openSession(sessionId) - val readStream = body.byteStream() // We do not need to buffer this because transfer has large writes session.openWrite(activity.packageName, 0, length ?: -1L) @@ -102,7 +102,8 @@ object ApkUpdater : AppUpdater { } else { PendingIntent.FLAG_UPDATE_CURRENT } - val receiverPendingIntent = PendingIntent.getBroadcast(activity, 0, receiverIntent, flags) + val receiverPendingIntent = + PendingIntent.getBroadcast(activity, 0, receiverIntent, flags) // Avoid delayed updates, and just commit instantly session.commit(receiverPendingIntent.intentSender) @@ -140,12 +141,11 @@ object ApkUpdater : AppUpdater { @Throws suspend fun legacyDownloader( activity: Activity, - body: ResponseBody, + readStream: InputStream, length: Long?, downloadProgress: (Long, Long?) -> Unit ) = withContext(Dispatchers.IO) { val downloadedFile = File.createTempFile(APP_UPDATE_NAME, ".$APP_UPDATE_SUFFIX") - val readStream = body.byteStream() // We do not need to buffer this because transfer has large writes downloadedFile.outputStream().use { writeStream -> From cb7dc1ab820f8b43591db826236b53d68d6b05ef Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:18:45 +0200 Subject: [PATCH 8/8] Added digest to the apk downloader --- .../cloudstream3/ui/settings/ApkUpdater.kt | 62 +++++++++++++++++-- .../ui/settings/GithubViewModel.kt | 56 +++++++++++++++-- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index 5bc14e34b23..08e279b49a0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -13,6 +13,7 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.receivers.PackageInstallerStatusReceiver import com.lagradost.cloudstream4.AppSettings import kotlinx.coroutines.Dispatchers @@ -22,6 +23,8 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream import java.io.OutputStream +import java.security.DigestException +import java.security.MessageDigest object ApkUpdater : AppUpdater { private const val APP_UPDATE_NAME = "CloudStream" @@ -31,6 +34,7 @@ object ApkUpdater : AppUpdater { override suspend fun update( settings: AppSettings, url: String, + digest: DigestPair?, downloadProgress: (Long, Long?) -> Unit ) { val activity = CommonActivity.activity ?: throw ErrorLoadingException("No activity found") @@ -45,11 +49,17 @@ object ApkUpdater : AppUpdater { when (settings.updates.apkInstaller.get()) { 0 -> { - packageInstallerDownloader(activity, readStream, length, downloadProgress) + packageInstallerDownloader( + activity, + readStream, + length, + digest, + downloadProgress + ) } else -> { - legacyDownloader(activity, readStream, length, downloadProgress) + legacyDownloader(activity, readStream, length, digest, downloadProgress) } } } @@ -71,7 +81,8 @@ object ApkUpdater : AppUpdater { activity: Activity, readStream: InputStream, length: Long?, - downloadProgress: (Long, Long?) -> Unit + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit, ) = withContext(Dispatchers.IO) { var sessionId: Int? = null val packageInstaller = activity.packageManager.packageInstaller @@ -92,7 +103,7 @@ object ApkUpdater : AppUpdater { // We do not need to buffer this because transfer has large writes session.openWrite(activity.packageName, 0, length ?: -1L) .use { writeStream -> - transfer(writeStream, readStream, length, downloadProgress) + transfer(writeStream, readStream, length, downloadProgress, digest) session.fsync(writeStream) } @@ -116,13 +127,41 @@ object ApkUpdater : AppUpdater { } } + /** + * Write the "readStream" to the "writeStream", while notifying the "downloadProgress" and + * calculating the digest + * + * ------------------------------------------------------------------------------------------ + * + * throws a DigestException if the Digest can be calculated (non-null + correct algorithm), + * and is mismatched + * + * throws a CancellationException if canceled, but may not be "instant" if the read is blocking + * + * throws IOException and similar for reading + * + * ------------------------------------------------------------------------------------------ + * + * In case of crashes from the digest, we ignore it as we do not want to "block" someone from + * updating if they got a broken OS without the desired algorithm or implementation. + * + * This is because a malicious CDN should not be able to "crash" the algorithm, but a broken + * OS will. And recovering from a broken OS by ignoring it is better than refusing it. + * */ @Throws suspend fun transfer( writeStream: OutputStream, readStream: InputStream, length: Long?, - downloadProgress: (Long, Long?) -> Unit + downloadProgress: (Long, Long?) -> Unit, + digest: DigestPair?, ) = withContext(Dispatchers.IO) { + val md = digest?.algorithm?.let { digestAlgorithm -> + safe { + MessageDigest.getInstance(digestAlgorithm) + } + } + val context = currentCoroutineContext() val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var read: Int @@ -131,11 +170,21 @@ object ApkUpdater : AppUpdater { .also { read = it }) >= 0 ) { writeStream.write(buffer, 0, read) + + safe { + md?.update(buffer, 0, read) + } + transferred += read.toLong() downloadProgress(transferred, length) context.ensureActive() } writeStream.flush() + + val check = safe { md?.digest() } + if (check != null && !check.contentEquals(digest?.digest)) { + throw DigestException("Mismatched digest on transfer ${digest?.algorithm} : ${check.toHexString()} / ${digest?.digest?.toHexString()}") + } } @Throws @@ -143,13 +192,14 @@ object ApkUpdater : AppUpdater { activity: Activity, readStream: InputStream, length: Long?, + digest: DigestPair?, downloadProgress: (Long, Long?) -> Unit ) = withContext(Dispatchers.IO) { val downloadedFile = File.createTempFile(APP_UPDATE_NAME, ".$APP_UPDATE_SUFFIX") // We do not need to buffer this because transfer has large writes downloadedFile.outputStream().use { writeStream -> - transfer(writeStream, readStream, length, downloadProgress) + transfer(writeStream, readStream, length, downloadProgress, digest) } openApk(activity, downloadedFile) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt index a472751c880..99236ab7173 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.settings import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream4.AppSettings import com.lagradost.cloudstream4.compose.ActionHandler @@ -59,10 +60,53 @@ interface AppUpdater { suspend fun update( settings: AppSettings, url: String, - downloadProgress: (Long, Long?) -> Unit + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit, ) } +/** The digest pair to verify that a file is correctly downloaded */ +data class DigestPair( + val algorithm : String, + val digest : ByteArray, +) { + companion object { + // "sha256:XXXX" or "sha256-XXXX" -> "sha256", bytearray(XXXX) + fun parse(digestPair: String?) : DigestPair? { + if(digestPair == null) return null + + var split = digestPair.split(":", limit = 2) + if(split.size != 2) { + split = digestPair.split("-", limit = 2) + } + if(split.size != 2) { + return null + } + + val digestAlgorithm = split.getOrNull(0) ?: return null + val digestByteArray = safe { split.getOrNull(1)?.hexToByteArray() } ?: return null + + return DigestPair(digestAlgorithm, digestByteArray) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val other = other as? DigestPair ?: return false + + if (algorithm != other.algorithm) return false + if (!digest.contentEquals(other.digest)) return false + + return true + } + + override fun hashCode(): Int { + var result = algorithm.hashCode() + result = 31 * result + digest.contentHashCode() + return result + } +} + /** * Cross-platform downloader for updates served by GitHub. * @@ -125,7 +169,7 @@ class GithubViewModel( is GithubAction.Update -> { ioSafe { - installUpdate(action.file.downloadUrl) + installUpdate(action.file.downloadUrl, action.file.digest) } } } @@ -149,8 +193,12 @@ class GithubViewModel( } } - private suspend fun installUpdate(url: String) = dispatchUpdate { - updater.update(settings = settings, url = url) { progress, total -> + private suspend fun installUpdate(url: String, digestPair: String?) = dispatchUpdate { + updater.update( + settings = settings, + url = url, + digest = DigestPair.parse(digestPair), + ) { progress, total -> updateState { copy( dialog = dialog?.copy(