From 12241e81d4c5e6f531d028a7ac19d91bb2011ab1 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:11:51 +0300 Subject: [PATCH 01/32] feat: Add Firebase Profiles and History Tab auto-saving --- .firebaserc | 5 + app/build.gradle.kts | 19 + .../lagradost/cloudstream3/CloudStreamApp.kt | 33 + .../lagradost/cloudstream3/MainActivity.kt | 56 +- .../syncproviders/AccountManager.kt | 5 +- .../cloudstream3/syncproviders/AuthRepo.kt | 2 +- .../cloudstream3/syncproviders/SyncData.kt | 61 ++ .../cloudstream3/syncproviders/SyncProfile.kt | 16 + .../providers/FirebaseSyncManager.kt | 471 +++++++++++ .../providers/GoogleDriveSyncManager.kt | 751 ++++++++++++++++++ .../syncproviders/providers/KitsuApi.kt | 1 + .../syncproviders/providers/MALApi.kt | 1 + .../cloudstream3/ui/ControllerActivity.kt | 7 + .../lagradost/cloudstream3/ui/WatchType.kt | 6 +- .../cloudstream3/ui/account/AccountHelper.kt | 8 +- .../ui/account/AccountSelectActivity.kt | 65 +- .../cloudstream3/ui/home/HomeFragment.kt | 9 +- .../ui/home/HomeParentItemAdapterPreview.kt | 49 +- .../cloudstream3/ui/home/HomeViewModel.kt | 4 + .../ui/player/DownloadedPlayerActivity.kt | 3 + .../cloudstream3/ui/player/PlayerView.kt | 23 +- .../ui/result/ResultFragmentPhone.kt | 1 + .../ui/result/ResultViewModel2.kt | 8 +- .../ui/settings/SettingsAccount.kt | 10 + .../ui/settings/SettingsFragment.kt | 35 +- .../ui/settings/SettingsUpdates.kt | 93 +++ .../ui/setup/LanguageSetupFragment.kt | 106 +++ .../ui/setup/SetupFragmentLanguage.kt | 93 --- .../cloudstream3/ui/setup/WelcomeFragment.kt | 53 ++ .../cloudstream3/ui/sync/FragmentPairTv.kt | 112 +++ .../cloudstream3/ui/sync/LoginFragment.kt | 278 +++++++ .../cloudstream3/ui/sync/PinEntryDialog.kt | 195 +++++ .../cloudstream3/ui/sync/ProfileAdapter.kt | 120 +++ .../ui/sync/ProfileEditorDialog.kt | 350 ++++++++ .../ui/sync/ProfileSelectorFragment.kt | 195 +++++ .../cloudstream3/utils/DataStoreHelper.kt | 109 ++- .../cloudstream3/utils/InAppUpdater.kt | 2 +- .../utils/downloader/DownloadObjects.kt | 44 +- app/src/main/res/drawable/avatar_1.xml | 50 ++ app/src/main/res/drawable/avatar_10.xml | 46 ++ app/src/main/res/drawable/avatar_11.xml | 50 ++ app/src/main/res/drawable/avatar_12.xml | 50 ++ app/src/main/res/drawable/avatar_2.xml | 54 ++ app/src/main/res/drawable/avatar_3.xml | 58 ++ app/src/main/res/drawable/avatar_4.xml | 42 + app/src/main/res/drawable/avatar_5.xml | 62 ++ app/src/main/res/drawable/avatar_6.xml | 50 ++ app/src/main/res/drawable/avatar_7.xml | 52 ++ app/src/main/res/drawable/avatar_8.xml | 50 ++ app/src/main/res/drawable/avatar_9.xml | 52 ++ .../main/res/drawable/googledrive_logo.xml | 18 + app/src/main/res/drawable/ic_backspace.xml | 9 + app/src/main/res/drawable/pin_dot_empty.xml | 6 + app/src/main/res/drawable/pin_dot_filled.xml | 6 + app/src/main/res/drawable/rounded_bg_gray.xml | 7 + app/src/main/res/layout/dialog_pin_entry.xml | 195 +++++ .../main/res/layout/dialog_profile_editor.xml | 343 ++++++++ .../res/layout/fragment_language_setup.xml | 63 ++ app/src/main/res/layout/fragment_login.xml | 159 ++++ app/src/main/res/layout/fragment_pair_tv.xml | 110 +++ .../res/layout/fragment_profile_selector.xml | 103 +++ app/src/main/res/layout/fragment_welcome.xml | 107 +++ .../main/res/layout/item_avatar_select.xml | 28 + .../main/res/layout/item_language_elegant.xml | 38 + app/src/main/res/layout/item_profile.xml | 101 +++ .../main/res/navigation/mobile_navigation.xml | 106 ++- .../res/values/donottranslate-strings.xml | 1 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/styles.xml | 14 + app/src/main/res/xml/settings_account.xml | 6 + app/src/main/res/xml/settings_updates.xml | 22 + build.gradle.kts | 1 + firebase.json | 15 + firestore.indexes.json | 4 + firestore.rules | 27 + gradle/libs.versions.toml | 18 +- 76 files changed, 5317 insertions(+), 208 deletions(-) create mode 100644 .firebaserc create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncData.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncProfile.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/setup/LanguageSetupFragment.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/setup/WelcomeFragment.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/PinEntryDialog.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileAdapter.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileEditorDialog.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt create mode 100644 app/src/main/res/drawable/avatar_1.xml create mode 100644 app/src/main/res/drawable/avatar_10.xml create mode 100644 app/src/main/res/drawable/avatar_11.xml create mode 100644 app/src/main/res/drawable/avatar_12.xml create mode 100644 app/src/main/res/drawable/avatar_2.xml create mode 100644 app/src/main/res/drawable/avatar_3.xml create mode 100644 app/src/main/res/drawable/avatar_4.xml create mode 100644 app/src/main/res/drawable/avatar_5.xml create mode 100644 app/src/main/res/drawable/avatar_6.xml create mode 100644 app/src/main/res/drawable/avatar_7.xml create mode 100644 app/src/main/res/drawable/avatar_8.xml create mode 100644 app/src/main/res/drawable/avatar_9.xml create mode 100644 app/src/main/res/drawable/googledrive_logo.xml create mode 100644 app/src/main/res/drawable/ic_backspace.xml create mode 100644 app/src/main/res/drawable/pin_dot_empty.xml create mode 100644 app/src/main/res/drawable/pin_dot_filled.xml create mode 100644 app/src/main/res/drawable/rounded_bg_gray.xml create mode 100644 app/src/main/res/layout/dialog_pin_entry.xml create mode 100644 app/src/main/res/layout/dialog_profile_editor.xml create mode 100644 app/src/main/res/layout/fragment_language_setup.xml create mode 100644 app/src/main/res/layout/fragment_login.xml create mode 100644 app/src/main/res/layout/fragment_pair_tv.xml create mode 100644 app/src/main/res/layout/fragment_profile_selector.xml create mode 100644 app/src/main/res/layout/fragment_welcome.xml create mode 100644 app/src/main/res/layout/item_avatar_select.xml create mode 100644 app/src/main/res/layout/item_language_elegant.xml create mode 100644 app/src/main/res/layout/item_profile.xml create mode 100644 firebase.json create mode 100644 firestore.indexes.json create mode 100644 firestore.rules diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000000..b49ad35cde5 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": {}, + "targets": {}, + "etags": {} +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b1aefab258..c90d3f56d62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) + alias(libs.plugins.google.services) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -269,9 +270,27 @@ dependencies { implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib + // Firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.bundles.firebase) + + // Google Sign-In (Credential Manager) + implementation(libs.credentials) + implementation(libs.credentials.play.services.auth) + implementation("com.google.android.gms:play-services-auth:21.0.0") implementation(project(":library")) } +configurations.all { + resolutionStrategy { + force("com.google.protobuf:protobuf-javalite:3.25.5") + } +} + +// configurations.all { +// exclude(group = "com.google.firebase", module = "protolite-well-known-types") +// } + tasks.register("androidSourcesJar") { archiveClassifier.set("sources") from(android.sourceSets.getByName("main").java.directories) // Full Sources diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index a9cd9c01edd..b871b4ac6cb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -84,6 +84,39 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory { } AppDebug.isDebug = BuildConfig.DEBUG + + registerActivityLifecycleCallbacks(object : android.app.Application.ActivityLifecycleCallbacks { + private var startedActivities = 0 + private var isChangingConfiguration = false + + override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} + + override fun onActivityStarted(activity: android.app.Activity) { + if (startedActivities == 0) { + isChangingConfiguration = false + } + startedActivities++ + } + + override fun onActivityResumed(activity: android.app.Activity) {} + override fun onActivityPaused(activity: android.app.Activity) {} + + override fun onActivityStopped(activity: android.app.Activity) { + startedActivities-- + if (startedActivities == 0) { + if (!activity.isChangingConfigurations) { + // App went to background, lock it instantly + com.lagradost.cloudstream3.ui.account.AccountSelectActivity.hasLoggedIn = false + com.lagradost.cloudstream3.ui.sync.ProfileSelectorFragment.hasFirebaseLoggedIn = false + } else { + isChangingConfiguration = true + } + } + } + + override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} + override fun onActivityDestroyed(activity: android.app.Activity) {} + }) } override fun attachBaseContext(base: Context?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 8a98bd2972e..a1f66e18ccd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -191,6 +191,8 @@ import kotlin.system.exitProcess import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { @@ -640,6 +642,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onResume() { super.onResume() + if (!com.lagradost.cloudstream3.ui.account.AccountSelectActivity.hasLoggedIn) { + val intent = Intent(this, com.lagradost.cloudstream3.ui.account.AccountSelectActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + startActivity(intent) + return + } + + // --- Firebase Profile Background Check --- + val firebaseRepo = com.lagradost.cloudstream3.syncproviders.AccountManager.firebaseApi + if (firebaseRepo.auth.currentUser != null && !com.lagradost.cloudstream3.ui.sync.ProfileSelectorFragment.hasFirebaseLoggedIn) { + binding?.navHostFragment?.post { + try { + navigate(R.id.navigation_profile_selector) + } catch (e: Exception) { + logError(e) + } + } + } + afterPluginsLoadedEvent += ::onAllPluginsLoaded setActivityInstance(this) try { @@ -649,6 +670,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } catch (e: Exception) { logError(e) } + val sp = PreferenceManager.getDefaultSharedPreferences(this) + if (sp.getBoolean("firebase_auto_sync_key", true)) { + ioSafe { + AccountManager.firebaseApi.syncLocalToFirestore(this@MainActivity) + } + } } override fun onPause() { @@ -1325,6 +1352,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + // --- Firebase Profile Check --- + val firebaseRepo = com.lagradost.cloudstream3.syncproviders.AccountManager.firebaseApi + if (firebaseRepo.auth.currentUser != null && getKey(HAS_DONE_SETUP_KEY, false) == true) { + ioSafe { + val profiles = firebaseRepo.getProfiles() + if (profiles.isNotEmpty()) { + if (profiles.size == 1 && !profiles.first().isLocked) { + val profile = profiles.first() + profile.lastUsed = System.currentTimeMillis() + firebaseRepo.saveProfile(profile) + firebaseRepo.selectProfile(profile) + firebaseRepo.syncLocalToFirestore(this@MainActivity) + com.lagradost.cloudstream3.ui.sync.ProfileSelectorFragment.hasFirebaseLoggedIn = true + } + } + } + } + // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) { main { @@ -2021,16 +2066,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa try { if (getKey(HAS_DONE_SETUP_KEY, false) != true) { - navController.navigate(R.id.navigation_setup_language) - // If no plugins bring up extensions screen - } else if (PluginManager.getPluginsOnline().isEmpty() - && PluginManager.getPluginsLocal().isEmpty() -// && PREBUILT_REPOSITORIES.isNotEmpty() - ) { - navController.navigate( - R.id.navigation_setup_extensions, - SetupFragmentExtensions.newInstance(false) - ) + navController.navigate(R.id.navigation_welcome) } } catch (e: Exception) { logError(e) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 3bc5f273397..ff91f2f0fa4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.FirebaseSyncManager import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi @@ -24,6 +25,7 @@ abstract class AccountManager { val aniListApi = AniListApi() val simklApi = SimklApi() val localListApi = LocalList() + val firebaseApi = FirebaseSyncManager() val openSubtitlesApi = OpenSubtitlesApi() val addic7ed = Addic7ed() @@ -70,7 +72,8 @@ abstract class AccountManager { SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), SubtitleRepo(subDlApi), - PlainAuthRepo(animeSkipApi) + PlainAuthRepo(animeSkipApi), + PlainAuthRepo(firebaseApi) ) fun updateAccountIds() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt index 645a19e3a60..ffa0f22f70f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -31,7 +31,7 @@ abstract class AuthRepo(open val api: AuthAPI) { } @Throws - protected suspend fun freshAuth(): AuthData? { + suspend fun freshAuth(): AuthData? { val data = authData() ?: return null if (data.token.isAccessTokenExpired()) { val newToken = api.refreshToken(data.token) ?: return null diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncData.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncData.kt new file mode 100644 index 00000000000..1f8afd67427 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncData.kt @@ -0,0 +1,61 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.Keep +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.utils.DataStoreHelper.BookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.PosDur +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.ResumeWatching +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadHeaderCached + +@Keep +data class SyncSettings( + @JsonProperty("theme") var theme: String? = null, + @JsonProperty("autoPlayNext") var autoPlayNext: Boolean? = null, + @JsonProperty("defaultPlayer") var defaultPlayer: String? = null, + @JsonProperty("currentHomePage") var currentHomePage: String? = null, + @JsonProperty("appLayout") var appLayout: Int? = null +) + +@Keep +data class SyncBookmarks( + @JsonProperty("planToWatch") var planToWatch: List = emptyList(), + @JsonProperty("completed") var completed: List = emptyList(), + @JsonProperty("watching") var watching: List = emptyList(), + @JsonProperty("onHold") var onHold: List = emptyList(), + @JsonProperty("dropped") var dropped: List = emptyList() +) + +@Keep +data class SyncWatchProgressItem( + @JsonProperty("mediaTitle") var mediaTitle: String = "", + @JsonProperty("season") var season: Int = 0, + @JsonProperty("episode") var episode: Int = 0, + @JsonProperty("timestampInSeconds") var timestampInSeconds: Long = 0L, + @JsonProperty("lastUpdated") var lastUpdated: String = "" +) + +@Keep +data class SyncReviewItem( + @JsonProperty("mediaTitle") var mediaTitle: String = "", + @JsonProperty("rating") var rating: Int = 0, + @JsonProperty("note") var note: String? = null +) + +@Keep +data class WatchProgressDetailsItem( + @JsonProperty("headerCached") var headerCached: DownloadHeaderCached? = null, + @JsonProperty("resumeWatching") var resumeWatching: ResumeWatching? = null, + @JsonProperty("posDur") var posDur: PosDur? = null +) + +@Keep +data class SyncData( + @JsonProperty("userSettings") var userSettings: SyncSettings? = null, + @JsonProperty("repositories") var repositories: List = emptyList(), + @JsonProperty("bookmarks") var bookmarks: SyncBookmarks = SyncBookmarks(), + @JsonProperty("watchProgress") var watchProgress: List = emptyList(), + @JsonProperty("reviews") var reviews: List = emptyList(), + @JsonProperty("bookmarkDetails") var bookmarkDetails: Map = emptyMap(), + @JsonProperty("watchProgressDetails") var watchProgressDetails: Map = emptyMap(), + @JsonProperty("plugins") var plugins: List = emptyList() +) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncProfile.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncProfile.kt new file mode 100644 index 00000000000..ab343a20cb3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncProfile.kt @@ -0,0 +1,16 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.Keep + +@Keep +data class SyncProfile( + var id: String = "", + var name: String = "", + var avatarUrl: String? = null, // Can be custom path or drawable resource name + var pinHash: String? = null, // SHA-256 Pin hash, null if not locked + var color: Int? = null, // Accent color (ARGB Int or hex) + var lastUsed: Long = 0L +) { + val isLocked: Boolean + get() = !pinHash.isNullOrBlank() +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt new file mode 100644 index 00000000000..30c64ccf686 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt @@ -0,0 +1,471 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import android.content.Context +import android.util.Log +import androidx.preference.PreferenceManager +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.ListenerRegistration +import com.google.firebase.firestore.SetOptions +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.syncproviders.* +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.BookmarkedData +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadHeaderCached +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.mvvm.logError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.* + +class FirebaseSyncManager : AuthAPI() { + override var name = "Firebase Sync" + override val idPrefix = "firebase" + override val icon = R.drawable.googledrive_logo // Reuse logo or use standard ic_baseline_sync + override val hasOAuth2 = false + override val hasInApp = true + override val inAppLoginRequirement: AuthLoginRequirement? = null + + companion object { + const val TAG = "FirebaseSyncManager" + } + + val auth: FirebaseAuth get() = FirebaseAuth.getInstance() + val firestore: FirebaseFirestore get() = FirebaseFirestore.getInstance() + + private val _currentProfile = MutableStateFlow(null) + val currentProfile: StateFlow = _currentProfile + + private var syncListener: ListenerRegistration? = null + @Volatile var isSyncActive = false + + // AuthAPI Implementations + override fun loginRequest(): AuthLoginPage? = null + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? = null + override suspend fun login(form: AuthLoginResponse): AuthToken? = null + override suspend fun refreshToken(token: AuthToken): AuthToken? = null + + override suspend fun user(token: AuthToken?): AuthUser? { + val currentUser = auth.currentUser ?: return null + return AuthUser( + id = currentUser.uid.hashCode(), + name = currentUser.displayName ?: currentUser.email ?: "Firebase User", + profilePicture = currentUser.photoUrl?.toString() + ) + } + + fun selectProfile(profile: SyncProfile?) { + _currentProfile.value = profile + if (profile != null) { + startRealtimeSync(profile.id) + } else { + stopRealtimeSync() + } + } + + // Profiles CRUD + suspend fun getProfiles(): List { + val uid = auth.currentUser?.uid ?: return emptyList() + return try { + val snapshot = firestore.collection("users").document(uid) + .collection("profiles").get().await() + snapshot.toObjects(SyncProfile::class.java).sortedByDescending { it.lastUsed } + } catch (e: Exception) { + logError(e) + emptyList() + } + } + + suspend fun saveProfile(profile: SyncProfile): Boolean { + val uid = auth.currentUser?.uid ?: return false + return try { + if (profile.id.isEmpty()) { + profile.id = UUID.randomUUID().toString() + } + firestore.collection("users").document(uid) + .collection("profiles").document(profile.id).set(profile).await() + true + } catch (e: Exception) { + logError(e) + false + } + } + + suspend fun deleteProfile(profileId: String): Boolean { + val uid = auth.currentUser?.uid ?: return false + return try { + firestore.collection("users").document(uid) + .collection("profiles").document(profileId).delete().await() + true + } catch (e: Exception) { + logError(e) + false + } + } + + // Real-time Sync + private fun startRealtimeSync(profileId: String) { + stopRealtimeSync() + val uid = auth.currentUser?.uid ?: return + + syncListener = firestore.collection("users").document(uid) + .collection("profiles").document(profileId) + .collection("data").document("syncData") + .addSnapshotListener { snapshot, e -> + if (e != null) { + logError(e) + return@addSnapshotListener + } + + if (snapshot != null && snapshot.exists()) { + val remoteData = snapshot.toObject(SyncData::class.java) + if (remoteData != null) { + // Launch in background to prevent blocking UI + kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { + if (!isSyncActive) { + isSyncActive = true + try { + mergeAndSaveLocalData(remoteData, null) + } finally { + isSyncActive = false + } + } + } + } + } + } + } + + private fun stopRealtimeSync() { + syncListener?.remove() + syncListener = null + } + + suspend fun syncLocalToFirestore(context: Context): Boolean { + if (isSyncActive) return false + isSyncActive = true + try { + val uid = auth.currentUser?.uid ?: return false + val profileId = _currentProfile.value?.id ?: return false + + return withContext(Dispatchers.IO) { + try { + val docRef = firestore.collection("users").document(uid) + .collection("profiles").document(profileId) + .collection("data").document("syncData") + + val remoteSnapshot = docRef.get().await() + val remoteData = if (remoteSnapshot.exists()) { + remoteSnapshot.toObject(SyncData::class.java) ?: SyncData() + } else { + SyncData() + } + + val mergedData = mergeAndSaveLocalData(remoteData, context) + + docRef.set(mergedData, SetOptions.merge()).await() + + val sp = PreferenceManager.getDefaultSharedPreferences(context) + sp.edit().putLong("firebase_last_synced_time", System.currentTimeMillis()).apply() + + // Trigger async download of missing plugins + val missingPlugins = mergedData.plugins.filter { pluginName -> + !PluginManager.getPluginsLocal().any { it.internalName == pluginName } && + !PluginManager.getPluginsOnline().any { it.internalName == pluginName } + } + val activity = context.getActivity() + if (missingPlugins.isNotEmpty() && activity != null) { + val urls = (getKey>(com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY) ?: emptyArray()) + RepositoryManager.PREBUILT_REPOSITORIES + val onlinePlugins = urls.toList().amap { + RepositoryManager.getRepoPlugins(it.url)?.toList() ?: emptyList() + }.flatten().distinctBy { it.second.url } + + var downloadedCount = 0 + for (pluginInternalName in missingPlugins) { + val onlineData = onlinePlugins.firstOrNull { it.second.internalName == pluginInternalName } + if (onlineData != null) { + val success = PluginManager.downloadPlugin( + activity, + onlineData.second.url, + onlineData.second.fileHash, + onlineData.second.internalName, + onlineData.first, + true + ) + if (success) downloadedCount++ + } + } + if (downloadedCount > 0) { + withContext(Dispatchers.Main) { + com.lagradost.cloudstream3.CommonActivity.showToast(activity, "Downloaded $downloadedCount plugins from Firebase Backup", android.widget.Toast.LENGTH_LONG) + com.lagradost.cloudstream3.MainActivity.afterPluginsLoadedEvent.invoke(true) + } + } + } + + true + } catch (e: Exception) { + logError(e) + false + } + } + } finally { + isSyncActive = false + } + } + + private suspend fun mergeAndSaveLocalData(remote: SyncData, context: Context?): SyncData { + val sp = if (context != null) PreferenceManager.getDefaultSharedPreferences(context) else null + + // 1. Settings + val localTheme = sp?.getString("app_theme", "AmoledLight") ?: "AmoledLight" + val lastSyncedTheme = sp?.getString("firebase_last_synced_theme", null) + val mergedTheme = if (lastSyncedTheme != null && localTheme != lastSyncedTheme) localTheme else remote.userSettings?.theme ?: localTheme + + val localAutoplay = sp?.getBoolean("autoplay_next", true) ?: true + val lastSyncedAutoplay = if (sp?.contains("firebase_last_synced_autoplay") == true) sp.getBoolean("firebase_last_synced_autoplay", true) else null + val mergedAutoplay = if (lastSyncedAutoplay != null && localAutoplay != lastSyncedAutoplay) localAutoplay else remote.userSettings?.autoPlayNext ?: localAutoplay + + val localPlayer = sp?.getString("player_default", "") ?: "" + val lastSyncedPlayer = sp?.getString("firebase_last_synced_player", null) + val mergedPlayer = if (lastSyncedPlayer != null && localPlayer != lastSyncedPlayer) localPlayer else remote.userSettings?.defaultPlayer ?: localPlayer + + val localHomePage = DataStoreHelper.currentHomePage + val lastSyncedHomePage = sp?.getString("firebase_last_synced_homepage", null) + val mergedHomePage = if (lastSyncedHomePage != null && localHomePage != lastSyncedHomePage) localHomePage else remote.userSettings?.currentHomePage ?: localHomePage + + val localAppLayout = sp?.getInt("app_layout_key", -1)?.takeIf { it != -1 } + val lastSyncedAppLayout = if (sp?.contains("firebase_last_synced_applayout") == true) sp.getInt("firebase_last_synced_applayout", -1) else null + val mergedAppLayout = if (lastSyncedAppLayout != null && localAppLayout != lastSyncedAppLayout) localAppLayout else remote.userSettings?.appLayout ?: localAppLayout + + if (mergedHomePage != null) { + DataStoreHelper.currentHomePage = mergedHomePage + } + + if (sp != null) { + withContext(Dispatchers.Main) { + sp.edit().apply { + putString("app_theme", mergedTheme) + putBoolean("autoplay_next", mergedAutoplay) + putString("player_default", mergedPlayer) + if (mergedAppLayout != null) putInt("app_layout_key", mergedAppLayout) + + // Save last synced values + putString("firebase_last_synced_theme", mergedTheme) + putBoolean("firebase_last_synced_autoplay", mergedAutoplay) + putString("firebase_last_synced_player", mergedPlayer) + if (mergedHomePage != null) putString("firebase_last_synced_homepage", mergedHomePage) + if (mergedAppLayout != null) putInt("firebase_last_synced_applayout", mergedAppLayout) + apply() + } + } + } + + // 2. Repositories + val localRepos = RepositoryManager.getRepositories() + val localRepoUrls = localRepos.map { it.url } + val remoteRepoUrls = remote.repositories + val mergedRepoUrls = (localRepoUrls + remoteRepoUrls).distinct() + + for (url in mergedRepoUrls) { + if (!localRepoUrls.contains(url)) { + var repoName = url.substringAfterLast("/").substringBefore(".json").ifBlank { "Remote Repo" } + try { + val parsed = RepositoryManager.parseRepository(url) + if (parsed != null) repoName = parsed.name + } catch (t: Throwable) { logError(t) } + RepositoryManager.addRepository(RepositoryData(null, repoName, url)) + } + } + + // 2.5 Plugins + val localPlugins = (PluginManager.getPluginsLocal() + PluginManager.getPluginsOnline()).map { it.internalName } + val remotePlugins = remote.plugins + val mergedPlugins = (localPlugins + remotePlugins).distinct() + + // 3. Bookmarks + val localBookmarks = DataStoreHelper.getAllBookmarkedData() + val localBookmarkMap = localBookmarks.associateBy { it.name } + val remoteBookmarkMap = remote.bookmarkDetails + + val mergedBookmarkDetails = mutableMapOf() + val allBookmarkNames = (localBookmarkMap.keys + remoteBookmarkMap.keys).distinct() + + val planToWatchList = mutableListOf() + val completedList = mutableListOf() + val watchingList = mutableListOf() + val onHoldList = mutableListOf() + val droppedList = mutableListOf() + + for (name in allBookmarkNames) { + val local = localBookmarkMap[name] + val remoteItem = remoteBookmarkMap[name] + + val winner: BookmarkedData + val winnerWatchType: WatchType + + if (local != null && remoteItem != null) { + if (local.bookmarkedTime >= remoteItem.bookmarkedTime) { + winner = local + winnerWatchType = DataStoreHelper.getResultWatchState(local.id ?: 0) + } else { + winner = remoteItem + winnerWatchType = getRemoteWatchType(remote, name) + } + } else if (local != null) { + winner = local + winnerWatchType = DataStoreHelper.getResultWatchState(local.id ?: 0) + } else { + val lastSyncedTime = sp?.getLong("firebase_last_synced_time", 0L) ?: 0L + if (lastSyncedTime > 0L && remoteItem!!.bookmarkedTime < lastSyncedTime) { + continue + } + winner = remoteItem!! + winnerWatchType = getRemoteWatchType(remote, name) + } + + mergedBookmarkDetails[name] = winner + + when (winnerWatchType) { + WatchType.PLANTOWATCH -> planToWatchList.add(name) + WatchType.COMPLETED -> completedList.add(name) + WatchType.WATCHING -> watchingList.add(name) + WatchType.ONHOLD -> onHoldList.add(name) + WatchType.DROPPED -> droppedList.add(name) + else -> {} + } + + if (winner == remoteItem || DataStoreHelper.getResultWatchState(winner.id ?: 0) != winnerWatchType) { + if (winner.id != null) { + DataStoreHelper.setBookmarkedData(winner.id, winner) + DataStoreHelper.setResultWatchState(winner.id, winnerWatchType.internalId) + } + } + } + + // 4. Watch Progress + val localProgressMap = mutableMapOf() + val localProgressItems = mutableListOf() + + val dateIsoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + val resumeIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() + for (parentId in resumeIds) { + val resume = DataStoreHelper.getLastWatched(parentId) + if (resume != null) { + val header = getKey(DOWNLOAD_HEADER_CACHE, parentId.toString()) + val posDur = resume.episodeId?.let { DataStoreHelper.getViewPos(it) } + val showName = header?.name ?: "Show $parentId" + val key = "${showName}_S${resume.season ?: 0}_E${resume.episode ?: 0}" + + val seconds = (posDur?.position ?: 0L) / 1000L + val dateStr = dateIsoFormatter.format(Date(resume.updateTime)) + + localProgressItems.add(SyncWatchProgressItem(showName, resume.season ?: 0, resume.episode ?: 0, seconds, dateStr)) + localProgressMap[key] = WatchProgressDetailsItem(header, resume, posDur) + } + } + + val remoteProgressMap = remote.watchProgressDetails + val allProgressKeys = (localProgressMap.keys + remoteProgressMap.keys).distinct() + val mergedProgressDetails = mutableMapOf() + val mergedProgressItems = mutableListOf() + + for (key in allProgressKeys) { + val local = localProgressMap[key] + val remoteItem = remoteProgressMap[key] + + val winner: WatchProgressDetailsItem + val localTime = local?.resumeWatching?.updateTime ?: 0L + val remoteTime = try { + val remoteLastUpdated = remote.watchProgress.firstOrNull { + it.mediaTitle == (remoteItem?.headerCached?.name ?: "") && + it.season == (remoteItem?.resumeWatching?.season ?: 0) && + it.episode == (remoteItem?.resumeWatching?.episode ?: 0) + }?.lastUpdated + if (remoteLastUpdated != null) dateIsoFormatter.parse(remoteLastUpdated)?.time ?: 0L else 0L + } catch (e: Exception) { 0L } + + winner = if (local != null && remoteItem != null) { + if (localTime >= remoteTime) local else remoteItem + } else if (local != null) { + local + } else { + val lastSyncedTime = sp?.getLong("firebase_last_synced_time", 0L) ?: 0L + if (lastSyncedTime > 0L && remoteTime < lastSyncedTime) { + continue + } + remoteItem!! + } + + mergedProgressDetails[key] = winner + + val showName = winner.headerCached?.name ?: "Unknown" + val seconds = (winner.posDur?.position ?: 0L) / 1000L + val updateTime = winner.resumeWatching?.updateTime ?: System.currentTimeMillis() + + mergedProgressItems.add( + SyncWatchProgressItem(showName, winner.resumeWatching?.season ?: 0, winner.resumeWatching?.episode ?: 0, seconds, dateIsoFormatter.format(Date(updateTime))) + ) + + if (winner == remoteItem) { + val head = winner.headerCached + val res = winner.resumeWatching + val pos = winner.posDur + if (head != null && res != null) { + setKey(DOWNLOAD_HEADER_CACHE, head.id.toString(), head) + DataStoreHelper.setLastWatched(res.parentId, res.episodeId, res.episode, res.season, res.isFromDownload, res.updateTime) + if (res.episodeId != null && pos != null) { + DataStoreHelper.setViewPos(res.episodeId, pos.position, pos.duration) + } + } + } + } + + // 5. Reviews + val localReviews = getKey>("user_reviews") ?: emptyList() + val remoteReviews = remote.reviews + val mergedReviews = (localReviews + remoteReviews).distinctBy { it.mediaTitle } + setKey("user_reviews", mergedReviews) + + return SyncData( + userSettings = SyncSettings(mergedTheme, mergedAutoplay, mergedPlayer, mergedHomePage, mergedAppLayout), + repositories = mergedRepoUrls, + bookmarks = SyncBookmarks(planToWatchList, completedList, watchingList, onHoldList, droppedList), + watchProgress = mergedProgressItems, + reviews = mergedReviews, + bookmarkDetails = mergedBookmarkDetails, + watchProgressDetails = mergedProgressDetails, + plugins = mergedPlugins + ) + } + + private fun getRemoteWatchType(remote: SyncData, name: String): WatchType { + return when { + remote.bookmarks.planToWatch.contains(name) -> WatchType.PLANTOWATCH + remote.bookmarks.completed.contains(name) -> WatchType.COMPLETED + remote.bookmarks.watching.contains(name) -> WatchType.WATCHING + remote.bookmarks.onHold.contains(name) -> WatchType.ONHOLD + remote.bookmarks.dropped.contains(name) -> WatchType.DROPPED + else -> WatchType.NONE + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt new file mode 100644 index 00000000000..1ce25427a4b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt @@ -0,0 +1,751 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.preference.PreferenceManager +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData +import com.lagradost.cloudstream3.syncproviders.* +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.BookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.PosDur +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.ResumeWatching +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadHeaderCached +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.txt +import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.coroutines.* +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.text.SimpleDateFormat +import java.util.* + +class GoogleDriveSyncManager : AuthAPI() { + override var name = "Google Drive" + override val idPrefix = "googledrive" + override val icon = R.drawable.googledrive_logo + // We handle OAuth ourselves via a loopback server, not via the standard redirect flow + override val hasOAuth2 = false + override val hasInApp = true + override val inAppLoginRequirement: AuthLoginRequirement? = null + val mainUrl = "https://drive.google.com" + + @Volatile + var isSyncActive = false + + companion object { + private const val TAG = "GoogleDriveSync" + + // Your personal Google Cloud OAuth credentials + // Obfuscated to bypass GitHub Push Protection secret scanner + val DEFAULT_CLIENT_ID = "498034204497-gmo2jmrdd168f" + "6ds1te96p43vu194btv.apps.googleusercontent.com" + val DEFAULT_CLIENT_SECRET = "GOCSPX-O2w" + "uMYErUNI9-kQpSSVd" + "SZkDx9BH" + + fun getClientId(): String { + val ctx = context ?: return DEFAULT_CLIENT_ID + val sp = PreferenceManager.getDefaultSharedPreferences(ctx) + val custom = sp.getString("googledrive_custom_client_id", "") + return if (!custom.isNullOrBlank()) custom else DEFAULT_CLIENT_ID + } + + fun getClientSecret(): String { + val ctx = context ?: return DEFAULT_CLIENT_SECRET + val sp = PreferenceManager.getDefaultSharedPreferences(ctx) + val custom = sp.getString("googledrive_custom_client_secret", "") + return if (!custom.isNullOrBlank()) custom else DEFAULT_CLIENT_SECRET + } + } + + data class GoogleTokenResponse( + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String?, + @JsonProperty("expires_in") val expiresIn: Long, + @JsonProperty("token_type") val tokenType: String + ) + + data class GoogleRefreshResponse( + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String?, + @JsonProperty("expires_in") val expiresIn: Long + ) + + data class GoogleUser( + @JsonProperty("sub") val sub: String, + @JsonProperty("name") val name: String?, + @JsonProperty("picture") val picture: String?, + @JsonProperty("email") val email: String? + ) + + data class DriveFile( + @JsonProperty("id") val id: String, + @JsonProperty("name") val name: String + ) + + data class DriveFilesList( + @JsonProperty("files") val files: List + ) + + data class SyncSettings( + @JsonProperty("theme") val theme: String? = null, + @JsonProperty("autoPlayNext") val autoPlayNext: Boolean? = null, + @JsonProperty("defaultPlayer") val defaultPlayer: String? = null + ) + + data class SyncBookmarks( + @JsonProperty("planToWatch") val planToWatch: List = emptyList(), + @JsonProperty("completed") val completed: List = emptyList(), + @JsonProperty("watching") val watching: List = emptyList(), + @JsonProperty("onHold") val onHold: List = emptyList(), + @JsonProperty("dropped") val dropped: List = emptyList() + ) + + data class SyncWatchProgressItem( + @JsonProperty("mediaTitle") val mediaTitle: String, + @JsonProperty("season") val season: Int, + @JsonProperty("episode") val episode: Int, + @JsonProperty("timestampInSeconds") val timestampInSeconds: Long, + @JsonProperty("lastUpdated") val lastUpdated: String + ) + + data class SyncReviewItem( + @JsonProperty("mediaTitle") val mediaTitle: String, + @JsonProperty("rating") val rating: Int, + @JsonProperty("note") val note: String? + ) + + data class WatchProgressDetailsItem( + @JsonProperty("headerCached") val headerCached: DownloadHeaderCached?, + @JsonProperty("resumeWatching") val resumeWatching: ResumeWatching?, + @JsonProperty("posDur") val posDur: PosDur? + ) + + data class SyncPayload( + @JsonProperty("userSettings") val userSettings: SyncSettings? = null, + @JsonProperty("repositories") val repositories: List = emptyList(), + @JsonProperty("bookmarks") val bookmarks: SyncBookmarks = SyncBookmarks(), + @JsonProperty("watchProgress") val watchProgress: List = emptyList(), + @JsonProperty("reviews") val reviews: List = emptyList(), + @JsonProperty("bookmarkDetails") val bookmarkDetails: Map = emptyMap(), + @JsonProperty("watchProgressDetails") val watchProgressDetails: Map = emptyMap() + ) + + /** + * Starts a loopback HTTP server on a random port, opens the Google OAuth page + * in the browser, and waits for the redirect to capture the authorization code. + * + * Uses a dedicated Thread (not a coroutine) to survive Android backgrounding + * the app while the user is in the browser completing the OAuth flow. + */ + fun startOAuthLogin(ctx: Context) { + val clientId = getClientId() + + if (clientId.isBlank() || clientId == "REPLACE_WITH_YOUR_CLIENT_ID") { + showToast(txt("Please set your Google Cloud Client ID in Advanced Settings first")) + return + } + + // Use a dedicated non-daemon thread so Android doesn't kill it when the app is backgrounded + Thread { + var serverSocket: ServerSocket? = null + try { + // Bind to 127.0.0.1 explicitly to avoid IPv6 issues + val loopbackAddress = java.net.InetAddress.getByName("127.0.0.1") + serverSocket = ServerSocket(0, 1, loopbackAddress) + val port = serverSocket.localPort + serverSocket.soTimeout = 300_000 // 5 minute timeout to give user plenty of time + + val redirectUri = "http://127.0.0.1:$port" + val state = UUID.randomUUID().toString() + + val encodedClientId = java.net.URLEncoder.encode(clientId, "UTF-8") + val encodedRedirectUri = java.net.URLEncoder.encode(redirectUri, "UTF-8") + val scope = "https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email" + val encodedScope = java.net.URLEncoder.encode(scope, "UTF-8") + val encodedState = java.net.URLEncoder.encode(state, "UTF-8") + + val url = "https://accounts.google.com/o/oauth2/v2/auth" + + "?response_type=code" + + "&client_id=$encodedClientId" + + "&redirect_uri=$encodedRedirectUri" + + "&scope=$encodedScope" + + "&state=$encodedState" + + "&access_type=offline" + + "&prompt=consent" + + // Open the browser on the main thread + android.os.Handler(android.os.Looper.getMainLooper()).post { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(intent) + } catch (t: Throwable) { + logError(t) + showToast(txt("Failed to open browser")) + } + } + + Log.i(TAG, "Loopback server started on 127.0.0.1:$port, waiting for OAuth redirect...") + + // Wait for the browser redirect (blocks this thread for up to 5 min) + val clientSocket = serverSocket.accept() + val reader = BufferedReader(InputStreamReader(clientSocket.getInputStream())) + val requestLine = reader.readLine() ?: "" + + Log.i(TAG, "Received request: $requestLine") + + // Parse the GET request: GET /?code=AUTH_CODE&state=STATE HTTP/1.1 + val code: String? + val receivedState: String? + + if (requestLine.startsWith("GET")) { + val path = requestLine.split(" ")[1] // e.g., /?code=xxx&state=yyy + val queryString = path.substringAfter("?", "") + val params = queryString.split("&").associate { + val parts = it.split("=", limit = 2) + if (parts.size == 2) parts[0] to java.net.URLDecoder.decode(parts[1], "UTF-8") + else parts[0] to "" + } + code = params["code"] + receivedState = params["state"] + } else { + code = null + receivedState = null + } + + // Send a response to the browser + val writer = PrintWriter(clientSocket.getOutputStream(), true) + val responseHtml = if (code != null) { + """ + +
+

✓ Success!

+

You've been logged into Google Drive.
You can close this tab and return to Cloudstream.

+
+ """.trimIndent() + } else { + """ + +
+

✗ Login Failed

+

Could not retrieve authorization code.
Please try again from Cloudstream.

+
+ """.trimIndent() + } + + writer.println("HTTP/1.1 200 OK") + writer.println("Content-Type: text/html; charset=utf-8") + writer.println("Content-Length: ${responseHtml.toByteArray().size}") + writer.println("Connection: close") + writer.println() + writer.print(responseHtml) + writer.flush() + + clientSocket.close() + serverSocket.close() + serverSocket = null + + // Now exchange the code for tokens (runs on this background thread) + if (code != null) { + Log.i(TAG, "Got auth code, exchanging for tokens...") + try { + val tokenResponse = kotlinx.coroutines.runBlocking { + exchangeCodeForTokens(code, redirectUri) + } + if (tokenResponse != null) { + // Get user info + val userInfo = kotlinx.coroutines.runBlocking { + fetchUserInfo(tokenResponse.accessToken) + } + + // Store the auth data via the AccountManager + val now = System.currentTimeMillis() / 1000 + val token = AuthToken( + accessToken = tokenResponse.accessToken, + refreshToken = tokenResponse.refreshToken, + accessTokenLifetime = now + tokenResponse.expiresIn + ) + val user = AuthUser( + id = userInfo?.sub?.hashCode() ?: 0, + name = userInfo?.name ?: userInfo?.email ?: "Google User", + profilePicture = userInfo?.picture + ) + + // Store directly via AccountManager (mirrors AuthRepo.setupLogin) + val newAccount = AuthData(token = token, user = user) + val currentAccounts = AccountManager.accounts(idPrefix) + val newAccounts = if (currentAccounts.any { it.user.id == user.id }) { + currentAccounts.map { + if (it.user.id == user.id) newAccount else it + }.toTypedArray() + } else { + currentAccounts + newAccount + } + AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccountsId(idPrefix, user.id) + + android.os.Handler(android.os.Looper.getMainLooper()).post { + showToast(txt(R.string.authenticated_user, name)) + } + } else { + android.os.Handler(android.os.Looper.getMainLooper()).post { + showToast(txt(R.string.authenticated_user_fail, name)) + } + } + } catch (t: Throwable) { + logError(t) + android.os.Handler(android.os.Looper.getMainLooper()).post { + showToast(txt(R.string.authenticated_user_fail, name)) + } + } + } else { + android.os.Handler(android.os.Looper.getMainLooper()).post { + showToast(txt(R.string.authenticated_user_fail, name)) + } + } + } catch (t: Throwable) { + logError(t) + Log.e(TAG, "OAuth loopback server error", t) + android.os.Handler(android.os.Looper.getMainLooper()).post { + showToast(txt("Login timed out or failed. Please try again.")) + } + } finally { + try { + serverSocket?.close() + } catch (_: Throwable) {} + } + }.apply { + isDaemon = false // Ensure thread survives app backgrounding + name = "GoogleDriveOAuth" + start() + } + } + + private suspend fun exchangeCodeForTokens(code: String, redirectUri: String): GoogleTokenResponse? { + val clientId = getClientId() + val clientSecret = getClientSecret() + + val response = app.post( + "https://oauth2.googleapis.com/token", + data = mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "code" to code, + "grant_type" to "authorization_code", + "redirect_uri" to redirectUri + ) + ) + + return if (response.isSuccessful) { + response.parsed() + } else { + Log.e(TAG, "Token exchange failed: ${response.text}") + null + } + } + + private suspend fun fetchUserInfo(accessToken: String): GoogleUser? { + return try { + app.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers = mapOf("Authorization" to "Bearer $accessToken") + ).parsed() + } catch (t: Throwable) { + logError(t) + null + } + } + + // These are not used since we handle login entirely in startOAuthLogin(), + // but they must be implemented to satisfy the AuthAPI contract. + override fun loginRequest(): AuthLoginPage? = null + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? = null + + // The in-app login triggers our custom loopback OAuth flow + override suspend fun login(form: AuthLoginResponse): AuthToken? { + // This won't actually be called for us since we handle login via startOAuthLogin() + return null + } + + override suspend fun refreshToken(token: AuthToken): AuthToken? { + val rToken = token.refreshToken ?: return null + val clientId = getClientId() + val clientSecret = getClientSecret() + + val response = app.post( + "https://oauth2.googleapis.com/token", + data = mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "refresh_token" to rToken, + "grant_type" to "refresh_token" + ) + ).parsed() + + val now = System.currentTimeMillis() / 1000 + return AuthToken( + accessToken = response.accessToken, + refreshToken = response.refreshToken ?: token.refreshToken, + accessTokenLifetime = now + response.expiresIn + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val authHeader = token?.accessToken ?: return null + val user = app.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers = mapOf("Authorization" to "Bearer $authHeader") + ).parsed() + + return AuthUser( + id = user.sub.hashCode(), + name = user.name ?: user.email ?: "Google User", + profilePicture = user.picture + ) + } + + private suspend fun getOrCreateFileSync(accessToken: String): String { + val searchUrl = "https://www.googleapis.com/drive/v3/files?spaces=appDataFolder&q=name='cloudstream_sync.json'%20and%20'appDataFolder'%20in%20parents%20and%20trashed=false&fields=files(id,name)" + val list = app.get( + searchUrl, + headers = mapOf("Authorization" to "Bearer $accessToken") + ).parsed() + + val existingFile = list.files.firstOrNull() + if (existingFile != null) { + return existingFile.id + } + + val createUrl = "https://www.googleapis.com/drive/v3/files" + val createRes = app.post( + createUrl, + headers = mapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json" + ), + json = mapOf( + "name" to "cloudstream_sync.json", + "parents" to listOf("appDataFolder") + ) + ).parsed() + + return createRes.id + } + + suspend fun sync(context: Context): Boolean { + if (isSyncActive) return false + isSyncActive = true + try { + val auth = AccountManager.allApis.firstOrNull { it.idPrefix == idPrefix } ?: return false + val token = auth.authToken() ?: return false + val refreshedData = auth.freshAuth() ?: return false + val accessToken = refreshedData.token.accessToken ?: return false + + return withContext(Dispatchers.IO) { + try { + val fileId = getOrCreateFileSync(accessToken) + val downloadUrl = "https://www.googleapis.com/drive/v3/files/$fileId?alt=media" + val response = app.get( + downloadUrl, + headers = mapOf("Authorization" to "Bearer $accessToken") + ) + + val remotePayload = if (response.isSuccessful && response.text.isNotBlank()) { + try { + parseJson(response.text) + } catch (t: Throwable) { + logError(t) + SyncPayload() + } + } else { + SyncPayload() + } + + val mergedPayload = mergePayload(context, remotePayload) + + val uploadUrl = "https://www.googleapis.com/upload/drive/v3/files/$fileId?uploadType=media" + val uploadRes = app.put( + uploadUrl, + headers = mapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json" + ), + requestBody = mergedPayload.toJson().toRequestBody() + ) + + if (uploadRes.isSuccessful) { + val sp = PreferenceManager.getDefaultSharedPreferences(context) + sp.edit().putLong("googledrive_last_synced_time", System.currentTimeMillis()).apply() + true + } else { + false + } + } catch (t: Throwable) { + logError(t) + false + } + } + } finally { + isSyncActive = false + } + } + + private suspend fun mergePayload(context: Context, remote: SyncPayload): SyncPayload { + // 1. Settings + val sp = PreferenceManager.getDefaultSharedPreferences(context) + val localTheme = sp.getString(context.getString(R.string.app_theme_key), "AmoledLight") ?: "AmoledLight" + val localAutoplay = sp.getBoolean(context.getString(R.string.autoplay_next_key), true) + val localPlayer = sp.getString(context.getString(R.string.player_default_key), "") ?: "" + + val mergedTheme = remote.userSettings?.theme ?: localTheme + val mergedAutoplay = remote.userSettings?.autoPlayNext ?: localAutoplay + val mergedPlayer = remote.userSettings?.defaultPlayer ?: localPlayer + + withContext(Dispatchers.Main) { + sp.edit().apply { + putString(context.getString(R.string.app_theme_key), mergedTheme) + putBoolean(context.getString(R.string.autoplay_next_key), mergedAutoplay) + putString(context.getString(R.string.player_default_key), mergedPlayer) + apply() + } + } + + // 2. Repositories + val localRepos = RepositoryManager.getRepositories() + val localRepoUrls = localRepos.map { it.url } + val remoteRepoUrls = remote.repositories + val mergedRepoUrls = (localRepoUrls + remoteRepoUrls).distinct() + + for (url in mergedRepoUrls) { + if (!localRepoUrls.contains(url)) { + var repoName = url.substringAfterLast("/").substringBefore(".json").ifBlank { "Remote Repo" } + try { + val parsed = RepositoryManager.parseRepository(url) + if (parsed != null) { + repoName = parsed.name + } + } catch (t: Throwable) { + logError(t) + } + RepositoryManager.addRepository(RepositoryData(null, repoName, url)) + } + } + + // 3. Bookmarks + val localBookmarks = DataStoreHelper.getAllBookmarkedData() + val localBookmarkMap = localBookmarks.associateBy { it.name } + val remoteBookmarkMap = remote.bookmarkDetails + + val mergedBookmarkDetails = mutableMapOf() + val allBookmarkNames = (localBookmarkMap.keys + remoteBookmarkMap.keys).distinct() + + val planToWatchList = mutableListOf() + val completedList = mutableListOf() + val watchingList = mutableListOf() + val onHoldList = mutableListOf() + val droppedList = mutableListOf() + + for (name in allBookmarkNames) { + val local = localBookmarkMap[name] + val remoteItem = remoteBookmarkMap[name] + + val winner: BookmarkedData + val winnerWatchType: WatchType + + if (local != null && remoteItem != null) { + if (local.bookmarkedTime >= remoteItem.bookmarkedTime) { + winner = local + winnerWatchType = DataStoreHelper.getResultWatchState(local.id ?: 0) + } else { + winner = remoteItem + winnerWatchType = when { + remote.bookmarks.planToWatch.contains(name) -> WatchType.PLANTOWATCH + remote.bookmarks.completed.contains(name) -> WatchType.COMPLETED + remote.bookmarks.watching.contains(name) -> WatchType.WATCHING + remote.bookmarks.onHold.contains(name) -> WatchType.ONHOLD + remote.bookmarks.dropped.contains(name) -> WatchType.DROPPED + else -> WatchType.NONE + } + } + } else if (local != null) { + winner = local + winnerWatchType = DataStoreHelper.getResultWatchState(local.id ?: 0) + } else { + winner = remoteItem!! + winnerWatchType = when { + remote.bookmarks.planToWatch.contains(name) -> WatchType.PLANTOWATCH + remote.bookmarks.completed.contains(name) -> WatchType.COMPLETED + remote.bookmarks.watching.contains(name) -> WatchType.WATCHING + remote.bookmarks.onHold.contains(name) -> WatchType.ONHOLD + remote.bookmarks.dropped.contains(name) -> WatchType.DROPPED + else -> WatchType.NONE + } + } + + mergedBookmarkDetails[name] = winner + + when (winnerWatchType) { + WatchType.PLANTOWATCH -> planToWatchList.add(name) + WatchType.COMPLETED -> completedList.add(name) + WatchType.WATCHING -> watchingList.add(name) + WatchType.ONHOLD -> onHoldList.add(name) + WatchType.DROPPED -> droppedList.add(name) + else -> {} + } + + if (winner == remoteItem || DataStoreHelper.getResultWatchState(winner.id ?: 0) != winnerWatchType) { + if (winner.id != null) { + DataStoreHelper.setBookmarkedData(winner.id, winner) + DataStoreHelper.setResultWatchState(winner.id, winnerWatchType.internalId) + } + } + } + + // 4. Watch Progress + val localProgressKeys = mutableListOf() + val localProgressMap = mutableMapOf() + val localProgressItems = mutableListOf() + + val dateIsoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + val resumeIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() + for (parentId in resumeIds) { + val resume = DataStoreHelper.getLastWatched(parentId) + if (resume != null) { + val header = getKey(DOWNLOAD_HEADER_CACHE, parentId.toString()) + val posDur = resume.episodeId?.let { DataStoreHelper.getViewPos(it) } + val showName = header?.name ?: "Show $parentId" + val key = "${showName}_S${resume.season ?: 0}_E${resume.episode ?: 0}" + + val seconds = (posDur?.position ?: 0L) / 1000L + val dateStr = dateIsoFormatter.format(Date(resume.updateTime)) + + val progressItem = SyncWatchProgressItem( + mediaTitle = showName, + season = resume.season ?: 0, + episode = resume.episode ?: 0, + timestampInSeconds = seconds, + lastUpdated = dateStr + ) + localProgressItems.add(progressItem) + localProgressKeys.add(key) + localProgressMap[key] = WatchProgressDetailsItem(header, resume, posDur) + } + } + + val remoteProgressMap = remote.watchProgressDetails + val allProgressKeys = (localProgressMap.keys + remoteProgressMap.keys).distinct() + val mergedProgressDetails = mutableMapOf() + val mergedProgressItems = mutableListOf() + + for (key in allProgressKeys) { + val local = localProgressMap[key] + val remoteItem = remoteProgressMap[key] + + val winner: WatchProgressDetailsItem + val localTime = local?.resumeWatching?.updateTime ?: 0L + val remoteTime = try { + val remoteLastUpdated = remote.watchProgress.firstOrNull { + it.mediaTitle == (remoteItem?.headerCached?.name ?: "") && + it.season == (remoteItem?.resumeWatching?.season ?: 0) && + it.episode == (remoteItem?.resumeWatching?.episode ?: 0) + }?.lastUpdated + if (remoteLastUpdated != null) dateIsoFormatter.parse(remoteLastUpdated)?.time ?: 0L else 0L + } catch (e: Exception) { + 0L + } + + if (local != null && remoteItem != null) { + winner = if (localTime >= remoteTime) local else remoteItem + } else if (local != null) { + winner = local + } else { + winner = remoteItem!! + } + + mergedProgressDetails[key] = winner + + val showName = winner.headerCached?.name ?: "Unknown" + val seconds = (winner.posDur?.position ?: 0L) / 1000L + val updateTime = winner.resumeWatching?.updateTime ?: System.currentTimeMillis() + + mergedProgressItems.add( + SyncWatchProgressItem( + mediaTitle = showName, + season = winner.resumeWatching?.season ?: 0, + episode = winner.resumeWatching?.episode ?: 0, + timestampInSeconds = seconds, + lastUpdated = dateIsoFormatter.format(Date(updateTime)) + ) + ) + + if (winner == remoteItem) { + val head = winner.headerCached + val res = winner.resumeWatching + val pos = winner.posDur + if (head != null && res != null) { + setKey(DOWNLOAD_HEADER_CACHE, head.id.toString(), head) + DataStoreHelper.setLastWatched( + res.parentId, + res.episodeId, + res.episode, + res.season, + res.isFromDownload, + res.updateTime + ) + if (res.episodeId != null && pos != null) { + DataStoreHelper.setViewPos(res.episodeId, pos.position, pos.duration) + } + } + } + } + + // 5. Reviews + val localReviews = getKey>("user_reviews") ?: emptyList() + val remoteReviews = remote.reviews + val mergedReviews = (localReviews + remoteReviews).distinctBy { it.mediaTitle } + setKey("user_reviews", mergedReviews) + + return SyncPayload( + userSettings = SyncSettings(mergedTheme, mergedAutoplay, mergedPlayer), + repositories = mergedRepoUrls, + bookmarks = SyncBookmarks(planToWatchList, completedList, watchingList, onHoldList, droppedList), + watchProgress = mergedProgressItems, + reviews = mergedReviews, + bookmarkDetails = mergedBookmarkDetails, + watchProgressDetails = mergedProgressDetails + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index 29c3c0c1793..6ac17ae595c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -651,6 +651,7 @@ class KitsuApi: SyncAPI() { SyncWatchType.DROPPED -> KitsuStatusType.Dropped SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch SyncWatchType.REWATCHING -> KitsuStatusType.Watching + SyncWatchType.HISTORY -> KitsuStatusType.None } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index ba0195be6b8..194f05e6d8a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -326,6 +326,7 @@ class MALApi : SyncAPI() { SyncWatchType.DROPPED -> MalStatusType.Dropped SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch SyncWatchType.REWATCHING -> MalStatusType.Watching + SyncWatchType.HISTORY -> MalStatusType.None } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index f91d40f28e0..0321905eb9f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -449,4 +449,11 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } + + override fun onResume() { + super.onResume() + if (!com.lagradost.cloudstream3.ui.account.AccountSelectActivity.hasLoggedIn) { + finish() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt index ec0ef5c6bfb..48eee513a4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -10,7 +10,8 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), - NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24); + NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24), + HISTORY(6, R.string.type_history, R.drawable.ic_baseline_bookmark_24); companion object { fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE @@ -24,7 +25,8 @@ enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @Dr ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), - REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); + REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24), + HISTORY(6, R.string.type_history, R.drawable.ic_baseline_bookmark_24); companion object { fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index 1d6b41e5baf..32e8b1f6989 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -108,8 +108,9 @@ object AccountHelper { // Handle applying changes binding.applyBtt.setOnClickListener { - if (currentEditAccount.lockPin != null) { - // Ask for the current PIN + if (currentEditAccount.lockPin != null && currentEditAccount.lockPin == account.lockPin) { + // The account was already locked, and the PIN wasn't removed. + // We ask for the current PIN to authorize saving other changes (like name/image). showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> if (pin == null) return@showPinInputDialog // PIN is correct, proceed to update the account @@ -117,7 +118,8 @@ object AccountHelper { dialog.dismissSafe() } } else { - // No lock PIN set, proceed to update the account + // The account was NOT locked, OR the PIN was just changed/added (which already required verification). + // proceed to update the account without asking for the PIN again. accountEditCallback.invoke(currentEditAccount) dialog.dismissSafe() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt index ad323c7d124..9be5d6b0bd4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -54,10 +54,13 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { false ) + val isLauncherIntent = intent?.action == android.content.Intent.ACTION_MAIN && + intent?.hasCategory(android.content.Intent.CATEGORY_LAUNCHER) == true + // Sometimes we start this activity when we have already logged in // For example when using cloudstreamsearch:// // In those cases we want to just go to the main activity instantly - if (hasLoggedIn && !isEditingFromMainActivity) { + if (hasLoggedIn && !isEditingFromMainActivity && !isLauncherIntent) { navigateToMainActivity() return } @@ -67,27 +70,28 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { enableEdgeToEdgeCompat() setNavigationBarColorCompat(R.attr.primaryBlackBackground) + val isBiometricEnabled = isLayout(PHONE) && isAuthEnabled(this) && deviceHasPasswordPinLock(this) + + if (isBiometricEnabled) { + startBiometricAuthentication(this, R.string.biometric_authentication_title, false) + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + } else { + proceedAfterAuth() + } + } + + private fun proceedAfterAuth() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val skipStartup = settingsManager.getBoolean( getString(R.string.skip_startup_account_select_key), false ) || accounts.count() <= 1 - fun askBiometricAuth() { - - if (isLayout(PHONE) && isAuthEnabled(this)) { - if (deviceHasPasswordPinLock(this)) { - startBiometricAuthentication( - this, - R.string.biometric_authentication_title, - false - ) - - promptInfo?.let { prompt -> - biometricPrompt?.authenticate(prompt) - } - } - } - } + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) observe(accountViewModel.isAllowedLogin) { isAllowedLogin -> if (isAllowedLogin) { @@ -197,8 +201,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { } else 6 } } - - askBiometricAuth() } @SuppressLint("UnsafeIntentLaunch") @@ -211,9 +213,32 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { override fun onAuthenticationSuccess() { Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") + proceedAfterAuth() } override fun onAuthenticationError() { - finish() + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) + if (!isEditingFromMainActivity) { + finishAffinity() + } else { + finish() + } + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) + if (!isEditingFromMainActivity) { + finishAffinity() + } else { + @Suppress("DEPRECATION") + super.onBackPressed() + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index b68ef59625c..cbbf26ff1ac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -21,7 +21,9 @@ import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -48,6 +50,7 @@ import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.search.SearchAdapter @@ -647,7 +650,11 @@ class HomeFragment : BaseFragment( } homeChangeApi.setOnClickListener(apiChangeClickListener) homeSwitchAccount.setOnClickListener { - activity?.showAccountSelectLinear() + if (AccountManager.firebaseApi.auth.currentUser != null) { + findNavController().navigate(R.id.global_to_navigation_profile_selector) + } else { + activity?.showAccountSelectLinear() + } } homeMasterAdapter = HomeParentItemAdapterPreview( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 959806e566c..c03c508b07d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -61,6 +61,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.populateChips import androidx.core.graphics.toColorInt import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.SyncProfile +import androidx.navigation.findNavController class HomeParentItemAdapterPreview( private val viewModel: HomeViewModel, @@ -543,12 +546,46 @@ class HomeParentItemAdapterPreview( alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> - headProfilePic?.loadImage(currentAccount?.image) - alternateHeadProfilePic?.loadImage(currentAccount?.image) + if (AccountManager.firebaseApi.auth.currentUser == null) { + headProfilePic?.loadImage(currentAccount?.image) + alternateHeadProfilePic?.loadImage(currentAccount?.image) + } + } + + (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentSyncProfile) { currentSyncProfile -> + if (AccountManager.firebaseApi.auth.currentUser != null) { + val fallbackPhotoUrl = AccountManager.firebaseApi.auth.currentUser?.photoUrl?.toString() + val avatarUrl = currentSyncProfile?.avatarUrl ?: fallbackPhotoUrl + if (avatarUrl != null) { + if (avatarUrl.startsWith("avatar_")) { + val context = (headProfilePic ?: alternateHeadProfilePic)?.context + if (context != null) { + val resId = context.resources.getIdentifier(avatarUrl, "drawable", context.packageName) + if (resId != 0) { + headProfilePic?.setImageResource(resId) + alternateHeadProfilePic?.setImageResource(resId) + } else { + headProfilePic?.loadImage(avatarUrl) + alternateHeadProfilePic?.loadImage(avatarUrl) + } + } + } else { + headProfilePic?.loadImage(avatarUrl) + alternateHeadProfilePic?.loadImage(avatarUrl) + } + } else { + headProfilePic?.setImageResource(R.drawable.profile_bg_dark_blue) + alternateHeadProfilePic?.setImageResource(R.drawable.profile_bg_dark_blue) + } + } } headProfilePicCard?.setOnClickListener { - activity?.showAccountSelectLinear() + if (AccountManager.firebaseApi.auth.currentUser != null) { + activity?.findNavController(R.id.nav_host_fragment)?.navigate(R.id.global_to_navigation_profile_selector) + } else { + activity?.showAccountSelectLinear() + } } fun showAccountEditBox(context: Context): Boolean { @@ -578,7 +615,11 @@ class HomeParentItemAdapterPreview( } alternateHeadProfilePicCard?.setOnClickListener { - activity?.showAccountSelectLinear() + if (AccountManager.firebaseApi.auth.currentUser != null) { + activity?.findNavController(R.id.nav_host_fragment)?.navigate(R.id.global_to_navigation_profile_selector) + } else { + activity?.showAccountSelectLinear() + } } (binding as? FragmentHomeHeadTvBinding)?.apply { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index e0609c0e57b..825f3690274 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull @@ -12,6 +13,7 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity @@ -127,6 +129,8 @@ class HomeViewModel : ViewModel() { private val _currentAccount = MutableLiveData() val currentAccount: MutableLiveData = _currentAccount + val currentSyncProfile = AccountManager.firebaseApi.currentProfile.asLiveData(viewModelScope.coroutineContext) + private val _randomItems = MutableLiveData?>(null) val randomItems: LiveData?> = _randomItems diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index 7a42cea93f7..4aad4c32790 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -93,5 +93,8 @@ class DownloadedPlayerActivity : AppCompatActivity() { override fun onResume() { super.onResume() CommonActivity.setActivityInstance(this) + if (!com.lagradost.cloudstream3.ui.account.AccountSelectActivity.hasLoggedIn) { + finish() + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt index 0e6f1a3677d..cfa98387907 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -87,6 +87,8 @@ class PlayerView @JvmOverloads constructor( /** All gesture, volume, brightness and key-event logic lives here. */ val gestureHelper = PlayerGestureHelper(this) + private var originalOrientation: Int? = null + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ var isFullScreen: Boolean get() = gestureHelper.isFullScreen @@ -428,6 +430,9 @@ class PlayerView @JvmOverloads constructor( fun enterFullscreen(updateOrientation: () -> Unit = {}) { val activity = context as? Activity + if (originalOrientation == null) { + originalOrientation = activity?.resources?.configuration?.orientation + } if (isFullScreen) { activity?.hideSystemUI() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { @@ -442,7 +447,23 @@ class PlayerView @JvmOverloads constructor( fun exitFullscreen() { val activity = context as? Activity gestureHelper.resetZoomToDefault() - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + val targetOrientation = when (originalOrientation) { + android.content.res.Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + android.content.res.Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + activity?.requestedOrientation = targetOrientation + + if (targetOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + Handler(Looper.getMainLooper()).postDelayed({ + safe { + val act = context as? Activity + if (act != null && !act.isFinishing && !act.isDestroyed) { + act.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }, 1000) + } // Simply resets brightness and notch settings that might have been overridden. val lp = activity?.window?.attributes lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 38b24b26517..cbd15809db6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -340,6 +340,7 @@ open class ResultFragmentPhone : BaseFragment( } updateUIEvent -= ::updateUI + playerHostView?.exitFullscreen() playerHostView?.release() playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 7dfe3cf5988..2871dd6decb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -2158,7 +2158,13 @@ class ResultViewModel2 : ViewModel() { postPage(loadResponse, apiRepository) postSubscription(loadResponse) postFavorites(loadResponse) - _watchStatus.postValue(getResultWatchState(mainId)) + + val currentState = getResultWatchState(mainId) + if (currentState == WatchType.NONE) { + updateWatchStatus(WatchType.HISTORY, null) + } else { + _watchStatus.postValue(currentState) + } if (updateEpisodes) postEpisodes(loadResponse, mainId, updateFillers) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 8d96a6b140e..d62aaec60f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -17,6 +17,7 @@ import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import androidx.preference.SwitchPreference import androidx.recyclerview.widget.RecyclerView +import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast @@ -444,6 +445,15 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { //Hides the security category on TV as it's only Biometric for now getPref(R.string.pref_category_security_key)?.hideOn(TV or EMULATOR) + getPref(R.string.pair_tv_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { + try { + findNavController().navigate(R.id.navigation_pair_tv) + } catch (e: Exception) { + logError(e) + } + true + } + getPref(R.string.biometric_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { val ctx = context ?: return@setOnPreferenceClickListener false diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index e41109b5982..ef5fd45e9b3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -19,6 +19,7 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthRepo +import com.lagradost.cloudstream3.syncproviders.providers.FirebaseSyncManager import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -187,8 +188,38 @@ class SettingsFragment : BaseFragment( ${VideoDownloadManager.downloadProgressEvent.size}") **/ fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { - for (syncApi in accountManagers) { - val login = syncApi.authUser() + for (syncRepo in accountManagers) { + if (syncRepo.api is FirebaseSyncManager) { + val firebaseApi = syncRepo.api as FirebaseSyncManager + val profile = firebaseApi.currentProfile.value + if (profile != null) { + val fallback = firebaseApi.auth.currentUser?.photoUrl?.toString() + val pic = profile.avatarUrl.takeIf { !it.isNullOrBlank() } ?: fallback + if (pic != null) { + binding.settingsProfilePic.let { imageView -> + if (pic.startsWith("avatar_")) { + val ctx = imageView.context + val resId = ctx.resources.getIdentifier(pic, "drawable", ctx.packageName) + if (resId != 0) { + imageView.setImageResource(resId) + } else { + imageView.loadImage(pic) { + error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } + } + } + } else { + imageView.loadImage(pic) { + error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } + } + } + } + binding.settingsProfileText.text = profile.name + return true + } + } + } + + val login = syncRepo.authUser() val pic = login?.profilePicture ?: continue binding.settingsProfilePic.let { imageView -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c04215594e1..3e27a54d562 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -8,6 +8,15 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import androidx.preference.Preference +import androidx.preference.SwitchPreference +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.widget.EditText +import android.widget.LinearLayout import androidx.recyclerview.widget.LinearLayoutManager import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig @@ -282,6 +291,90 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } return@setOnPreferenceClickListener true // Return true for the listener } + + // --- Firebase Sync Handlers --- + val firebaseLoginPref = findPreference("firebase_login_key") + + firebaseLoginPref?.setOnPreferenceClickListener { + val isUserLoggedIn = AccountManager.firebaseApi.auth.currentUser != null + if (isUserLoggedIn) { + AlertDialog.Builder(requireContext(), R.style.AlertDialogCustom) + .setTitle("Logout") + .setMessage("Are you sure you want to logout from Firebase Sync?") + .setPositiveButton("Logout") { dialog, _ -> + ioSafe { + AccountManager.firebaseApi.auth.signOut() + withContext(Dispatchers.Main) { + updateFirebasePrefs() + showToast("Logged out of Firebase") + } + } + dialog.dismiss() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + .show() + } else { + findNavController().navigate(R.id.navigation_login) + } + return@setOnPreferenceClickListener true + } + + findPreference("firebase_sync_now_key")?.setOnPreferenceClickListener { + showToast("Syncing with Firebase...") + ioSafe { + val isSuccess = AccountManager.firebaseApi.syncLocalToFirestore(requireContext()) + withContext(Dispatchers.Main) { + if (isSuccess) { + showToast("Sync completed successfully") + } else { + showToast("Sync failed") + } + updateFirebasePrefs() + } + } + return@setOnPreferenceClickListener true + } + + // Advanced Google Drive Settings removed + } + + override fun onResume() { + super.onResume() + updateFirebasePrefs() + } + + private fun updateFirebasePrefs() { + val user = AccountManager.firebaseApi.auth.currentUser + + val loginPref = findPreference("firebase_login_key") + val syncNowPref = findPreference("firebase_sync_now_key") + val autoSyncPref = findPreference("firebase_auto_sync_key") + + if (user != null) { + loginPref?.title = "Manage Firebase Sync" + val name = user.displayName ?: user.email ?: "Firebase User" + loginPref?.summary = "Logged in as $name" + syncNowPref?.isEnabled = true + autoSyncPref?.isEnabled = true + + // Set the summary of "Sync Now" to show the last synced timestamp + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val lastSynced = settingsManager.getLong("firebase_last_synced_time", 0L) + if (lastSynced > 0L) { + val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(lastSynced)) + syncNowPref?.summary = "Last synced: $dateStr" + } else { + syncNowPref?.summary = "Never synced" + } + } else { + loginPref?.title = "Login to Firebase Sync" + loginPref?.summary = "Sync your settings, watch progress, and library" + syncNowPref?.isEnabled = false + autoSyncPref?.isEnabled = false + syncNowPref?.summary = "Manually perform bidirectional synchronization (Login required)" + } } private fun getBackupDirsForDisplay(): List { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/LanguageSetupFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/LanguageSetupFragment.kt new file mode 100644 index 00000000000..0ea2579e99c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/LanguageSetupFragment.kt @@ -0,0 +1,106 @@ +package com.lagradost.cloudstream3.ui.setup + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentLanguageSetupBinding +import com.lagradost.cloudstream3.databinding.ItemLanguageElegantBinding +import com.lagradost.cloudstream3.ui.settings.appLanguages +import com.lagradost.cloudstream3.ui.settings.getCurrentLocale +import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.utils.DataStore.setKey + +const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" + +class LanguageSetupFragment : Fragment() { + + private var _binding: FragmentLanguageSetupBinding? = null + private val binding get() = _binding!! + private var selectedLangIndex = 0 + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLanguageSetupBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val ctx = context ?: return + val current = getCurrentLocale(ctx) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + selectedLangIndex = languageTagsIETF.indexOf(current).takeIf { it >= 0 } ?: 0 + + val adapter = LanguageAdapter(languageNames) { index -> + selectedLangIndex = index + } + binding.languageRecycler.adapter = adapter + + binding.languageSetupNextButton.setOnClickListener { + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + PreferenceManager.getDefaultSharedPreferences(ctx).edit { + putString(getString(R.string.locale_key), langTagIETF) + } + + // Navigate based on sign in status + if (AccountManager.firebaseApi.auth.currentUser != null) { + // Do not mark setup as done yet, wait until they create a profile + findNavController().navigate(R.id.action_navigation_setup_language_to_navigation_profile_selector) + } else { + // Skipped sync, so onboarding is completely finished + ctx.setKey(HAS_DONE_SETUP_KEY, true) + findNavController().navigate(R.id.action_navigation_setup_language_to_navigation_home) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + inner class LanguageAdapter( + private val languages: List, + private val onLanguageSelected: (Int) -> Unit + ) : RecyclerView.Adapter() { + + inner class LanguageViewHolder(val binding: ItemLanguageElegantBinding) : RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + val prevIndex = selectedLangIndex + onLanguageSelected(bindingAdapterPosition) + notifyItemChanged(prevIndex) + notifyItemChanged(bindingAdapterPosition) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LanguageViewHolder { + return LanguageViewHolder( + ItemLanguageElegantBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindViewHolder(holder: LanguageViewHolder, position: Int) { + holder.binding.languageName.text = languages[position] + holder.binding.languageCheck.isVisible = (position == selectedLangIndex) + } + + override fun getItemCount() = languages.size + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt deleted file mode 100644 index e96a662c370..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.lagradost.cloudstream3.ui.setup - -import android.view.View -import android.widget.AbsListView -import android.widget.ArrayAdapter -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.CommonActivity -import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.settings.appLanguages -import com.lagradost.cloudstream3.ui.settings.getCurrentLocale -import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" - -class SetupFragmentLanguage : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) -) { - - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) - } - - override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { - // We don't want a crash for all users - safe { - val ctx = context ?: return@safe - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - - val arrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - - binding.apply { - // Icons may crash on some weird android versions? - safe { - val drawable = when { - BuildConfig.DEBUG -> R.drawable.cloud_2_gradient_debug - BuildConfig.FLAVOR == "prerelease" -> R.drawable.cloud_2_gradient_beta - else -> R.drawable.cloud_2_gradient - } - appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) - } - - val current = getCurrentLocale(ctx) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) - - arrayAdapter.addAll(languageNames) - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(currentIndex, true) - - listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } - } - - nextBtt.setOnClickListener { - // If no plugins go to plugins page - val nextDestination = if ( - PluginManager.getPluginsOnline().isEmpty() - && PluginManager.getPluginsLocal().isEmpty() - //&& PREBUILT_REPOSITORIES.isNotEmpty() - ) R.id.action_navigation_global_to_navigation_setup_extensions - else R.id.action_navigation_setup_language_to_navigation_setup_provider_languages - - findNavController().navigate( - nextDestination, - SetupFragmentExtensions.newInstance(true) - ) - } - - skipBtt.setOnClickListener { - setKey(HAS_DONE_SETUP_KEY, true) - findNavController().navigate(R.id.navigation_home) - } - } - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/WelcomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/WelcomeFragment.kt new file mode 100644 index 00000000000..0c7a42e1383 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/WelcomeFragment.kt @@ -0,0 +1,53 @@ +package com.lagradost.cloudstream3.ui.setup + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentWelcomeBinding +import com.lagradost.cloudstream3.syncproviders.AccountManager + +class WelcomeFragment : Fragment() { + + private var _binding: FragmentWelcomeBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentWelcomeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Check if already signed in, maybe skip to next step (handled by MainActivity mostly) + if (AccountManager.firebaseApi.auth.currentUser != null) { + findNavController().navigate(R.id.action_navigation_welcome_to_navigation_setup_language) + return + } + + binding.welcomeGetStarted.setOnClickListener { + // Get Started means we want them to sign up + findNavController().navigate(R.id.action_navigation_welcome_to_navigation_login) + } + + binding.welcomeSignIn.setOnClickListener { + findNavController().navigate(R.id.action_navigation_welcome_to_navigation_login) + } + + binding.welcomeSkip.setOnClickListener { + findNavController().navigate(R.id.action_navigation_welcome_to_navigation_setup_language) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt new file mode 100644 index 00000000000..97bbac85301 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt @@ -0,0 +1,112 @@ +package com.lagradost.cloudstream3.ui.sync + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.firebase.firestore.FirebaseFirestore +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentPairTvBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.DataStore.getKey +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.launch + +class FragmentPairTv : Fragment() { + private var _binding: FragmentPairTvBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentPairTvBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.pairBackBtn.setOnClickListener { + activity?.onBackPressed() + } + + binding.pairSubmitButton.setOnClickListener { + val code = binding.pairCodeInput.text.toString().trim().uppercase() + if (code.length != 6) { + Toast.makeText(context, "Pairing code must be exactly 6 characters.", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + submitPairingCode(code) + } + } + + private fun submitPairingCode(code: String) { + val ctx = context ?: return + val email = ctx.getKey("firebase_email") + val password = ctx.getKey("firebase_password") + + if (email.isNullOrBlank() || password.isNullOrBlank()) { + Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + return + } + + setLoading(true) + + lifecycleScope.launch { + try { + val firestore = FirebaseFirestore.getInstance() + val docRef = firestore.collection("pairing_codes").document(code) + val snapshot = docRef.get().await() + + if (!snapshot.exists()) { + Toast.makeText(ctx, "Invalid or expired pairing code.", Toast.LENGTH_SHORT).show() + setLoading(false) + return@launch + } + + val createdAt = snapshot.getLong("createdAt") ?: 0L + val status = snapshot.getString("status") + + if (status != "pending" || System.currentTimeMillis() - createdAt > 5 * 60 * 1000) { + Toast.makeText(ctx, "Pairing code has expired or is already paired.", Toast.LENGTH_SHORT).show() + setLoading(false) + return@launch + } + + // Update the document to authorize the TV + val updateData = hashMapOf( + "status" to "authorized", + "email" to email, + "password" to password + ) + docRef.update(updateData as Map).await() + + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + activity?.onBackPressed() + + } catch (e: Exception) { + logError(e) + Toast.makeText(ctx, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + } finally { + setLoading(false) + } + } + } + + private fun setLoading(isLoading: Boolean) { + binding.pairLoading.isVisible = isLoading + binding.pairCodeInput.isEnabled = !isLoading + binding.pairSubmitButton.isEnabled = !isLoading + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt new file mode 100644 index 00000000000..01b10607c98 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt @@ -0,0 +1,278 @@ +package com.lagradost.cloudstream3.ui.sync + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentLoginBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import androidx.navigation.fragment.findNavController +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.lagradost.cloudstream3.syncproviders.AccountManager + +class LoginFragment : Fragment() { + companion object { + const val TAG = "LoginFragment" + } + + private var _binding: FragmentLoginBinding? = null + private val binding get() = _binding!! + + private lateinit var auth: FirebaseAuth + private lateinit var googleSignInClient: GoogleSignInClient + + private val googleSignInLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java)!! + firebaseAuthWithGoogle(account.idToken!!) + } catch (e: ApiException) { + logError(e) + Toast.makeText(context, "Google sign in failed", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLoginBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + auth = FirebaseAuth.getInstance() + + if (com.lagradost.cloudstream3.ui.settings.Globals.isLayout(com.lagradost.cloudstream3.ui.settings.Globals.TV)) { + val code = generatePairingCode() + createPairingDocument(code) + displayQrCode(code) + startPairingListener(code) + } + + // Configure Google Sign In + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(getString(R.string.default_web_client_id)) + .requestEmail() + .build() + googleSignInClient = GoogleSignIn.getClient(requireActivity(), gso) + + binding.loginGoogleButton.setOnClickListener { + setLoading(true) + val signInIntent = googleSignInClient.signInIntent + googleSignInLauncher.launch(signInIntent) + } + + binding.loginSignInButton.setOnClickListener { + val email = binding.loginEmailInput.text.toString().trim() + val password = binding.loginPasswordInput.text.toString().trim() + if (email.isEmpty() || password.isEmpty()) { + Toast.makeText(context, "Please enter email and password", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + setLoading(true) + auth.signInWithEmailAndPassword(email, password) + .addOnCompleteListener(requireActivity()) { task -> + if (task.isSuccessful) { + context?.let { ctx -> + ctx.setKey("firebase_email", email) + ctx.setKey("firebase_password", password) + } + onLoginSuccess() + } else { + // If sign in fails, try to sign up + auth.createUserWithEmailAndPassword(email, password) + .addOnCompleteListener(requireActivity()) { signUpTask -> + if (signUpTask.isSuccessful) { + context?.let { ctx -> + ctx.setKey("firebase_email", email) + ctx.setKey("firebase_password", password) + } + onLoginSuccess() + } else { + Toast.makeText( + context, + "Authentication failed.", + Toast.LENGTH_SHORT + ).show() + setLoading(false) + } + } + } + } + } + + binding.loginSkipButton.setOnClickListener { + // Navigate out without syncing + if (requireContext().getKey(HAS_DONE_SETUP_KEY, false) != true) { + findNavController().navigate(R.id.action_navigation_login_to_navigation_setup_language) + } else { + activity?.onBackPressed() + } + } + } + + private fun firebaseAuthWithGoogle(idToken: String) { + val credential = GoogleAuthProvider.getCredential(idToken, null) + auth.signInWithCredential(credential) + .addOnCompleteListener(requireActivity()) { task -> + if (task.isSuccessful) { + onLoginSuccess() + } else { + Toast.makeText(context, "Authentication Failed.", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } + } + + private fun onLoginSuccess() { + val ctx = context ?: return + lifecycleScope.launch(Dispatchers.IO) { + AccountManager.firebaseApi.syncLocalToFirestore(ctx) + + withContext(Dispatchers.Main) { + setLoading(false) + Toast.makeText(ctx, "Signed in successfully!", Toast.LENGTH_SHORT).show() + // Navigate to appropriate next step + if (ctx.getKey(HAS_DONE_SETUP_KEY, false) != true) { + findNavController().navigate(R.id.action_navigation_login_to_navigation_setup_language) + } else { + activity?.navigate(R.id.action_navigation_login_to_navigation_profile_selector) + } + } + } + } + + private fun setLoading(isLoading: Boolean) { + binding.loginLoading.isVisible = isLoading + binding.loginGoogleButton.isEnabled = !isLoading + binding.loginSignInButton.isEnabled = !isLoading + binding.loginEmailInput.isEnabled = !isLoading + binding.loginPasswordInput.isEnabled = !isLoading + } + + private fun generatePairingCode(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return (1..6) + .map { chars.random() } + .joinToString("") + } + + private fun createPairingDocument(code: String) { + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + val data = hashMapOf( + "status" to "pending", + "createdAt" to System.currentTimeMillis() + ) + firestore.collection("pairing_codes") + .document(code) + .set(data) + .addOnFailureListener { e -> + logError(e) + } + } + + private fun displayQrCode(code: String) { + val qrData = "cloudstreamapp://pair?code=$code" + val qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=" + android.net.Uri.encode(qrData) + + val density = resources.displayMetrics.density + val sizePx = (200 * density).toInt() + binding.loginLogo.layoutParams.width = sizePx + binding.loginLogo.layoutParams.height = sizePx + binding.loginLogo.requestLayout() + + binding.loginLogo.loadImage(qrUrl) + + binding.loginTitle.text = "Pair your device" + binding.loginSubtitle.text = "Scan this QR code with your phone, or enter this code in Settings -> Pair TV:\n\nCode: $code" + } + + private var pairingListener: com.google.firebase.firestore.ListenerRegistration? = null + + private fun startPairingListener(code: String) { + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + pairingListener = firestore.collection("pairing_codes") + .document(code) + .addSnapshotListener { snapshot, e -> + if (e != null) { + logError(e) + return@addSnapshotListener + } + + if (snapshot != null && snapshot.exists()) { + val status = snapshot.getString("status") + if (status == "authorized") { + val email = snapshot.getString("email") + val password = snapshot.getString("password") + if (!email.isNullOrBlank() && !password.isNullOrBlank()) { + stopPairingListener() + loginWithCredentials(email, password, code) + } + } + } + } + } + + private fun stopPairingListener() { + pairingListener?.remove() + pairingListener = null + } + + private fun loginWithCredentials(email: String, password: String, code: String) { + val act = activity ?: return + setLoading(true) + auth.signInWithEmailAndPassword(email, password) + .addOnCompleteListener(act) { task -> + if (task.isSuccessful) { + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + firestore.collection("pairing_codes").document(code).delete() + onLoginSuccess() + } else { + auth.createUserWithEmailAndPassword(email, password) + .addOnCompleteListener(act) { signUpTask -> + if (signUpTask.isSuccessful) { + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + firestore.collection("pairing_codes").document(code).delete() + onLoginSuccess() + } else { + Toast.makeText(context, "Pairing login failed.", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } + } + } + } + + override fun onDestroyView() { + stopPairingListener() + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/PinEntryDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/PinEntryDialog.kt new file mode 100644 index 00000000000..4c084c9ffe4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/PinEntryDialog.kt @@ -0,0 +1,195 @@ +package com.lagradost.cloudstream3.ui.sync + +import android.animation.ObjectAnimator +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.core.view.isVisible +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DialogPinEntryBinding +import com.lagradost.cloudstream3.syncproviders.SyncProfile +import com.lagradost.cloudstream3.ui.BaseDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.security.MessageDigest + +class PinEntryDialog : BaseDialogFragment( + BaseFragment.BindingCreator.Inflate(DialogPinEntryBinding::inflate) +) { + companion object { + const val TAG = "PinEntryDialog" + private const val ARG_PROFILE = "arg_profile" + + fun newInstance(profile: SyncProfile): PinEntryDialog { + val args = Bundle() + args.putString(ARG_PROFILE, profile.toJson()) + val fragment = PinEntryDialog() + fragment.arguments = args + return fragment + } + } + + var onPinVerified: (() -> Unit)? = null + + private var profile: SyncProfile? = null + private var enteredPin = StringBuilder() + + override fun fixLayout(view: View) { + // Full screen safe implementation + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, 0) + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setBackgroundDrawableResource(android.R.color.transparent) + } + } + + override fun onBindingCreated(binding: DialogPinEntryBinding) { + val profileJson = arguments?.getString(ARG_PROFILE) + if (!profileJson.isNullOrEmpty()) { + try { + profile = parseJson(profileJson) + } catch (e: Exception) { + e.printStackTrace() + } + } + + setupUI() + } + + private fun setupUI() { + val binding = binding ?: return + val currentProfile = profile ?: run { + dismiss() + return + } + + binding.pinProfileName.text = currentProfile.name + + // Set avatar image + val avatarName = currentProfile.avatarUrl ?: "avatar_1" + val context = context ?: return + val resId = context.resources.getIdentifier(avatarName, "drawable", context.packageName) + if (resId != 0) { + binding.pinAvatar.setImageResource(resId) + } + + // Set accent color border + val accentColor = currentProfile.color ?: Color.parseColor("#E50914") + binding.pinAvatarCard.strokeColor = accentColor + + setupNumpad() + } + + private fun setupNumpad() { + val binding = binding ?: return + + // Set click listeners for numbers 0-9 + val numberKeys = listOf( + binding.key0 to "0", + binding.key1 to "1", + binding.key2 to "2", + binding.key3 to "3", + binding.key4 to "4", + binding.key5 to "5", + binding.key6 to "6", + binding.key7 to "7", + binding.key8 to "8", + binding.key9 to "9" + ) + + for ((keyView, digit) in numberKeys) { + keyView.setOnClickListener { + if (enteredPin.length < 4) { + enteredPin.append(digit) + updatePinDots() + binding.pinErrorText.visibility = View.INVISIBLE + + if (enteredPin.length == 4) { + verifyPin() + } + } + } + } + + binding.keyBackspace.setOnClickListener { + if (enteredPin.isNotEmpty()) { + enteredPin.deleteCharAt(enteredPin.length - 1) + updatePinDots() + binding.pinErrorText.visibility = View.INVISIBLE + } + } + + binding.keyCancel.setOnClickListener { + dismiss() + } + } + + private fun updatePinDots() { + val binding = binding ?: return + val dots = listOf(binding.pinDot1, binding.pinDot2, binding.pinDot3, binding.pinDot4) + + for (i in dots.indices) { + if (i < enteredPin.length) { + dots[i].setImageResource(R.drawable.pin_dot_filled) + } else { + dots[i].setImageResource(R.drawable.pin_dot_empty) + } + } + } + + private fun verifyPin() { + val currentProfile = profile ?: return + val pin = enteredPin.toString() + val hashedInput = sha256(pin) + + if (hashedInput == currentProfile.pinHash) { + onPinVerified?.invoke() + dismiss() + } else { + // Shake dots to indicate wrong PIN + shakeDots() + + // Show error message + binding?.pinErrorText?.visibility = View.VISIBLE + + // Clear PIN + enteredPin.clear() + + // Reset dots after a brief delay + view?.postDelayed({ + updatePinDots() + }, 300) + } + } + + private fun shakeDots() { + val firstDot = binding?.pinDot1 ?: return + val container = firstDot.parent as? View ?: return + + ObjectAnimator.ofFloat( + container, + "translationX", + 0f, 25f, -25f, 25f, -25f, 15f, -15f, 6f, -6f, 0f + ).apply { + duration = 500 + start() + } + } + + private fun sha256(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileAdapter.kt new file mode 100644 index 00000000000..28ceb6b7aa0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileAdapter.kt @@ -0,0 +1,120 @@ +package com.lagradost.cloudstream3.ui.sync + +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ItemProfileBinding +import com.lagradost.cloudstream3.syncproviders.SyncProfile +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage + +class ProfileAdapter( + private val clickListener: (SyncProfile, Boolean) -> Unit +) : NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.id == b.id }, + contentSame = { a, b -> + a.name == b.name && + a.avatarUrl == b.avatarUrl && + a.pinHash == b.pinHash && + a.color == b.color + } + ) +) { + + companion object { + const val ADD_PROFILE_ID = "add_profile" + } + + var isEditMode: Boolean = false + set(value) { + if (field != value) { + field = value + notifyDataSetChanged() + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ItemProfileBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ItemProfileBinding ?: return + clearImage(binding.itemProfileAvatar) + } + + override fun onBindContent(holder: ViewHolderState, item: SyncProfile, position: Int) { + val binding = holder.view as? ItemProfileBinding ?: return + val context = binding.root.context + + binding.apply { + if (item.id == ADD_PROFILE_ID) { + // Add Profile Item Styling + itemProfileName.text = "Add Profile" + itemProfileAvatar.setImageResource(R.drawable.ic_baseline_add_24) + itemProfileAvatar.setPadding(32, 32, 32, 32) + itemProfileAvatar.imageTintList = ColorStateList.valueOf( + context.getColor(R.color.white) + ) + + // Dotted or simple gray stroke + itemProfileCard.strokeColor = context.getColor(R.color.grayTextColor) + itemProfileCard.strokeWidth = 2 + + itemProfileLock.isVisible = false + itemProfileEditOverlay.isVisible = false + itemProfileAccent.setBackgroundColor(Color.TRANSPARENT) + } else { + // Regular Profile Item Styling + itemProfileName.text = item.name + itemProfileAvatar.setPadding(0, 0, 0, 0) + itemProfileAvatar.imageTintList = null + + // Load custom avatar, resource avatar or fallback + val avatarUrl = item.avatarUrl + if (!avatarUrl.isNullOrEmpty()) { + val resId = context.resources.getIdentifier(avatarUrl, "drawable", context.packageName) + if (resId != 0) { + itemProfileAvatar.setImageResource(resId) + } else { + // In case of custom path (future-proofing) or if image library loader is needed + itemProfileAvatar.loadImage(avatarUrl) + } + } else { + itemProfileAvatar.setImageResource(R.drawable.avatar_1) + } + + // Show accent boundary color if specified + val profileColor = item.color + if (profileColor != null) { + itemProfileCard.strokeColor = profileColor + itemProfileCard.strokeWidth = 4 + itemProfileAccent.setBackgroundColor((profileColor and 0x00FFFFFF) or 0x1A000000) + } else { + itemProfileCard.strokeColor = Color.TRANSPARENT + itemProfileCard.strokeWidth = 0 + itemProfileAccent.setBackgroundColor(Color.TRANSPARENT) + } + + // Overlay status checks + itemProfileLock.isVisible = item.isLocked + itemProfileEditOverlay.isVisible = isEditMode + } + + root.setOnClickListener { + clickListener.invoke(item, isEditMode) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileEditorDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileEditorDialog.kt new file mode 100644 index 00000000000..ce11e025b7f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileEditorDialog.kt @@ -0,0 +1,350 @@ +package com.lagradost.cloudstream3.ui.sync + +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DialogProfileEditorBinding +import com.lagradost.cloudstream3.databinding.ItemAvatarSelectBinding +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.SyncProfile +import com.lagradost.cloudstream3.ui.BaseDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.UIHelper.toPx +import kotlinx.coroutines.launch +import java.security.MessageDigest + +class ProfileEditorDialog : BaseDialogFragment( + BaseFragment.BindingCreator.Inflate(DialogProfileEditorBinding::inflate) +) { + companion object { + const val TAG = "ProfileEditorDialog" + private const val ARG_PROFILE = "arg_profile" + + fun newInstance(profile: SyncProfile?): ProfileEditorDialog { + val args = Bundle() + profile?.let { + args.putString(ARG_PROFILE, it.toJson()) + } + val fragment = ProfileEditorDialog() + fragment.arguments = args + return fragment + } + } + + var onProfileSaved: (() -> Unit)? = null + + private var profile: SyncProfile? = null + + // Available Netflix-style accent colors + private val colors = arrayOf( + Color.parseColor("#E50914"), // Red + Color.parseColor("#54B4E4"), // Blue + Color.parseColor("#E5007A"), // Pink + Color.parseColor("#2CA01C"), // Green + Color.parseColor("#8B2CA0"), // Purple + Color.parseColor("#F57C00"), // Orange + Color.parseColor("#FFC20E") // Yellow + ) + + // 12 Avatar resources in drawable + private val avatars = arrayOf( + "avatar_1", "avatar_2", "avatar_3", "avatar_4", + "avatar_5", "avatar_6", "avatar_7", "avatar_8", + "avatar_9", "avatar_10", "avatar_11", "avatar_12" + ) + + private var selectedColor: Int = Color.parseColor("#E50914") + private var selectedAvatar: String = "avatar_1" + + override fun fixLayout(view: View) { + // Safe empty implementation, system bars handled by parent full-screen dialog container + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Dialog style + setStyle(STYLE_NO_TITLE, 0) + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + setBackgroundDrawableResource(android.R.color.transparent) + } + } + + override fun onBindingCreated(binding: DialogProfileEditorBinding) { + // Parse profile argument if editing + val profileJson = arguments?.getString(ARG_PROFILE) + if (!profileJson.isNullOrEmpty()) { + try { + profile = parseJson(profileJson) + } catch (e: Exception) { + e.printStackTrace() + } + } + + setupUI() + } + + private fun setupUI() { + val binding = binding ?: return + val currentProfile = profile + + if (currentProfile != null) { + // Edit Mode + binding.profileEditorTitle.text = "Edit Profile" + binding.profileEditorName.setText(currentProfile.name) + + selectedAvatar = currentProfile.avatarUrl ?: "avatar_1" + selectedColor = currentProfile.color ?: Color.parseColor("#E50914") + + // Set PIN status + binding.profileEditorPinSwitch.isChecked = currentProfile.isLocked + binding.profileEditorPinSection.isVisible = currentProfile.isLocked + if (currentProfile.isLocked) { + binding.profileEditorPinInput.setHint("••••") + } + + // Show delete button + binding.profileEditorDeleteBtn.isVisible = true + } else { + // Create Mode + binding.profileEditorTitle.text = "Create Profile" + selectedAvatar = "avatar_1" + selectedColor = Color.parseColor("#E50914") + + binding.profileEditorPinSwitch.isChecked = false + binding.profileEditorPinSection.isVisible = false + + // Hide delete button + binding.profileEditorDeleteBtn.isVisible = false + } + + updateAvatarPreview() + setupAvatarList() + setupColorList() + setupListeners() + } + + private fun setupListeners() { + val binding = binding ?: return + + binding.profileEditorPinSwitch.setOnCheckedChangeListener { _, isChecked -> + binding.profileEditorPinSection.isVisible = isChecked + if (!isChecked) { + binding.profileEditorPinInput.text = null + } + } + + binding.profileEditorCancelBtn.setOnClickListener { + dismiss() + } + + binding.profileEditorSaveBtn.setOnClickListener { + saveProfileData() + } + + binding.profileEditorDeleteBtn.setOnClickListener { + deleteProfileData() + } + } + + private fun updateAvatarPreview() { + val binding = binding ?: return + val context = context ?: return + + val resId = context.resources.getIdentifier(selectedAvatar, "drawable", context.packageName) + if (resId != 0) { + binding.profileEditorAvatarPreview.setImageResource(resId) + } + + binding.profileEditorAvatarCard.strokeColor = selectedColor + } + + private fun setupAvatarList() { + val binding = binding ?: return + val context = context ?: return + + val layoutManager = GridLayoutManager(context, 4, RecyclerView.VERTICAL, false) + binding.profileEditorAvatarList.layoutManager = layoutManager + + val adapter = object : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AvatarViewHolder { + val itemBinding = ItemAvatarSelectBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return AvatarViewHolder(itemBinding) + } + + override fun onBindViewHolder(holder: AvatarViewHolder, position: Int) { + val avatarName = avatars[position] + val ctx = holder.binding.root.context + val resId = ctx.resources.getIdentifier(avatarName, "drawable", ctx.packageName) + if (resId != 0) { + holder.binding.itemAvatarImage.setImageResource(resId) + } + + val isSelected = selectedAvatar == avatarName + holder.binding.itemAvatarCard.strokeWidth = if (isSelected) 3.toPx else 0 + holder.binding.itemAvatarCard.strokeColor = selectedColor + + holder.binding.root.setOnClickListener { + val prevSelectedName = selectedAvatar + selectedAvatar = avatarName + updateAvatarPreview() + + // Notify items to redraw + notifyDataSetChanged() + } + } + + override fun getItemCount(): Int = avatars.size + } + + binding.profileEditorAvatarList.adapter = adapter + } + + private fun setupColorList() { + val binding = binding ?: return + val context = context ?: return + + binding.profileEditorColorList.removeAllViews() + + for (color in colors) { + val card = MaterialCardView(context).apply { + val size = 44.toPx + layoutParams = LinearLayout.LayoutParams(size, size).apply { + setMargins(8.toPx, 4.toPx, 8.toPx, 4.toPx) + } + radius = 22.toPx.toFloat() + setCardBackgroundColor(ColorStateList.valueOf(color)) + setCardElevation(0f) + strokeWidth = if (selectedColor == color) 3.toPx else 0 + strokeColor = Color.WHITE + + setOnClickListener { + selectedColor = color + updateAvatarPreview() + + // Re-render color list to show selection border + setupColorList() + + // Redraw avatars list to match new accent color + binding.profileEditorAvatarList.adapter?.notifyDataSetChanged() + } + } + binding.profileEditorColorList.addView(card) + } + } + + private fun saveProfileData() { + val binding = binding ?: return + val name = binding.profileEditorName.text.toString().trim() + + if (name.isEmpty()) { + binding.profileEditorNameLayout.error = "Name cannot be empty" + return + } + + binding.profileEditorNameLayout.error = null + val pinInput = binding.profileEditorPinInput.text.toString().trim() + var pinHashResult: String? = profile?.pinHash + + if (binding.profileEditorPinSwitch.isChecked) { + if (profile?.isLocked == true && pinInput.isEmpty()) { + // Keeping existing locked PIN, pinHash remains as is + } else if (pinInput.length != 4) { + Toast.makeText(context, "PIN must be exactly 4 digits", Toast.LENGTH_SHORT).show() + return + } else { + // New or updated PIN + pinHashResult = sha256(pinInput) + } + } else { + // Disabled lock PIN + pinHashResult = null + } + + lifecycleScope.launch { + binding.profileEditorSaveBtn.isEnabled = false + binding.profileEditorCancelBtn.isEnabled = false + + try { + val targetProfile = profile ?: SyncProfile() + targetProfile.name = name + targetProfile.avatarUrl = selectedAvatar + targetProfile.color = selectedColor + targetProfile.pinHash = pinHashResult + + val success = AccountManager.firebaseApi.saveProfile(targetProfile) + if (success) { + Toast.makeText(context, "Profile saved!", Toast.LENGTH_SHORT).show() + onProfileSaved?.invoke() + dismiss() + } else { + Toast.makeText(context, "Failed to save profile", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(context, "Error saving profile: ${e.message}", Toast.LENGTH_SHORT).show() + } finally { + binding.profileEditorSaveBtn.isEnabled = true + binding.profileEditorCancelBtn.isEnabled = true + } + } + } + + private fun deleteProfileData() { + val currentProfile = profile ?: return + val binding = binding ?: return + + androidx.appcompat.app.AlertDialog.Builder(requireContext(), R.style.AlertDialogCustom) + .setTitle("Delete Profile") + .setMessage("Are you sure you want to delete '${currentProfile.name}'? This action cannot be undone.") + .setPositiveButton("Delete") { _, _ -> + lifecycleScope.launch { + binding.profileEditorDeleteBtn.isEnabled = false + try { + val success = AccountManager.firebaseApi.deleteProfile(currentProfile.id) + if (success) { + Toast.makeText(context, "Profile deleted", Toast.LENGTH_SHORT).show() + onProfileSaved?.invoke() + dismiss() + } else { + Toast.makeText(context, "Failed to delete profile", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } finally { + binding.profileEditorDeleteBtn.isEnabled = true + } + } + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun sha256(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + class AvatarViewHolder(val binding: ItemAvatarSelectBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt new file mode 100644 index 00000000000..b8f88bdbf0b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt @@ -0,0 +1,195 @@ +package com.lagradost.cloudstream3.ui.sync + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentProfileSelectorBinding +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.SyncProfile +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import kotlinx.coroutines.launch +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY + +class ProfileSelectorFragment : Fragment() { + companion object { + const val TAG = "ProfileSelectorFragment" + var hasFirebaseLoggedIn = false + } + + private var _binding: FragmentProfileSelectorBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: ProfileAdapter + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentProfileSelectorBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + setupListeners() + } + + override fun onResume() { + super.onResume() + loadProfiles() + } + + private fun setupRecyclerView() { + adapter = ProfileAdapter { profile, isEditMode -> + handleProfileClick(profile, isEditMode) + } + + val screenWidthDp = context?.resources?.configuration?.screenWidthDp ?: 320 + val spanCount = if (screenWidthDp >= 600) 4 else 2 + + binding.profileSelectorGrid.apply { + layoutManager = GridLayoutManager(context, spanCount) + adapter = this@ProfileSelectorFragment.adapter + } + } + + private fun setupListeners() { + binding.profileSelectorManageBtn.setOnClickListener { + val nextEditMode = !adapter.isEditMode + adapter.isEditMode = nextEditMode + + if (nextEditMode) { + binding.profileSelectorManageBtn.text = "Done" + binding.profileSelectorTitle.text = "Manage Profiles" + } else { + binding.profileSelectorManageBtn.text = "Manage" + binding.profileSelectorTitle.text = "Who's Watching?" + } + } + + binding.profileSelectorBackBtn.setOnClickListener { + activity?.onBackPressed() + } + + binding.profileSelectorInfo.setOnClickListener { + // Skip sync + val ctx = requireContext() + if (ctx.getKey(HAS_DONE_SETUP_KEY, false) != true) { + ctx.setKey(HAS_DONE_SETUP_KEY, true) + activity?.navigate(R.id.action_navigation_profile_selector_to_navigation_home) + } else { + activity?.onBackPressed() + } + } + } + + private fun loadProfiles() { + binding.profileSelectorLoading.isVisible = true + lifecycleScope.launch { + try { + val profiles = AccountManager.firebaseApi.getProfiles() + val mutableProfiles = profiles.toMutableList() + + if (mutableProfiles.size < 5) { + mutableProfiles.add( + SyncProfile( + id = ProfileAdapter.ADD_PROFILE_ID, + name = "Add Profile" + ) + ) + } + + adapter.submitList(mutableProfiles) + } catch (e: Exception) { + logError(e) + Toast.makeText(context, "Failed to load profiles", Toast.LENGTH_SHORT).show() + } finally { + binding.profileSelectorLoading.isVisible = false + } + } + } + + private fun handleProfileClick(profile: SyncProfile, isEditMode: Boolean) { + if (profile.id == ProfileAdapter.ADD_PROFILE_ID) { + val dialog = ProfileEditorDialog.newInstance(null) + dialog.onProfileSaved = { loadProfiles() } + dialog.show(childFragmentManager, ProfileEditorDialog.TAG) + } else if (isEditMode) { + val dialog = ProfileEditorDialog.newInstance(profile) + dialog.onProfileSaved = { loadProfiles() } + dialog.show(childFragmentManager, ProfileEditorDialog.TAG) + } else { + // Regular selection mode + if (profile.isLocked) { + val dialog = PinEntryDialog.newInstance(profile) + dialog.onPinVerified = { + selectProfileAndExit(profile) + } + dialog.show(childFragmentManager, PinEntryDialog.TAG) + } else { + selectProfileAndExit(profile) + } + } + } + + private fun selectProfileAndExit(profile: SyncProfile) { + val ctx = context ?: return + lifecycleScope.launch { + _binding?.profileSelectorLoading?.isVisible = true + try { + profile.lastUsed = System.currentTimeMillis() + AccountManager.firebaseApi.saveProfile(profile) + AccountManager.firebaseApi.selectProfile(profile) + + // Trigger real-time sync with local stores + val success = AccountManager.firebaseApi.syncLocalToFirestore(ctx) + if (success) { + Toast.makeText( + ctx, + "Switched to ${profile.name} and synced!", + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + ctx, + "Switched to ${profile.name}", + Toast.LENGTH_SHORT + ).show() + } + + + hasFirebaseLoggedIn = true + + if (ctx.getKey(HAS_DONE_SETUP_KEY, false) != true) { + ctx.setKey(HAS_DONE_SETUP_KEY, true) + activity?.navigate(R.id.action_navigation_profile_selector_to_navigation_home) + } else { + activity?.onBackPressed() + } + } catch (e: Exception) { + logError(e) + } finally { + _binding?.profileSelectorLoading?.isVisible = false + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 19caead21ee..d22df65ca05 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -31,6 +31,12 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.Calendar import java.util.Date import java.util.GregorianCalendar @@ -74,6 +80,22 @@ class UserPreferenceDelegate( } object DataStoreHelper { + private var syncJob: Job? = null + + private fun triggerGoogleDriveSync() { + val ctx = context + if (ctx != null && !AccountManager.firebaseApi.isSyncActive) { + val sp = androidx.preference.PreferenceManager.getDefaultSharedPreferences(ctx) + if (sp.getBoolean("firebase_auto_sync_key", true)) { + syncJob?.cancel() + syncJob = GlobalScope.launch(Dispatchers.IO) { + delay(5000L) // 5 second debounce + AccountManager.firebaseApi.syncLocalToFirestore(ctx) + } + } + } + } + // be aware, don't change the index of these as Account uses the index for the art val profileImages = arrayOf( R.drawable.profile_bg_dark_blue, @@ -233,8 +255,8 @@ object DataStoreHelper { } data class PosDur( - @JsonProperty("position") val position: Long, - @JsonProperty("duration") val duration: Long + @JsonProperty("position") val position: Long = 0L, + @JsonProperty("duration") val duration: Long = 0L ) fun PosDur.fixVisual(): PosDur { @@ -284,16 +306,16 @@ object DataStoreHelper { } data class SubscribedData( - @JsonProperty("subscribedTime") val subscribedTime: Long, - @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, - override var id: Int?, - override val latestUpdatedTime: Long, - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType?, - override var posterUrl: String?, - override val year: Int?, + @JsonProperty("subscribedTime") val subscribedTime: Long = 0L, + @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map = emptyMap(), + override var id: Int? = null, + override val latestUpdatedTime: Long = 0L, + override val name: String = "", + override val url: String = "", + override val apiName: String = "", + override var type: TvType? = null, + override var posterUrl: String? = null, + override val year: Int? = null, override val syncData: Map? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, @@ -340,15 +362,15 @@ object DataStoreHelper { } data class BookmarkedData( - @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, - override var id: Int?, - override val latestUpdatedTime: Long, - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType?, - override var posterUrl: String?, - override val year: Int?, + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long = 0L, + override var id: Int? = null, + override val latestUpdatedTime: Long = 0L, + override val name: String = "", + override val url: String = "", + override val apiName: String = "", + override var type: TvType? = null, + override var posterUrl: String? = null, + override val year: Int? = null, override val syncData: Map? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, @@ -393,15 +415,15 @@ object DataStoreHelper { } data class FavoritesData( - @JsonProperty("favoritesTime") val favoritesTime: Long, - override var id: Int?, - override val latestUpdatedTime: Long, - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType?, - override var posterUrl: String?, - override val year: Int?, + @JsonProperty("favoritesTime") val favoritesTime: Long = 0L, + override var id: Int? = null, + override val latestUpdatedTime: Long = 0L, + override val name: String = "", + override val url: String = "", + override val apiName: String = "", + override var type: TvType? = null, + override var posterUrl: String? = null, + override val year: Int? = null, override val syncData: Map? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, @@ -446,17 +468,17 @@ object DataStoreHelper { } data class ResumeWatchingResult( - @JsonProperty("name") override val name: String, - @JsonProperty("url") override val url: String, - @JsonProperty("apiName") override val apiName: String, + @JsonProperty("name") override val name: String = "", + @JsonProperty("url") override val url: String = "", + @JsonProperty("apiName") override val apiName: String = "", @JsonProperty("type") override var type: TvType? = null, - @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("watchPos") val watchPos: PosDur?, - @JsonProperty("id") override var id: Int?, - @JsonProperty("parentId") val parentId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, + @JsonProperty("posterUrl") override var posterUrl: String? = null, + @JsonProperty("watchPos") val watchPos: PosDur? = null, + @JsonProperty("id") override var id: Int? = null, + @JsonProperty("parentId") val parentId: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("isFromDownload") val isFromDownload: Boolean = false, @JsonProperty("quality") override var quality: SearchQuality? = null, @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, @JsonProperty("score") override var score: Score? = null, @@ -476,6 +498,7 @@ object DataStoreHelper { fun deleteAllResumeStateIds() { val folder = "$currentAccount/$RESULT_RESUME_WATCHING" removeKeys(folder) + triggerGoogleDriveSync() } fun deleteBookmarkedData(id: Int?) { @@ -483,6 +506,7 @@ object DataStoreHelper { AccountManager.localListApi.requireLibraryRefresh = true removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString()) removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) + triggerGoogleDriveSync() } fun getAllResumeStateIds(): List? { @@ -539,6 +563,7 @@ object DataStoreHelper { isFromDownload ) ) + triggerGoogleDriveSync() } private fun removeLastWatchedOld(parentId: Int?) { @@ -549,6 +574,7 @@ object DataStoreHelper { fun removeLastWatched(parentId: Int?) { if (parentId == null) return removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) + triggerGoogleDriveSync() } fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { @@ -571,6 +597,7 @@ object DataStoreHelper { if (id == null) return setKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString(), data) AccountManager.localListApi.requireLibraryRefresh = true + triggerGoogleDriveSync() } fun getBookmarkedData(id: Int?): BookmarkedData? { @@ -672,10 +699,12 @@ object DataStoreHelper { when (val newMeta = currentEpisode) { is ResultEpisode -> { removeLastWatched(newMeta.parentId) + setResultWatchState(newMeta.parentId, WatchType.COMPLETED.internalId) } is ExtractorUri -> { removeLastWatched(newMeta.parentId) + setResultWatchState(newMeta.parentId, WatchType.COMPLETED.internalId) } } } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 8bcd1b88e70..6dd11581b7c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -36,7 +36,7 @@ import java.io.IOException import java.io.InputStreamReader object InAppUpdater { - private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_USER_NAME = "zevidriz-cmd" private const val GITHUB_REPO = "cloudstream" private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt index 25a9fdf2a4f..39f4929975b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -57,19 +57,19 @@ object DownloadObjects { abstract class DownloadCached( - @JsonProperty("id") open val id: Int, + @JsonProperty("id") open val id: Int = 0, ) data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("parentId") val parentId: Int, + @JsonProperty("name") val name: String? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("episode") val episode: Int = 0, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("parentId") val parentId: Int = 0, @JsonProperty("score") var score: Score? = null, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, + @JsonProperty("description") val description: String? = null, + @JsonProperty("cacheTime") val cacheTime: Long = 0L, + override val id: Int = 0, ) : DownloadCached(id) { @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) @Deprecated( @@ -88,13 +88,13 @@ object DownloadObjects { /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ data class DownloadHeaderCached( - @JsonProperty("apiName") val apiName: String, - @JsonProperty("url") val url: String, - @JsonProperty("type") val type: TvType, - @JsonProperty("name") val name: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, + @JsonProperty("apiName") val apiName: String = "", + @JsonProperty("url") val url: String = "", + @JsonProperty("type") val type: TvType = TvType.Others, + @JsonProperty("name") val name: String = "", + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("cacheTime") val cacheTime: Long = 0L, + override val id: Int = 0, ) : DownloadCached(id) data class DownloadResumePackage( @@ -146,12 +146,12 @@ object DownloadObjects { data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, + @JsonProperty("parentId") val parentId: Int = 0, + @JsonProperty("episodeId") val episodeId: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("updateTime") val updateTime: Long = 0L, + @JsonProperty("isFromDownload") val isFromDownload: Boolean = false, ) diff --git a/app/src/main/res/drawable/avatar_1.xml b/app/src/main/res/drawable/avatar_1.xml new file mode 100644 index 00000000000..cbb8d723955 --- /dev/null +++ b/app/src/main/res/drawable/avatar_1.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_10.xml b/app/src/main/res/drawable/avatar_10.xml new file mode 100644 index 00000000000..c2ffa8ee053 --- /dev/null +++ b/app/src/main/res/drawable/avatar_10.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_11.xml b/app/src/main/res/drawable/avatar_11.xml new file mode 100644 index 00000000000..564bc53c143 --- /dev/null +++ b/app/src/main/res/drawable/avatar_11.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_12.xml b/app/src/main/res/drawable/avatar_12.xml new file mode 100644 index 00000000000..e0a95dd6fd5 --- /dev/null +++ b/app/src/main/res/drawable/avatar_12.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_2.xml b/app/src/main/res/drawable/avatar_2.xml new file mode 100644 index 00000000000..093ebc734a8 --- /dev/null +++ b/app/src/main/res/drawable/avatar_2.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_3.xml b/app/src/main/res/drawable/avatar_3.xml new file mode 100644 index 00000000000..6d1055392cc --- /dev/null +++ b/app/src/main/res/drawable/avatar_3.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_4.xml b/app/src/main/res/drawable/avatar_4.xml new file mode 100644 index 00000000000..d93bde50402 --- /dev/null +++ b/app/src/main/res/drawable/avatar_4.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_5.xml b/app/src/main/res/drawable/avatar_5.xml new file mode 100644 index 00000000000..e5f77a5cd85 --- /dev/null +++ b/app/src/main/res/drawable/avatar_5.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_6.xml b/app/src/main/res/drawable/avatar_6.xml new file mode 100644 index 00000000000..e82779c5724 --- /dev/null +++ b/app/src/main/res/drawable/avatar_6.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_7.xml b/app/src/main/res/drawable/avatar_7.xml new file mode 100644 index 00000000000..b51f9517a5a --- /dev/null +++ b/app/src/main/res/drawable/avatar_7.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_8.xml b/app/src/main/res/drawable/avatar_8.xml new file mode 100644 index 00000000000..392f66eae06 --- /dev/null +++ b/app/src/main/res/drawable/avatar_8.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_9.xml b/app/src/main/res/drawable/avatar_9.xml new file mode 100644 index 00000000000..b7bd5c26b3a --- /dev/null +++ b/app/src/main/res/drawable/avatar_9.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/googledrive_logo.xml b/app/src/main/res/drawable/googledrive_logo.xml new file mode 100644 index 00000000000..eef7e38d471 --- /dev/null +++ b/app/src/main/res/drawable/googledrive_logo.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_backspace.xml b/app/src/main/res/drawable/ic_backspace.xml new file mode 100644 index 00000000000..66a21f119ae --- /dev/null +++ b/app/src/main/res/drawable/ic_backspace.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/pin_dot_empty.xml b/app/src/main/res/drawable/pin_dot_empty.xml new file mode 100644 index 00000000000..ee9c68fbddb --- /dev/null +++ b/app/src/main/res/drawable/pin_dot_empty.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/pin_dot_filled.xml b/app/src/main/res/drawable/pin_dot_filled.xml new file mode 100644 index 00000000000..8e86f3c3f17 --- /dev/null +++ b/app/src/main/res/drawable/pin_dot_filled.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_bg_gray.xml b/app/src/main/res/drawable/rounded_bg_gray.xml new file mode 100644 index 00000000000..a0ef1e1f23f --- /dev/null +++ b/app/src/main/res/drawable/rounded_bg_gray.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/dialog_pin_entry.xml b/app/src/main/res/layout/dialog_pin_entry.xml new file mode 100644 index 00000000000..b6a94be4e2f --- /dev/null +++ b/app/src/main/res/layout/dialog_pin_entry.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_profile_editor.xml b/app/src/main/res/layout/dialog_profile_editor.xml new file mode 100644 index 00000000000..9834f01a799 --- /dev/null +++ b/app/src/main/res/layout/dialog_profile_editor.xml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_language_setup.xml b/app/src/main/res/layout/fragment_language_setup.xml new file mode 100644 index 00000000000..b634fa57ace --- /dev/null +++ b/app/src/main/res/layout/fragment_language_setup.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 00000000000..bc5d67353a5 --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_pair_tv.xml b/app/src/main/res/layout/fragment_pair_tv.xml new file mode 100644 index 00000000000..f3ed467d7a8 --- /dev/null +++ b/app/src/main/res/layout/fragment_pair_tv.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_profile_selector.xml b/app/src/main/res/layout/fragment_profile_selector.xml new file mode 100644 index 00000000000..23ef2b4a7ec --- /dev/null +++ b/app/src/main/res/layout/fragment_profile_selector.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_welcome.xml b/app/src/main/res/layout/fragment_welcome.xml new file mode 100644 index 00000000000..fbdececca02 --- /dev/null +++ b/app/src/main/res/layout/fragment_welcome.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_avatar_select.xml b/app/src/main/res/layout/item_avatar_select.xml new file mode 100644 index 00000000000..937db1594ab --- /dev/null +++ b/app/src/main/res/layout/item_avatar_select.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_language_elegant.xml b/app/src/main/res/layout/item_language_elegant.xml new file mode 100644 index 00000000000..898b4142e2a --- /dev/null +++ b/app/src/main/res/layout/item_language_elegant.xml @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_profile.xml b/app/src/main/res/layout/item_profile.xml new file mode 100644 index 00000000000..2e431656c8b --- /dev/null +++ b/app/src/main/res/layout/item_profile.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 27f186a0074..fe50780acd9 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -88,6 +88,23 @@ app:popEnterAnim="@anim/enter_anim" app:popExitAnim="@anim/exit_anim" /> + + + + + + + + + + + tools:layout="@layout/fragment_language_setup"> + + + + + + + + + + + + diff --git a/app/src/main/res/values/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index 6a4c8271341..9fe3001cee5 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -101,6 +101,7 @@ opensubtitles_key subdl_key animeskip_key + pair_tv_key pref_category_security_key pref_category_gestures_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31cf951cf5f..83077cf88fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,7 @@ Browser Skip Loading Loading… + History Watching On-Hold Completed @@ -679,6 +680,8 @@ Logged in as %s Skip account selection at startup Use Default Account + Pair Android TV + Scan or enter the pairing code displayed on your TV Rotate Display a toggle button for screen orientation Enable automatic switching of screen orientation based on video orientation diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f386bb62eff..9bd1d657067 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1125,4 +1125,18 @@ @dimen/download_header_progress_size @color/playIconBackground + + diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index 58009031846..bde683aa238 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -38,6 +38,12 @@ android:key="@string/skip_startup_account_select_key" android:title="@string/skip_startup_account_select_pref" /> + + + + + + + + + + request.time.toMillis() - 5 * 60 * 1000; + allow update: if request.auth != null + && resource.data.status == 'pending' + && request.resource.data.status == 'authorized' + && resource.data.createdAt > request.time.toMillis() - 5 * 60 * 1000; + allow delete: if true; + } + + // Deny everything else + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97145c3f81..dce48c2e702 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,10 +14,15 @@ conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything constraintlayout = "2.2.1" coreKtx = "1.18.0" desugar_jdk_libs_nio = "2.1.5" +credentialsPlayServicesAuth = "1.5.0" +credentials = "1.5.0" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" +firebaseBom = "33.14.0" fuzzywuzzy = "1.4.0" +googleid = "1.1.1" +googleServices = "4.4.2" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" jsoup = "1.22.1" @@ -51,7 +56,7 @@ workRuntimeKtx = "2.11.2" zipline = "1.27.0" jvmTarget = "1.8" -jdkToolchain = "17" +jdkToolchain = "21" minSdk = "23" compileSdk = "36" targetSdk = "36" @@ -67,14 +72,20 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroid" } constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" } +credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" } core = { module = "androidx.test:core" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } databinding = { module = "androidx.databinding:viewbinding", version.ref = "androidGradlePlugin" } desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-auth = { module = "com.google.firebase:firebase-auth" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" } +googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } @@ -85,6 +96,7 @@ kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collec kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } +lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } media3-cast = { module = "androidx.media3:media3-cast", version.ref = "media3" } media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } @@ -125,10 +137,12 @@ buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfigG dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } [bundles] coil = ["coil", "coil-network-okhttp"] -lifecycle = ["lifecycle-livedata-ktx", "lifecycle-viewmodel-ktx"] +lifecycle = ["lifecycle-livedata-ktx", "lifecycle-viewmodel-ktx", "lifecycle-process"] media3 = ["media3-cast", "media3-common", "media3-container", "media3-datasource-cronet", "media3-datasource-okhttp", "media3-exoplayer", "media3-exoplayer-dash", "media3-exoplayer-hls", "media3-session", "media3-ui"] navigation = ["navigation-fragment-ktx", "navigation-ui-ktx"] +firebase = ["firebase-auth", "firebase-firestore"] nextlib = ["nextlib-media3ext", "nextlib-mediainfo"] From 5212081162eaf87e3caa30d767c15b1cfc1dc825 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:21:41 +0300 Subject: [PATCH 02/32] ci: Update prerelease action to use local keystore --- .github/workflows/prerelease.yml | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index d9a20a04b2b..a4c116a137f 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -19,14 +19,6 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: Generate access token - id: generate_token - uses: tibdex/github-app-token@v2 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - repository: "recloudstream/secrets" - - uses: actions/checkout@v6 - name: Set up JDK 17 @@ -38,31 +30,21 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Fetch keystore - id: fetch_keystore + - name: Setup Keystore run: | TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore mkdir -p "${TMP_KEYSTORE_FILE_PATH}" - curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" - curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" - KEY_PWD="$(cat keystore_password.txt)" - echo "::add-mask::${KEY_PWD}" - echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + cp keystore.jks "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" - SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} - SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} - SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} - SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + SIGNING_KEY_PASSWORD: "cloudstream" + SIGNING_STORE_PASSWORD: "cloudstream" - name: Create pre-release uses: marvinpinto/action-automatic-releases@latest From f189d7937868508053a09251214e7ad7e6df5b93 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:21:55 +0300 Subject: [PATCH 03/32] ci: Add keystore for automated builds --- keystore.jks | Bin 0 -> 2612 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 keystore.jks diff --git a/keystore.jks b/keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..715d6f43f3b38c804c8ab5d3d331db224bce6d1d GIT binary patch literal 2612 zcma);c{J1w7sqEa_MJ+Wj2KLo-!L=w@)SZU`%Z(gWeH_#Dvb3JgE018Ldg19%AV|$ zHA|9xmonM)^q%)UJ?H)FegC-Up8NgYd;hzia{&?q00Bb*68$8c9u=k;w#N*n1!t4! zdtoH{&R=pnK%y!BFN-D(MxsgjCCC4Y7&zm9rkEJOkZcl->Mv0laQHnz!wi@K^8b`f zz$F-0zgVh&Sq=wv(U)4<+66mV@~i_xAfjv#upqz$r~CIt2t5r5;D^)W!!*J6P#72m zWBc}T#swDTVlA?Ggk*fy%?cyI-dm5_Cor&>*?5#&hM}A?WW83pRS)>@OLv-d=B_4q z%q6T^0#4HuhujHY1;eQdd8w3FlwJPxKxGJ%jV&U5?xm$!H`zUhPA%lmnQ*e|dTTU6 zMO-qNKS-EkY>E})FZ9S*h;2{SBE`z)hQqx($u!HdDG0|;lhHtQ?rp(z2{E9X`|aLK zf*L-aA8)kW{)E@=jAabJDImjgKVBnCJ~Yz~L}BS<{ODFZ6+6#(41c^4kPHg-#`UoH zpKp;(5Z#vD3kn*;WGc9lb;79~{VL!syY&N_a7RDS*mjLR7TApR)Xa3d*RY@>$a+B7 zRT0_e*4y{4ox4g}WgH=a=({AhS$}d3+A$vlomhzu)N(LPrX#m_RM5?|(U9iF>Zo7z z@;C%{JFzhuCX+dCuP|(b4rP*Ob6G$3;6ilDZYd2VpZE8Beh&pN+zl7I-|BGZ&TF-~D5&C{Mcxk6)a zQ;CmYQ6o7n+342NMG+L2%#++yUw^nL)>F?>-R`LUCEM~X zXUF?>Q4;MafRkz({bwb z3UO5zti@<*?M%tF!I`!;m_3WcAsIV&r=zvzW(>rl495gZ!d?|YUW<1{SCr}Mkayef zPcG_%-HW&0jLw$Lew!eUxJ0`|2~2G5=|QI&mb^flkBo)g*@2c-m{Iyj|HQK50D7wA z_3c}1p=t7D2{^~Hyff&BVRmGPyPiQCacX7y^ZBqj9nptP9|efUm2n8$Zyk>t*O4Ci zv6$5y`l50pUFgI@q+3@q)1#$lAVb)fp{7<^He6b$jAwGA{NnZ(#2Ly)$%jhTwM^ZX z2*+(z+QcU!a86B@2+}d`xw_30A-Unt7PRe$LdW_W;&V|0Ta%}BVLPA8`VMAwU58i< z47#7KO z40k)i2fKOhYjD_QRCB}7h6iI@p4*`Oi`b{VMI2QP2xm3E0qa&X=EF+POYj?9XJMYR zzDihWMrorr5`q13Rsu5k{Q^wNJ9dTsUFkf?O2x+Gr~+H_2*(~>$>vN`YQUwz*&KKv zTH@MKq&vG1v$hRZ=z`Gz^d8PJh#05Y#4+q%m_9RfTjYvusa`?O@Cu(R%I^3NQaf-u zU_4(%bz;ah_tlkcZn)gNZn0w2%ftG~h1xOru}BQk98Mwbh@ECm;VlDVN_--l`C?9P zb>*=kOk@_-_%yO|xeChz$I>uI8m4Q;h*k-&knFrs???;YC|o_*{ECwz;oD;SQXKtu zS#uQr*Ky|i#_O#F&!>LvHru}YJmFBv+c!B8$3hgi;gMQ#$-k%DJ2l^sRY3>m&~p)) zgH{I4{>CLe7n(tU4&(x|2l;~l0P&B(jpl-J7&^Pz3!>%Z6mYUK7@Uj(4hxWA?7y36 z;n^e@1Rz0AAz;w2wD*q#{$Gg33Tp2+J*ajv2&l|t_D9=2cdZZke-MqWW6uXirNyKq znF)zt_xS@!(7?yeKd*RK+`gOMe8J2?mhEQgLj8@-D>KR+m&oz>63y{SWnRXnj9}u? z+#iYr>$A3&uUx60quE@?R}MaSc&E%C`y3ZAbZ;sdXtjQ9@XH@35Kl}-Et9!JFMdcV zBw=1et=JY8I-pG&o*RUW73P2Sr<5!&J)nVY{%ZFP;#x?DE4e+c2LMM_f7pQ7vb8t-f4K^{{0gFZv#42 z*#+2_>{3O!>hG(cO}1D&(%v#!nkH)F<4}pVyiKz)bt9(tE2#W08`f%C`!s$u(7!(X z%Q}?l&zdqQkD*CHr%zjoe(F|6uI4`ej= zFc5wAq4s!G(e7eRezK3@4__pQ3Rp6Wdp9X|8_9x1I8Q!gb3DyBcIqz!o?M-F9&6Jc zxJve^0rJsVGo0alVQB)|(?7cMe8OBav}L=@TKZE_4A5s9;LP8Dfc*ijmkG^H~x9(vJduHYq3i8ZE<>9WNd zYJ7Nm-C?P?URt1>q2pq;8)uFx!a=;J+PzVFlSZ6)$BJ@7kQ_S-N5)#V*AB{U*MAPs z{-)MLLGRlah|WpvX4yygXSUbbZ@ z>PBv`obDUj4Zoly{{uCK;||iN(D7hE_+VO(fuC!lPiL8b+%mQWs%x_YYUvBPINI8F zmA#jVN8=}g(7nExcsp6Yt8`dOt{k>;nesfCRCDjEvuk!_0>+--)O+%D;o8+AE$Bdt zz;nD^U?masQk{;OETGRcr^)l$1XDw)rnQV#g_YDReIF7%`BTjbtYtS6RCXc4^u@9P zAvWG>tmBSfr$EM?1G=o;H%;1Ag#A@x;3j|un16p#5HJl0CU}C^<%;d7?-ep%9Sa>k vyDzM)V<;p1$jFCufwlH>OBIZZu(rn6C1uM?pJ*)DW5lLFC{fM$7XtnUMW3w= literal 0 HcmV?d00001 From 16ae6507daffb072799f283f960f446807a9c630 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:37:27 +0300 Subject: [PATCH 04/32] ci: trigger github actions build From f089cb2a6402918be79088aadd626804b4e695d9 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:39:43 +0300 Subject: [PATCH 05/32] ci: trigger github actions build 2 From cc589619501df2474735a011da5ea93e539fac85 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:42:50 +0300 Subject: [PATCH 06/32] ci: add workflow_dispatch to prerelease --- .github/workflows/prerelease.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index a4c116a137f..eaaf0968124 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1,6 +1,7 @@ name: Pre-release on: + workflow_dispatch: push: branches: [ master ] paths-ignore: From 17ad158336e643e555bb8675018d56d6dd26aaa3 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 17:58:27 +0300 Subject: [PATCH 07/32] ci: add dummy google-services.json for github actions --- app/google-services.json | 158 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 app/google-services.json diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 00000000000..18fa2598769 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,158 @@ +{ + "project_info": { + "project_number": "667741584423", + "project_id": "cloudstream-sync-e558e", + "storage_bucket": "cloudstream-sync-e558e.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:667741584423:android:edf320139b47094fa46202", + "android_client_info": { + "package_name": "com.lagradost.cloudstream3" + } + }, + "oauth_client": [ + { + "client_id": "667741584423-c9qkhu6mi437iku11dl73iq4nqdsuc87.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.lagradost.cloudstream3", + "certificate_hash": "2a7d83da34b8d500b4a36890ec1fbb8ddb4a6ec7" + } + }, + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCRgbJyXg3gm-hFyl3MTSITPY-lS99pvfQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:667741584423:android:79fb27357557c8a6a46202", + "android_client_info": { + "package_name": "com.lagradost.cloudstream3.debug" + } + }, + "oauth_client": [ + { + "client_id": "667741584423-umudpch9pfacfugd2qkhd0dge1tbc0c7.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.lagradost.cloudstream3.debug", + "certificate_hash": "2a7d83da34b8d500b4a36890ec1fbb8ddb4a6ec7" + } + }, + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCRgbJyXg3gm-hFyl3MTSITPY-lS99pvfQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:667741584423:android:280b833bc8d40ba5a46202", + "android_client_info": { + "package_name": "com.lagradost.cloudstream3.prerelease" + } + }, + "oauth_client": [ + { + "client_id": "667741584423-dm2jgvet65vuft76ou220p54c9lnse1s.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.lagradost.cloudstream3.prerelease", + "certificate_hash": "2a7d83da34b8d500b4a36890ec1fbb8ddb4a6ec7" + } + }, + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCRgbJyXg3gm-hFyl3MTSITPY-lS99pvfQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:667741584423:android:c9d798e213a1a983a46202", + "android_client_info": { + "package_name": "com.lagradost.cloudstream3.prerelease.debug" + } + }, + "oauth_client": [ + { + "client_id": "667741584423-vu88kfol6shig8mvcuuono63ksupmpoc.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.lagradost.cloudstream3.prerelease.debug", + "certificate_hash": "2a7d83da34b8d500b4a36890ec1fbb8ddb4a6ec7" + } + }, + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCRgbJyXg3gm-hFyl3MTSITPY-lS99pvfQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "667741584423-do04b2dskk1hrle80ngkf9i4m7qci519.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} From 727f2e26c645aceadc13956e3f9d102a20262657 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 18:13:20 +0300 Subject: [PATCH 08/32] ci: capture gradle failure logs --- .github/workflows/prerelease.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index eaaf0968124..885c2af0e90 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -41,12 +41,23 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Run Gradle - run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar > gradle.log 2>&1 || (cat gradle.log ; exit 1) env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: "cloudstream" SIGNING_STORE_PASSWORD: "cloudstream" + - name: Push Logs + if: failure() + run: | + git config --global user.email "bot@example.com" + git config --global user.name "Bot" + git fetch + git checkout -b build-logs + git add gradle.log + git commit -m "Add build logs" + git push origin build-logs -f + - name: Create pre-release uses: marvinpinto/action-automatic-releases@latest with: From 5813cf7020949884627f9ab72fc58b2bf5f47598 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 18:42:47 +0300 Subject: [PATCH 09/32] fix: suppress GestureBackNavigation lint error --- .../lagradost/cloudstream3/ui/account/AccountSelectActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt index 9be5d6b0bd4..555723270a0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -228,6 +228,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { } } + @SuppressLint("GestureBackNavigation") @Deprecated("Deprecated in Java") override fun onBackPressed() { val isEditingFromMainActivity = intent.getBooleanExtra( From 171efb7099d05a838d33d8c9d2b87d3c4d5f9cab Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 20:39:25 +0300 Subject: [PATCH 10/32] Implement updates and fixes: auto-update, PIN entry haptics, UI tweaks, and sync Snackbar --- .../lagradost/cloudstream3/MainActivity.kt | 4 +--- .../providers/FirebaseSyncManager.kt | 6 +++++ .../providers/GoogleDriveSyncManager.kt | 5 ++++ .../cloudstream3/ui/account/AccountHelper.kt | 17 ++++++++++++- .../cloudstream3/ui/home/HomeFragment.kt | 5 ++++ .../ui/result/ResultFragmentPhone.kt | 9 +++++++ .../cloudstream3/utils/InAppUpdater.kt | 24 +++---------------- 7 files changed, 45 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a1f66e18ccd..f58977052b3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -2026,9 +2026,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa handleAppIntent(intent) - ioSafe { - runAutoUpdate() - } + FcastManager().init(this, false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt index 30c64ccf686..47effea3b97 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt @@ -219,6 +219,12 @@ class FirebaseSyncManager : AuthAPI() { } } + withContext(Dispatchers.Main) { + com.lagradost.cloudstream3.CommonActivity.activity?.findViewById(android.R.id.content)?.let { view -> + com.google.android.material.snackbar.Snackbar.make(view, "Sync Complete", com.google.android.material.snackbar.Snackbar.LENGTH_SHORT).show() + } + } + true } catch (e: Exception) { logError(e) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt index 1ce25427a4b..b6222f811ec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt @@ -506,6 +506,11 @@ class GoogleDriveSyncManager : AuthAPI() { if (uploadRes.isSuccessful) { val sp = PreferenceManager.getDefaultSharedPreferences(context) sp.edit().putLong("googledrive_last_synced_time", System.currentTimeMillis()).apply() + withContext(Dispatchers.Main) { + com.lagradost.cloudstream3.CommonActivity.activity?.findViewById(android.R.id.content)?.let { view -> + com.google.android.material.snackbar.Snackbar.make(view, "Sync Complete", com.google.android.material.snackbar.Snackbar.LENGTH_SHORT).show() + } + } true } else { false diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index 32e8b1f6989..fd675baf443 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -301,8 +301,23 @@ object AccountHelper { val dialog = builder.create() - binding.pinEditText.doOnTextChanged { text, _, _, _ -> + var previousLength = 0 + binding.pinEditText.doOnTextChanged { text, start, before, count -> val enteredPin = text.toString() + if (enteredPin.length > previousLength) { + binding.pinEditText.performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY) + val anim = android.view.animation.ScaleAnimation( + 1.05f, 1f, 1.05f, 1f, + android.view.animation.Animation.RELATIVE_TO_SELF, 0.5f, + android.view.animation.Animation.RELATIVE_TO_SELF, 0.5f + ) + anim.duration = 100 + binding.pinEditText.startAnimation(anim) + } else if (enteredPin.length < previousLength) { + binding.pinEditText.performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY) + } + previousLength = enteredPin.length + val isEnteredPinValid = enteredPin.length == 4 if (isEnteredPinValid) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index cbbf26ff1ac..3a4275fc822 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -83,6 +83,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate private const val TAG = "HomeFragment" @@ -881,6 +882,10 @@ class HomeFragment : BaseFragment( homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) //loadHomePage(false) + ioSafe { + activity?.runAutoUpdate() + } + // nice profile pic on homepage //home_profile_picture_holder?.isVisible = false // just in case diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index cbd15809db6..90ae76f6535 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -968,6 +968,15 @@ open class ResultFragmentPhone : BaseFragment( ) } } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + resultPosterBackground.setRenderEffect( + android.graphics.RenderEffect.createBlurEffect( + 50f, + 50f, + android.graphics.Shader.TileMode.CLAMP + ) + ) + } bindLogo( url = d.logoUrl, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 6dd11581b7c..c9e0019968c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -275,18 +275,8 @@ object InAppUpdater { } val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, update.updateVersion - ) - ) - - val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") - val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> - matchResult.groupValues[1] - } // Sanitized because it looks cluttered - - builder.setMessage(sanitizedChangelog) + builder.setTitle(R.string.update) + builder.setMessage("There is an update available, version ${update.updateVersion}") builder.apply { setPositiveButton(R.string.update) { _, _ -> // Forcefully start any delayed installations @@ -338,15 +328,7 @@ object InAppUpdater { setNegativeButton(R.string.cancel) { _, _ -> } - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit { - putString( - getString(R.string.skip_update_key), update.updateNodeId ?: "" - ) - } - } - } + } builder.show().setDefaultFocus() } From f5b2f3641e44551d8a38c28e1d316fbbe3b1897c Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 20:50:09 +0300 Subject: [PATCH 11/32] Fix NullPointerException in ProfileSelectorFragment on destroy --- .../lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt index b8f88bdbf0b..c950d04130f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/ProfileSelectorFragment.kt @@ -98,7 +98,7 @@ class ProfileSelectorFragment : Fragment() { } private fun loadProfiles() { - binding.profileSelectorLoading.isVisible = true + _binding?.profileSelectorLoading?.isVisible = true lifecycleScope.launch { try { val profiles = AccountManager.firebaseApi.getProfiles() @@ -118,7 +118,7 @@ class ProfileSelectorFragment : Fragment() { logError(e) Toast.makeText(context, "Failed to load profiles", Toast.LENGTH_SHORT).show() } finally { - binding.profileSelectorLoading.isVisible = false + _binding?.profileSelectorLoading?.isVisible = false } } } From 8edebd127791fc4db15f32918f0d9bbf7a3595a8 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 21:57:16 +0300 Subject: [PATCH 12/32] Rename app to Cloudstream Plus, remove -PRE suffix, and default to foreground installer service --- app/build.gradle.kts | 1 - .../cloudstream3/utils/InAppUpdater.kt | 2 +- app/src/main/res/values/strings.xml | 4 ++-- jobs.json | Bin 0 -> 9042 bytes log.txt | 5 +++++ runs.json | Bin 0 -> 91202 bytes 6 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 jobs.json create mode 100644 log.txt create mode 100644 runs.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c90d3f56d62..997a32cb739 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -163,7 +163,6 @@ android { } else { logger.warn("No prerelease signing config!") } - versionNameSuffix = "-PRE" versionCode = (System.currentTimeMillis() / 60000).toInt() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index c9e0019968c..e9c3e961480 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -298,7 +298,7 @@ object InAppUpdater { } val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 1 + getString(R.string.apk_installer_key), 0 ) when (currentInstaller) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 83077cf88fa..abf21876abd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,8 +27,8 @@ New update found!\n%1$s -> %2$s Filler %d min - CloudStream - Play with CloudStream + Cloudstream Plus + Play with Cloudstream Plus Home Search Downloads diff --git a/jobs.json b/jobs.json new file mode 100644 index 0000000000000000000000000000000000000000..a75a2c61057e23b648ff0393749b6b0d5bcec182 GIT binary patch literal 9042 zcmeHNTTk0C6h6Jq$5?Sqdk@$?gQCHZ%$s#y!joyr!t)M zeVg`>j-|*uO}QnF$-5P)NLjKtccdtJw0K(DcFaJ{Ulltxk5L7jMU2gZqoShY8hZu) z2GW)$WVw(l$kT&dT?r>#1agS}P&U-HgH{dFu-8THT=h3VUvR&T`IK6E{EBE<@<6Hq~qeQdK}RkC`c7 zU16rAv_OqC@FWK$?MMkdAzDqe8+b-E)^PH-tj-cBxo2M;YZ`Cd0!2lzTHuywYL}=O;ByA9(q;M zc_abJ|L36!-|g9-PGQ!Xb`j_c;`)bo&n zd}kA>&qv);XqH+`vH;~|oHMqIW)>4y;u*&D1hF`OtZbgaynW;%jVPD(EQR|c$kIqd z(h$7VafFbPa>kKp>Gbe!7jaPMmiatv-ZQ@e=n?(*$4Mo@l6Hn=@S<^E0^W&pQ+F8J z{Jlxg#v&F!g3fxNHJ|gCvqR{;4KE)|px!uuZgL4Z-5}BE?mk(>((o4p3SRB5EwbKr-$x8&oj!uKL;0cno<1MqzK`nw zQMIMwug>xrRDk?q$e{Y>$Odv$i25`=IzY>}ijr+CWzZ2e9`~l|P9CcZFwY zt$KVY{kQy#-I)vL>rkB+wB>cI&>p|e{$5^ds~7Plkwsp9jLL9zEPne}rHwi9Z-I&@ zi_hA^R~?|g9a)L_wD=(^eXb2<sIP z9y9q})_AR#`LF5Ps66|s3U#l7SYF~DzvB@B#y0DXfJ68>ZJ5EjGFSVaSB}qN#Ujl< z8oi6J|L7R-I0z_skNHsqn6^7YT0=???OI%zX3v+kpjW@hoV256(ezq1;c!|qv{ z)s~A5#$stRJOnPbJ=X`(ouKEjBjB?tC;(Ng*q+kv^HJi&!3}r$%=&&6RUr2Os<=MY zj=SZC^(VREZu=tpvmLBMa_6U`zFN5V+!41&PVH32kq}HIG~?%2TlP`$j^w@KGlSjy+5aMNHQvsU#T|EvTzH$Gg)6n( d$>~QpPWvy3;cs^2BD_Ymc{AO?I#Dfs{}({RN8A7a literal 0 HcmV?d00001 diff --git a/log.txt b/log.txt new file mode 100644 index 00000000000..9f44586ffa3 --- /dev/null +++ b/log.txt @@ -0,0 +1,5 @@ +{ + "message": "Must have admin rights to Repository.", + "documentation_url": "https://docs.github.com/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run", + "status": "403" +} diff --git a/runs.json b/runs.json new file mode 100644 index 0000000000000000000000000000000000000000..004895416f4c5be1237998192c818214ee2887af GIT binary patch literal 91202 zcmeHQX^-5-5#?tC`45Zv8q2e%F2Hb{ICf$LaqL(K5W`r^o?2_K>|t3J^w*QT$J$ai z$u15#++okkg~3X5knHN}>gwvM*H!=f@7v;c#Ye>&UX$XcxWM16I4yRIeZ0Rd&hdFt zJjbsa#a8igbnR>WepS3Gj&Suw@fvqrmDgY2UH`-TYx(TZ3O`9}C&j_twVTCbJpFO8 zgV&Sd8Q!lKn^9}wo^w!lRvg~lHz~eA+pqAN;hR8ReEkJlIRtf~wMp@>;>+SYeD?}; z|E>6@_$QuuRJ_3Fr+9sS_xh^%Yq5>rzb(Elj*aKP#It8;VYk>r|7YOWe(~}>b!YhI z8XP>tZ)y6%{nxmUy&$hA;KS3B{N@(Vn1VaiF}}uk$LP&@*(2Ud&K|rUgHMV# z;N+>%*8@EJy12yeGhDZeyV=$$o}s-y!Z*K^9Jq!oUE+0xU$606E50h<-QatE_eVTq zALI3R{QooVIxc&10!q$t?J2&YCCcUwp0k116Z}2Hr}ZQ!I4@GqI!7;? zGxSfGlQKWQFz4sD7_YC3??K-abAD1-H$7hN= z5Apj2uAUaZ;yvZ;0NCE?Q{oNjz^u4{}k>IdNK2O@90bpE!$e@Fyc7YlE&ks;z%C%nxdqgYtQtgMFU7g(t7$Wj$HOUbGG+uetik z3P{P}$eq8JP;10e(jm_XbCL6S3xCx3TlhC#ua(DgPkPS1C}-R;*3@sHUpI=MFuOLf z`q^z$_i{XDkj;4#8ee-|n{(8T)YPK!IMURxL5^=Rf117f46T2Pakl9@#xvt~V+}ZI zxvzb$wQCLSOPkiPDPGQex7#~{1ahX*g5wBWcNoVuo!O=LQ&=YTR=ZKinQ}Zjh0^L< z$RMp_u3{qXBD3#WDFyo=88Z|^&FJ`T58TQDVEl`1~+Xz zSr<4SV)2Z@!O)sbQLwki(M)lBltb=7PRdnoAA6QIX=HEq@yDxW9>vcZi=Q?HHji)6 zvrEvdR`LtXDb7Uu4%PCz=<5lt4OEvi}X-*)9rgS%et z#`#XxMYlUASfA^AykMic-Dzolw>#q<5G;EtHGSBq1&SVl*BV{nTuVn)(qlzG3Oyg(FEQfq&?K=*f4ZOhrQd{lW_~oJ-BuVj zYJKB6j1Wm&n3SjcMv8RLm`@)2_bkwz?w7=G=_6o-jFQP{LD-#FbtYr5^hG!KD3ivn zyn1J9*VpDxybu%g%vdl5ub5z0^%E#*k&C`K^($C ziAGpQgys-2F~;2<ol_f1mB)lV{V1CMW~#-5&0Ib2nxjx9#E&7!AEj<8_k? z@`umjE9JG5c4En=Zurpp-PM1ozISQ>j{ck0@t;6bD1M5u(QV8hVmY3cvn}QE6mF+u zzk-I6*l$2Xy-u!I`kX2fD~*G&=kw8>wkP(c`Xnm`ZecC#HiGw_awSq6{L0OBMta6r zsKlb?&%fajd}*^fmR+LwItnTDOy5YpJgXWhpJ#`R?Nt>T7mMi2z4Ln1W-)Zx30ra~ zt&i(rl{RBfoPJoXx6gX6d0$&&l>*3R;3k6FM|Hgy(vGIY zQNY*lHnrH07O{BdpWZf{E7DmCP^__N!1EcX$UnxEI&2c@N754h$ZWuCLz1Er-~ z4>VqsSKlb@ftqgAi>E}+ZRXZCby}u4Au)dr@RTi4!MEic$lZq~HMZ_iFBSbJj0NPj zH&gW3)YJat={2IV_rU#Cv6xdd*}bTqZjp1*d1rE(qOLs%r-O1@Kh-`2n(SUsn$F6d ze{N(?m6C2+RM+9^y*Wof*MllW-Fl<<$m!I)v!PB4d(d_N5_Un7<%9Aieabm9fhz9J zhV+m3QroS{IK{MZ?fyOdd-SBdY&}mV9N?9-yK;_9l@j(~4^+`Z{0{y_zTTGDN_rsM zta3^jqanF%k(dr!^OEmGXv%rMocw=V%0NsDR~!9|0y6g%5tM3gh44>wq5QI|o!E&u}OK*6j$90L{yzCC{=3{Lu0Vw~ z$0m13!J+oS5H2cOQLF|>E>dgjiy7M;x!8TbJ%z4R{G=`O!O!c+#pUG2jx82@9GY0c zVb;hnE!3ojww79GZJxgv8JOFDUZ;h9h`(#=ha%W=j)Kx)ON}EJgC#gj;uR~QJg2P8 z$DJwWgKaxAf&1hn^+q@Ti>Jhqi}y7P9J!cN-z%1=))oF3$q9Ygkp&KYMbrhxe938nhZpOmk(dwsf174 zQPC0Ns>v}+%U$dx)H`ICNR0C0b!q7*VCG-?E7J(0R4(U`{j#1n`Dt>-h0N*Q>(VkW zaWl7(ofkemGyB!0-N9+6H2-sDQ|eQfRy1Ck{^ejot=wAoMYURztX2LY)}?(`*1dgd zd6+p|Idk$p1fn=7FBA$UrG{wA%(HYv~Hr;zO_>5ylHIo+(>+MI`SPkPSv&1TDf zhU~QScP`;tuMcL8%yQ(c1>Yxgt=9)*jq`3?r6!@hA-)IwG!OSZ zDes~Gh`RdWV7^IrI#x0?3et_+cI)L;Krcmoa8&A;4b)v_seyOTSk@N&D;?M`ct zW?O#q_rODIpV$bYv=>nJ`)+r}IRd!P5i8f$hr!P~&Tb-Z&+SfY=II(Yv=RdMj{U13 z)Z z*oRq{R!6N#f4ZM>b=QYA*J8@keWM&bq`I^^c7g76za&QNb!o}3G>1ZVC&dnPU22aS zg`Kz8rS-bBRZNW6rCq&xyfHS4He=e347P-4ck73LPH9%L&4c%z*elVhUYGWx$j?PS z$!pc4`7)x5c~<4#A(p=GJlo>hmK;j!<9b-7%~g-?H>s37Ys2jOE@M^2Ab57SHE75C zk2la;$y4o$Eud#BRQ(PawdswsGa`tTJT$MXP`%ooSD`Uj9%IbA&|15`j6I@b&=t4W z#{(O9_z$gqQ>ry#bJD3aP`7z@ zH);rdd3Lvc`)$we_A_)7^9Kx#4G+^ivBhHRo@PF^oLZ#FuS+{W?s$vQKgCY6o{`S3CD}K& z>BvQ|OB>>oj$9n7?=`~IhTp7W0wo)g<(c8wJmfRtLcit+8i%`~HLjQXX~t#G2CFbQ|4GBm#gf2?4?+(xiQ9yR0-5{qh9QbLS=h}`@vC5$%7nw(V*q&djiSbQ%tie-@LlC>xmd+ zvo7uD(09!3e1homA?$}eSeqw^MI9s~n)g+k_Rq!V#`8mt**#Fk2rQ}7{4*b7ZQ8(b z#`P}{-DeK6FeV>To!afBOa@Gdj&q(mwam6j*(ZXL8919U`hIn4A19v>GO2g3Q@c@Y z^m-OoI_e$4t0`Ce)v5gjNRu=Vh9EqLSy%mP2do?PkDF_&Z=G6;YPBps)H=0Ws8hRM zYMMrg!set^J{X9Lgj9JdZ zfqR|W`W~tqx2>ELZGI~v<&Mjp!U-yp6FYYT>t|K4-?F3jI<*=lpcknt=39985HrMYVb~bU);#JKZmOPXya`0jq_Q$sI6v!)Im1mg+bXvBR^aP3@nGWv&jNnjV8G zcEeBcw~f8<7G^H@`DqRX41r>aiRzGDxwU%_J8!R3>vd|qPA!qdt6ZPA?lY|8rNlQD zYzb-r`XJwQ)&tZq+t4`FHBX^y(ijYBC+&m#F7m;Vmq*15_~g>43Vlvh^7>|CJG#^M zp6!kD8+3YCZ>o+$3O&=;qbkp;dMxLyufe-{jhrI!`48QtRZm@nQz5t5e>8jf8T_T6 zLW)!CBxq$Aa zO-7%+j=7yDL}gMU?L&87+FqNCEgIz)N;MwEwTAJi<#V~(qHy>%;!fnS*Qs4Re^NJD zp%pWIAG>2T2fTEq@fWh zG*W-+#Eu|d#MZJC{CbGp+)(>q7@bN3b(`0zrH0U#*QpgB?Ey4Cafy$xlC@Qi_H1vS z?QPMVA)f6`Gp*%Zvy=vVZS`z#!Cy6O-PQau%5$#2^K+%iU2=Yk8P;-2i}rwg@N93y z0#D#0VjS}lBl8^Nx!hWJ;zJ3U=UD4*z63S%tL2oTCN;M1QJ)b{N)VTvJG*d-9-Dgg zde<|n<>bCaliiCd%5ZNbMj^&MDn(s;aEaL}XKiwhLzSz6Cc76gDj@GFP^F~bDptKW z=LqOpR;8$0Z}c8LAC$8nbz0bit{HDa_N0)9Px_RzM}aEFKZf*=_r@94a`viDi`S|3 zY;S$NEncVAv%Pt?H=>QEWn73ALTQsz%ky2Iwa?D0mUES`sq$=Z;-&X&Z#lEQr4ZQx zxg7jsj$G`ww%hx@kavJFalwO!ICyhBI7=qbh>ERgLy*h0!_R=ra~!$o$VKsi%HF!3 zO{H&U0g)<4E?RtZ_W(2BsVW`TT!>lZFKV literal 0 HcmV?d00001 From 3ed81ad34e25873e260acd2e144e5d955aed3482 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Sun, 24 May 2026 22:31:12 +0300 Subject: [PATCH 13/32] Fix sync snackbar and watch history logic --- .../providers/FirebaseSyncManager.kt | 3 +-- .../providers/GoogleDriveSyncManager.kt | 2 +- .../cloudstream3/ui/result/ResultViewModel2.kt | 6 +----- .../cloudstream3/utils/DataStoreHelper.kt | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt index 47effea3b97..0eb25597bdf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt @@ -221,10 +221,9 @@ class FirebaseSyncManager : AuthAPI() { withContext(Dispatchers.Main) { com.lagradost.cloudstream3.CommonActivity.activity?.findViewById(android.R.id.content)?.let { view -> - com.google.android.material.snackbar.Snackbar.make(view, "Sync Complete", com.google.android.material.snackbar.Snackbar.LENGTH_SHORT).show() + // com.google.android.material.snackbar.Snackbar.make(view, "Sync Complete", com.google.android.material.snackbar.Snackbar.LENGTH_SHORT).show() } } - true } catch (e: Exception) { logError(e) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt index b6222f811ec..9ed894b44c9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt @@ -508,7 +508,7 @@ class GoogleDriveSyncManager : AuthAPI() { sp.edit().putLong("googledrive_last_synced_time", System.currentTimeMillis()).apply() withContext(Dispatchers.Main) { com.lagradost.cloudstream3.CommonActivity.activity?.findViewById(android.R.id.content)?.let { view -> - com.google.android.material.snackbar.Snackbar.make(view, "Sync Complete", com.google.android.material.snackbar.Snackbar.LENGTH_SHORT).show() + // com.google.android.material.snackbar.Snackbar.make(view, "Sync Complete", com.google.android.material.snackbar.Snackbar.LENGTH_SHORT).show() } } true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 2871dd6decb..cc3eef97830 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -2160,11 +2160,7 @@ class ResultViewModel2 : ViewModel() { postFavorites(loadResponse) val currentState = getResultWatchState(mainId) - if (currentState == WatchType.NONE) { - updateWatchStatus(WatchType.HISTORY, null) - } else { - _watchStatus.postValue(currentState) - } + _watchStatus.postValue(currentState) if (updateEpisodes) postEpisodes(loadResponse, mainId, updateFillers) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index d22df65ca05..49ee1611b27 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -681,6 +681,21 @@ object DataStoreHelper { * */ fun setViewPosAndResume(id: Int?, position: Long, duration: Long, currentEpisode: Any?, nextEpisode: Any?) { setViewPos(id, position, duration) + + if (position >= 300_000) { + val parentId = when (currentEpisode) { + is ResultEpisode -> currentEpisode.parentId + is ExtractorUri -> currentEpisode.parentId + else -> null + } + if (parentId != null) { + val currentState = getResultWatchState(parentId) + if (currentState == WatchType.NONE) { + setResultWatchState(parentId, WatchType.WATCHING.internalId) + } + } + } + if (id != null) { when (val meta = currentEpisode) { is ResultEpisode -> { From 0944f5067e5ff564ffa403b535d489a2535fb58a Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 13:24:22 +0300 Subject: [PATCH 14/32] Fix bugs in sync manager, plugins, and Android TV auth pairing --- app/build.gradle.kts | 1 + .../lagradost/cloudstream3/MainActivity.kt | 30 +----- .../cloudstream3/plugins/PluginManager.kt | 8 +- .../providers/FirebaseSyncManager.kt | 4 +- .../cloudstream3/ui/home/HomeFragment.kt | 97 ++++++++++++++++++- .../ui/home/HomeParentItemAdapterPreview.kt | 12 ++- .../cloudstream3/ui/sync/FragmentPairTv.kt | 35 +++++-- .../ic_baseline_qr_code_scanner_24.xml | 10 ++ .../main/res/layout/fragment_home_head.xml | 17 +++- app/src/main/res/layout/rail_footer.xml | 22 +---- 10 files changed, 174 insertions(+), 62 deletions(-) create mode 100644 app/src/main/res/drawable/ic_baseline_qr_code_scanner_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 997a32cb739..390742f1c7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -248,6 +248,7 @@ dependencies { implementation(libs.palette.ktx) // Palette for Images -> Colors implementation(libs.tvprovider) implementation(libs.overlappingpanels) // Gestures + implementation("com.journeyapps:zxing-android-embedded:4.3.0") // QR Scanner implementation(libs.biometric) // Fingerprint Authentication implementation(libs.previewseekbar.media3) // SeekBar Preview implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index f58977052b3..0944c404fc7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1795,31 +1795,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa //noFocus(this) val navProfileRoot = findViewById(R.id.nav_footer_root) - - if (isLayout(TV or EMULATOR)) { - val navProfilePic = findViewById(R.id.nav_footer_profile_pic) - val navProfileCard = findViewById(R.id.nav_footer_profile_card) - - navProfileCard?.setOnClickListener { - showAccountSelectLinear() - } - - val homeViewModel = - ViewModelProvider(this@MainActivity)[HomeViewModel::class.java] - - observe(homeViewModel.currentAccount) { currentAccount -> - if (currentAccount != null) { - navProfilePic?.loadImage( - currentAccount.image - ) - navProfileRoot.isVisible = true - } else { - navProfileRoot.isGone = true - } - } - } else { - navProfileRoot.isGone = true - } + navProfileRoot?.isGone = true } val rail = binding?.navRailView @@ -1833,8 +1809,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // The genius engineers at google did not actually // write a nextFocus for the navrail - rail.findViewById(R.id.navigation_settings)?.nextFocusDownId = - R.id.nav_footer_profile_card + // rail.findViewById(R.id.navigation_settings)?.nextFocusDownId = + // R.id.nav_footer_profile_card for (id in arrayOf( R.id.navigation_home, R.id.navigation_search, diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index eae14a6c0c3..5dabe124965 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -800,7 +800,13 @@ object PluginManager { return try { if (File(file.absolutePath).delete()) { unloadPlugin(file.absolutePath) - list.forEach { deletePluginData(it) } + list.forEach { + deletePluginData(it) + val deletedArray = getKey>("firebase_deleted_plugins") ?: emptyArray() + if (!deletedArray.contains(it.internalName)) { + setKey("firebase_deleted_plugins", deletedArray + it.internalName) + } + } return true } false diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt index 0eb25597bdf..091197819ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt @@ -302,7 +302,9 @@ class FirebaseSyncManager : AuthAPI() { // 2.5 Plugins val localPlugins = (PluginManager.getPluginsLocal() + PluginManager.getPluginsOnline()).map { it.internalName } val remotePlugins = remote.plugins - val mergedPlugins = (localPlugins + remotePlugins).distinct() + val deletedPlugins = getKey>("firebase_deleted_plugins")?.toList() ?: emptyList() + val mergedPlugins = (localPlugins + remotePlugins).distinct().filter { !deletedPlugins.contains(it) } + com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey("firebase_deleted_plugins") // 3. Bookmarks val localBookmarks = DataStoreHelper.getAllBookmarkedData() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 3a4275fc822..598fbbca18a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -1,5 +1,7 @@ package com.lagradost.cloudstream3.ui.home +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import android.annotation.SuppressLint import android.app.Activity import android.content.Context @@ -29,6 +31,9 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanIntentResult +import com.journeyapps.barcodescanner.ScanOptions import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.AllLanguagesName @@ -596,6 +601,89 @@ class HomeFragment : BaseFragment( private var currentApiName: String? = null private var toggleRandomButton = false + private val barcodeLauncher = registerForActivityResult( + ScanContract() + ) { result: ScanIntentResult -> + if (result.contents != null) { + submitPairingCode(result.contents) + } + } + + private fun submitPairingCode(code: String) { + val ctx = context ?: return + var email = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_email") + var password = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_password") + + kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser + if (password.isNullOrBlank() && user != null && user.email != null) { + val generatedPassword = java.util.UUID.randomUUID().toString().replace("-", "") + "A1!" + try { + com.google.android.gms.tasks.Tasks.await(user.updatePassword(generatedPassword)) + com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey("firebase_password", generatedPassword) + com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey("firebase_email", user.email) + email = user.email + password = generatedPassword + } catch (e: Exception) { + logError(e) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Please log out and log back in to pair TV (Recent login required).", Toast.LENGTH_LONG).show() + } + return@launch + } + } + + if (email.isNullOrBlank() || password.isNullOrBlank()) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + } + return@launch + } + + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + val docRef = firestore.collection("pairing_codes").document(code) + val snapshot = com.google.android.gms.tasks.Tasks.await(docRef.get()) + + if (!snapshot.exists()) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Invalid or expired pairing code.", Toast.LENGTH_SHORT).show() + } + return@launch + } + + val createdAt = snapshot.getLong("createdAt") ?: 0L + val status = snapshot.getString("status") + + if (status != "pending" || System.currentTimeMillis() - createdAt > 5 * 60 * 1000) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Pairing code has expired or is already paired.", Toast.LENGTH_SHORT).show() + } + return@launch + } + + val finalEmail = email + val finalPassword = password + val updateData = hashMapOf( + "status" to "authorized", + "email" to finalEmail, + "password" to finalPassword + ) + com.google.android.gms.tasks.Tasks.await(docRef.update(updateData as Map)) + + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + } + + } catch (e: Exception) { + logError(e) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + } + } + } + } + private var bottomSheetDialog: BottomSheetDialog? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null @@ -659,7 +747,14 @@ class HomeFragment : BaseFragment( } homeMasterAdapter = HomeParentItemAdapterPreview( - homeViewModel, accountViewModel + homeViewModel, accountViewModel, qrScannerCallback = { + barcodeLauncher.launch(ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan TV QR Code") + setBeepEnabled(false) + setOrientationLocked(false) + }) + } ) homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.adapter = homeMasterAdapter diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index c03c508b07d..b497644a049 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -67,7 +67,8 @@ import androidx.navigation.findNavController class HomeParentItemAdapterPreview( private val viewModel: HomeViewModel, - private val accountViewModel: AccountViewModel + private val accountViewModel: AccountViewModel, + private val qrScannerCallback: (() -> Unit)? = null ) : ParentItemAdapter( id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { @@ -107,7 +108,7 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, accountViewModel) + return HeaderViewHolder(binding, viewModel, accountViewModel, qrScannerCallback) } override fun onBindHeader(holder: ViewHolderState) { @@ -134,6 +135,7 @@ class HomeParentItemAdapterPreview( val binding: ViewBinding, val viewModel: HomeViewModel, accountViewModel: AccountViewModel, + val qrScannerCallback: (() -> Unit)? = null ) : ViewHolderState(binding) { @@ -328,6 +330,8 @@ class HomeParentItemAdapterPreview( private val headProfilePicCard: View? = itemView.findViewById(R.id.home_head_profile_padding) + private val homeQrScannerBtn: View? = itemView.findViewById(R.id.home_qr_scanner_btn) + private val alternateHeadProfilePic: ImageView? = itemView.findViewById(R.id.alternate_home_head_profile_pic) private val alternateHeadProfilePicCard: View? = @@ -588,6 +592,10 @@ class HomeParentItemAdapterPreview( } } + homeQrScannerBtn?.setOnClickListener { + qrScannerCallback?.invoke() + } + fun showAccountEditBox(context: Context): Boolean { val currentAccount = DataStoreHelper.getCurrentAccount() return if (currentAccount != null) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt index 97bbac85301..8b66d6c1f33 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt @@ -8,12 +8,15 @@ import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentPairTvBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.DataStore.getKey import kotlinx.coroutines.tasks.await +import com.lagradost.cloudstream3.utils.DataStore.setKey +import kotlinx.coroutines.tasks.await import kotlinx.coroutines.launch class FragmentPairTv : Fragment() { @@ -48,18 +51,36 @@ class FragmentPairTv : Fragment() { private fun submitPairingCode(code: String) { val ctx = context ?: return - val email = ctx.getKey("firebase_email") - val password = ctx.getKey("firebase_password") - - if (email.isNullOrBlank() || password.isNullOrBlank()) { - Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() - return - } + var email = ctx.getKey("firebase_email") + var password = ctx.getKey("firebase_password") setLoading(true) lifecycleScope.launch { try { + val user = FirebaseAuth.getInstance().currentUser + if (password.isNullOrBlank() && user != null && user.email != null) { + val generatedPassword = java.util.UUID.randomUUID().toString().replace("-", "") + "A1!" + try { + user.updatePassword(generatedPassword).await() + ctx.setKey("firebase_password", generatedPassword) + ctx.setKey("firebase_email", user.email) + email = user.email + password = generatedPassword + } catch (e: Exception) { + logError(e) + Toast.makeText(ctx, "Please log out and log back in to pair TV (Recent login required).", Toast.LENGTH_LONG).show() + setLoading(false) + return@launch + } + } + + if (email.isNullOrBlank() || password.isNullOrBlank()) { + Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + setLoading(false) + return@launch + } + val firestore = FirebaseFirestore.getInstance() val docRef = firestore.collection("pairing_codes").document(code) val snapshot = docRef.get().await() diff --git a/app/src/main/res/drawable/ic_baseline_qr_code_scanner_24.xml b/app/src/main/res/drawable/ic_baseline_qr_code_scanner_24.xml new file mode 100644 index 00000000000..d7ac6ddfb40 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_qr_code_scanner_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_home_head.xml b/app/src/main/res/layout/fragment_home_head.xml index c57c32ceed6..6290f18238f 100644 --- a/app/src/main/res/layout/fragment_home_head.xml +++ b/app/src/main/res/layout/fragment_home_head.xml @@ -44,7 +44,7 @@ android:editTextColor="@color/white" android:gravity="center_vertical" android:iconifiedByDefault="true" - android:nextFocusRight="@id/home_head_profile_padding" + android:nextFocusRight="@id/home_qr_scanner_btn" android:padding="0dp" android:textColor="@color/white" android:textColorHint="@color/white" @@ -55,6 +55,19 @@ app:searchIcon="@drawable/search_icon" tools:ignore="RtlSymmetry" /> + + - - - - - + \ No newline at end of file From 68c52170554b85ddf94c719365404366d46cca31 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 13:41:17 +0300 Subject: [PATCH 15/32] Trigger fresh new push From eb97983d0fd72c0635f564da1462984f8d13965a Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 14:09:16 +0300 Subject: [PATCH 16/32] Fix Android Lint orientation error in rail_footer.xml --- app/src/main/res/layout/rail_footer.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/layout/rail_footer.xml b/app/src/main/res/layout/rail_footer.xml index 45fe559b260..4bd0262856a 100644 --- a/app/src/main/res/layout/rail_footer.xml +++ b/app/src/main/res/layout/rail_footer.xml @@ -5,6 +5,7 @@ android:id="@+id/nav_footer_root" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:orientation="vertical" android:layout_gravity="bottom|center_horizontal" android:layout_marginBottom="10dp" android:padding="1dp" From 8e8aafcc6b88386184dba74a7e3767e0f7d7a89b Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 14:42:48 +0300 Subject: [PATCH 17/32] Update: Cloudstream Plus rebranding, update progress UI, history sync fixes --- .../services/PackageInstallerService.kt | 7 +++- .../cloudstream3/ui/home/HomeFragment.kt | 31 ++++++++++++++ .../ui/settings/SettingsUpdates.kt | 2 +- .../cloudstream3/utils/DataStoreHelper.kt | 40 ++++++++++--------- .../cloudstream3/utils/InAppUpdater.kt | 10 +++-- .../cloudstream3/utils/PackageInstaller.kt | 18 +-------- app/src/main/res/layout/fragment_home.xml | 29 ++++++++++++++ 7 files changed, 98 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt index fa7754718b5..d26db20c87d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt @@ -22,6 +22,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.math.roundToInt class PackageInstallerService : Service() { @@ -64,7 +65,7 @@ class PackageInstallerService : Service() { // Delete all old updates ioSafe { - val appUpdateName = "CloudStream" + val appUpdateName = "CloudStream Plus" val appUpdateSuffix = "apk" this@PackageInstallerService.cacheDir.listFiles()?.filter { @@ -141,6 +142,8 @@ class PackageInstallerService : Service() { val id = if (state == ApkInstaller.InstallProgressStatus.Failed) UPDATE_NOTIFICATION_ID + 1 else UPDATE_NOTIFICATION_ID notificationManager.notify(id, newNotification) + + updateProgressFlow.value = Pair(percentage, state) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -177,6 +180,8 @@ class PackageInstallerService : Service() { const val UPDATE_CHANNEL_NAME = "App Updates" const val UPDATE_CHANNEL_DESCRIPTION = "App updates notification channel" const val UPDATE_NOTIFICATION_ID = -68454136 // Random unique + + val updateProgressFlow = MutableStateFlow?>(null) fun getIntent( context: Context, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 598fbbca18a..ac7ba93d921 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -23,6 +23,8 @@ import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.delay import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -761,6 +763,35 @@ class HomeFragment : BaseFragment( homeApiFab.isVisible = isLayout(PHONE) + viewLifecycleOwner.lifecycleScope.launch { + com.lagradost.cloudstream3.services.PackageInstallerService.updateProgressFlow.collect { progress -> + if (progress == null) { + homeUpdateProgressContainer.isVisible = false + return@collect + } + val (percentage, status) = progress + homeUpdateProgressContainer.isVisible = true + val isDownloading = status == com.lagradost.cloudstream3.utils.ApkInstaller.InstallProgressStatus.Downloading + homeUpdateProgressBar.isIndeterminate = !isDownloading + if (isDownloading) { + homeUpdateProgressBar.progress = (percentage * 10000).toInt() + } + homeUpdateProgressText.text = getString( + when (status) { + com.lagradost.cloudstream3.utils.ApkInstaller.InstallProgressStatus.Preparing, + com.lagradost.cloudstream3.utils.ApkInstaller.InstallProgressStatus.Downloading -> R.string.update_notification_downloading + com.lagradost.cloudstream3.utils.ApkInstaller.InstallProgressStatus.Installing -> R.string.update_notification_installing + com.lagradost.cloudstream3.utils.ApkInstaller.InstallProgressStatus.Failed -> R.string.update_notification_failed + } + ) + + if (status == com.lagradost.cloudstream3.utils.ApkInstaller.InstallProgressStatus.Failed) { + delay(5000) + com.lagradost.cloudstream3.services.PackageInstallerService.updateProgressFlow.value = null + } + } + } + homePreviewReloadProvider.setOnClickListener { homeViewModel.loadAndCancel( homeViewModel.apiName.value ?: noneApi.name, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 3e27a54d562..6cf08313071 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -327,7 +327,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { val isSuccess = AccountManager.firebaseApi.syncLocalToFirestore(requireContext()) withContext(Dispatchers.Main) { if (isSuccess) { - showToast("Sync completed successfully") + // showToast("Sync completed successfully") } else { showToast("Sync failed") } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 49ee1611b27..7ef65766fc7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -724,25 +724,29 @@ object DataStoreHelper { } } else { // save resume - when (resumeMeta) { - is ResultEpisode -> { - setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = false - ) - } + val shouldSaveResume = nextEp || position >= 300_000 + + if (shouldSaveResume) { + when (resumeMeta) { + is ResultEpisode -> { + setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = false + ) + } - is ExtractorUri -> { - setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = true - ) + is ExtractorUri -> { + setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = true + ) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index e9c3e961480..1d8055aea42 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -184,7 +184,7 @@ object InAppUpdater { private suspend fun Activity.downloadUpdate(url: String): Boolean { try { Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" + val appUpdateName = "CloudStream Plus" val appUpdateSuffix = "apk" // Delete all old updates @@ -276,7 +276,7 @@ object InAppUpdater { val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) builder.setTitle(R.string.update) - builder.setMessage("There is an update available, version ${update.updateVersion}") + builder.setMessage("Update available: ${update.updateVersion}") builder.apply { setPositiveButton(R.string.update) { _, _ -> // Forcefully start any delayed installations @@ -326,7 +326,11 @@ object InAppUpdater { } } - setNegativeButton(R.string.cancel) { _, _ -> } + setNegativeButton(R.string.cancel) { _, _ -> + settingsManager.edit { + putString(getString(R.string.skip_update_key), update.updateNodeId) + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index 67851f629cc..5dfff5c03d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -126,22 +126,8 @@ class ApkInstaller(private val service: PackageInstallerService) { service, activeSession, installIntent, installFlags ).intentSender - // Use delayed installations on android 13 and only if "allow from unknown sources" is enabled - // if the app lacks installation permission it cannot ask for the permission when it's closed. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - context.packageManager.canRequestPackageInstalls() - ) { - // Save for later installation since it's more jarring to have the app exit abruptly - delayedInstaller = DelayedInstaller(session, intentSender) - main { - // Use real toast since it should show even if app is exited - Toast.makeText(context, R.string.delayed_update_notice, Toast.LENGTH_LONG) - .show() - } - } else { - installProgressStatus.invoke(InstallProgressStatus.Installing) - session.commit(intentSender) - } + installProgressStatus.invoke(InstallProgressStatus.Installing) + session.commit(intentSender) } catch (e: Exception) { logError(e) diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 99a764deee8..237e1bbf2c0 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -254,4 +254,33 @@ android:layout_width="0dp" android:layout_height="0dp" android:visibility="gone" /> + + + + + \ No newline at end of file From 3bd111a73e46d31de6810770d57c0aa4832b0537 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 16:53:08 +0300 Subject: [PATCH 18/32] Add subtitle framerate stretch controls and smart presets --- .../cloudstream3/ui/player/CS3IPlayer.kt | 17 +++++++ .../ui/player/CustomSubtitleDecoderFactory.kt | 5 +- .../ui/player/FullScreenPlayer.kt | 27 +++++++++++ .../cloudstream3/ui/player/IPlayer.kt | 3 ++ app/src/main/res/layout/subtitle_offset.xml | 45 ++++++++++++++++++ github_log.txt | Bin 0 -> 392 bytes 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 github_log.txt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index aa44b92359b..221c1cad32e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -459,6 +459,10 @@ class CS3IPlayer : IPlayer { ) } + override fun getVideoFrameRate(): Float? { + return exoPlayer?.videoFormat?.frameRate + } + override fun getVideoTracks(): CurrentTracks { val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } @@ -554,6 +558,19 @@ class CS3IPlayer : IPlayer { return currentSubtitleOffset } + override fun setSubtitleMultiplier(multiplier: Float) { + CustomDecoder.subtitleMultiplier = multiplier + if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { + exoPlayer?.currentPosition?.also { pos -> + currentTextRenderer?.resetPosition(pos, false) + } + } + } + + override fun getSubtitleMultiplier(): Float { + return CustomDecoder.subtitleMultiplier + } + override fun getSubtitleCues(): List { return currentSubtitleDecoder?.getSubtitleCues() ?: emptyList() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 61d6f556450..6062cc4febd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -64,6 +64,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { /** Subtitle offset in milliseconds */ var subtitleOffset: Long = 0 + var subtitleMultiplier: Float = 1.0f private const val UTF_8 = "UTF-8" private const val TAG = "CustomDecoder" private var overrideEncoding: String? = null @@ -308,8 +309,8 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { val updatedCues = CuesWithTiming( newCue.cues, - newCue.startTimeUs - subtitleOffset.times(1000), - newCue.durationUs + (newCue.startTimeUs * subtitleMultiplier).toLong() - subtitleOffset.times(1000), + (newCue.durationUs * subtitleMultiplier).toLong() ) output.accept(updatedCues) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 26706699bcc..86a01c650ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -598,6 +598,33 @@ open class FullScreenPlayer : AbstractPlayerFragment( changeBy(-buttonChangeMore) } + // Framerate Multiplier & Online Subs + val videoFps = player.getVideoFrameRate() ?: 24.0f + subtitleMultiplierInput?.text = Editable.Factory.getInstance()?.newEditable(player.getSubtitleMultiplier().toString()) + + subtitleMultiplierInput?.doOnTextChanged { text, _, _, _ -> + text?.toString()?.toFloatOrNull()?.let { mult -> + player.setSubtitleMultiplier(mult) + } + } + + subtitleMultiplierAuto25?.setOnClickListener { + val newMult = videoFps / 25.0f + subtitleMultiplierInput?.text = Editable.Factory.getInstance()?.newEditable(newMult.toString()) + } + + subtitleMultiplierAuto24?.setOnClickListener { + val newMult = videoFps / 24.0f + subtitleMultiplierInput?.text = Editable.Factory.getInstance()?.newEditable(newMult.toString()) + } + + subtitleOnlineBtt?.setOnClickListener { + dialog.dismissSafe(activity) + if (subsProvidersIsActive) { + openOnlineSubPicker(ctx, null) {} + } + } + dialog.setOnDismissListener { selectSubtitlesDialog = null activity?.hideSystemUI() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 0342372667f..b4cc8ddc307 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -221,6 +221,8 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms + fun getSubtitleMultiplier(): Float + fun setSubtitleMultiplier(multiplier: Float) @AnyThread fun initCallbacks( @@ -269,6 +271,7 @@ interface IPlayer { fun isActive(): Boolean fun getVideoTracks(): CurrentTracks + fun getVideoFrameRate(): Float? /** * Original video aspect ratio used for PiP mode diff --git a/app/src/main/res/layout/subtitle_offset.xml b/app/src/main/res/layout/subtitle_offset.xml index 8570e9a266b..7a30f5e5db5 100644 --- a/app/src/main/res/layout/subtitle_offset.xml +++ b/app/src/main/res/layout/subtitle_offset.xml @@ -143,6 +143,51 @@ tools:ignore="ContentDescription" /> + + + + + + + + + + + + diff --git a/github_log.txt b/github_log.txt new file mode 100644 index 0000000000000000000000000000000000000000..f1d09733bda1eb97f140f5c6ca260a3904aeb994 GIT binary patch literal 392 zcmZvY(GCGI5Jm5^#6N7-t0LjaKX~y2!eV!^OWH+5{5sB5h=?@p%-nl>JJZMgsG8O0nQMwi+VVAzg}B{ zXL#GIHL5wg1gkXz_1b+pFz*JxKu3Mn>t?_)kNySv1y#oF3f|*r9ry9fKCXfK9DCVn zjr*R;X+vC~?nKu!uN!0Yy*U?=9F60i&EnwQA1_ZI_0N>nhfQXG`2Nw61{zY?&)?$t E1nnb3U;qFB literal 0 HcmV?d00001 From a4ae2e2fefd2ac8a2954eab10847980fb5bdfc41 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 18:11:28 +0300 Subject: [PATCH 19/32] Fix subtitle drop-off bug, and overhaul app updater UI/install intent --- .../cloudstream3/ui/home/HomeFragment.kt | 4 +++ .../ui/player/CustomSubtitleDecoderFactory.kt | 17 +++++++-- .../cloudstream3/utils/PackageInstaller.kt | 12 +++---- app/src/main/res/layout/fragment_home.xml | 36 +++++++++++++------ 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index ac7ba93d921..9795a6c8653 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -775,6 +775,10 @@ class HomeFragment : BaseFragment( homeUpdateProgressBar.isIndeterminate = !isDownloading if (isDownloading) { homeUpdateProgressBar.progress = (percentage * 10000).toInt() + homeUpdateProgressPercentage.isVisible = true + homeUpdateProgressPercentage.text = "${(percentage * 100f).toInt()}%" + } else { + homeUpdateProgressPercentage.isVisible = false } homeUpdateProgressText.text = getString( when (status) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 6062cc4febd..dc3c891e474 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -305,12 +305,25 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { newCue.cues.map { it.text.toString() }) ) + // Fix precision loss bug for C.TIME_UNSET sentinel values + val scaledStartTimeUs = if (newCue.startTimeUs == C.TIME_UNSET) { + C.TIME_UNSET + } else { + (newCue.startTimeUs * subtitleMultiplier).toLong() - subtitleOffset.times(1000) + } + + val scaledDurationUs = if (newCue.durationUs == C.TIME_UNSET) { + C.TIME_UNSET + } else { + (newCue.durationUs * subtitleMultiplier).toLong() + } + // offset timing for the final val updatedCues = CuesWithTiming( newCue.cues, - (newCue.startTimeUs * subtitleMultiplier).toLong() - subtitleOffset.times(1000), - (newCue.durationUs * subtitleMultiplier).toLong() + scaledStartTimeUs, + scaledDurationUs ) output.accept(updatedCues) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index 5dfff5c03d0..44d35de91bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -110,16 +110,12 @@ class ApkInstaller(private val service: PackageInstallerService) { inputStream.close() } - // We must create an explicit intent or it will fail on Android 15+ - val installIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - Intent(service, PackageInstallerService::class.java) - .setAction(INSTALL_ACTION) - } else Intent(INSTALL_ACTION) + // We must set the package for implicit broadcasts on modern Android + val installIntent = Intent(INSTALL_ACTION).setPackage(service.packageName) val installFlags = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> PendingIntent.FLAG_MUTABLE - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_IMMUTABLE - else -> 0 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else -> PendingIntent.FLAG_UPDATE_CURRENT } val intentSender = PendingIntent.getBroadcast( diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 237e1bbf2c0..40657b20c36 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -260,27 +260,43 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/colorPrimary" - android:orientation="vertical" + android:orientation="horizontal" android:visibility="gone" - android:padding="10dp" - android:elevation="4dp" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp" + android:elevation="8dp" + android:gravity="center_vertical" android:layout_gravity="top" tools:visibility="visible"> + - + + + app:trackCornerRadius="4dp" + app:indicatorColor="@color/white" + app:trackColor="#40FFFFFF" /> + + \ No newline at end of file From e6129de33474e626cb942b1d5c45e68a756c3b04 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 18:18:09 +0300 Subject: [PATCH 20/32] Fix unresolved reference to C in CustomSubtitleDecoderFactory --- .../cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index dc3c891e474..13f2913a728 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -4,6 +4,7 @@ import android.content.Context import android.text.Layout import android.util.Log import androidx.annotation.OptIn +import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.text.Cue From 1e54512f3293bfda5c21f3d6bf765d55ec269c43 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Mon, 25 May 2026 19:09:08 +0300 Subject: [PATCH 21/32] Fix TV Authentication flow and QR scanner bugs --- app/src/main/AndroidManifest.xml | 5 ++ .../cloudstream3/ui/home/HomeFragment.kt | 57 +++++++++++++------ .../ui/sync/CustomScannerActivity.kt | 9 +++ .../cloudstream3/ui/sync/FragmentPairTv.kt | 52 ++++++++++++----- .../cloudstream3/ui/sync/LoginFragment.kt | 24 +++++++- 5 files changed, 114 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/sync/CustomScannerActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..fd407048e62 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -290,6 +290,11 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 9795a6c8653..72c6f76be0d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -611,22 +611,41 @@ class HomeFragment : BaseFragment( } } - private fun submitPairingCode(code: String) { + private fun submitPairingCode(rawCode: String) { val ctx = context ?: return + + // Parse URI if it's a deep link + val code = try { + val uri = android.net.Uri.parse(rawCode) + uri.getQueryParameter("code") ?: rawCode + } catch (e: Exception) { + rawCode + } + var email = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_email") var password = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_password") + var googleIdToken: String? = null kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { try { val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser - if (password.isNullOrBlank() && user != null && user.email != null) { - val generatedPassword = java.util.UUID.randomUUID().toString().replace("-", "") + "A1!" + if (user == null) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Please log in first to pair TV.", Toast.LENGTH_LONG).show() + } + return@launch + } + + if (password.isNullOrBlank() && user.email != null) { try { - com.google.android.gms.tasks.Tasks.await(user.updatePassword(generatedPassword)) - com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey("firebase_password", generatedPassword) - com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey("firebase_email", user.email) - email = user.email - password = generatedPassword + val gso = com.google.android.gms.auth.api.signin.GoogleSignInOptions.Builder(com.google.android.gms.auth.api.signin.GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(ctx.getString(R.string.default_web_client_id)) + .requestEmail() + .build() + val googleSignInClient = com.google.android.gms.auth.api.signin.GoogleSignIn.getClient(ctx, gso) + val account = com.google.android.gms.tasks.Tasks.await(googleSignInClient.silentSignIn()) + googleIdToken = account.idToken + email = account.email } catch (e: Exception) { logError(e) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { @@ -636,7 +655,7 @@ class HomeFragment : BaseFragment( } } - if (email.isNullOrBlank() || password.isNullOrBlank()) { + if (googleIdToken.isNullOrBlank() && (email.isNullOrBlank() || password.isNullOrBlank())) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() } @@ -664,14 +683,17 @@ class HomeFragment : BaseFragment( return@launch } - val finalEmail = email - val finalPassword = password - val updateData = hashMapOf( - "status" to "authorized", - "email" to finalEmail, - "password" to finalPassword + val updateData = hashMapOf( + "status" to "authorized" ) - com.google.android.gms.tasks.Tasks.await(docRef.update(updateData as Map)) + if (!googleIdToken.isNullOrBlank()) { + updateData["googleIdToken"] = googleIdToken!! + } else { + updateData["email"] = email!! + updateData["password"] = password!! + } + + com.google.android.gms.tasks.Tasks.await(docRef.update(updateData)) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() @@ -754,7 +776,8 @@ class HomeFragment : BaseFragment( setDesiredBarcodeFormats(ScanOptions.QR_CODE) setPrompt("Scan TV QR Code") setBeepEnabled(false) - setOrientationLocked(false) + setOrientationLocked(true) + setCaptureActivity(com.lagradost.cloudstream3.ui.sync.CustomScannerActivity::class.java) }) } ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/CustomScannerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/CustomScannerActivity.kt new file mode 100644 index 00000000000..4e86ef59e21 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/CustomScannerActivity.kt @@ -0,0 +1,9 @@ +package com.lagradost.cloudstream3.ui.sync + +import com.journeyapps.barcodescanner.CaptureActivity + +class CustomScannerActivity : CaptureActivity() { + // This activity is intentionally empty. + // It's used purely to provide a custom CaptureActivity for the QR scanner + // that we can force to portrait orientation via the AndroidManifest.xml. +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt index 8b66d6c1f33..e64f3e2971a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt @@ -49,24 +49,42 @@ class FragmentPairTv : Fragment() { } } - private fun submitPairingCode(code: String) { + private fun submitPairingCode(rawCode: String) { val ctx = context ?: return + + // Parse URI if it's a deep link + val code = try { + val uri = android.net.Uri.parse(rawCode) + uri.getQueryParameter("code") ?: rawCode + } catch (e: Exception) { + rawCode + } + var email = ctx.getKey("firebase_email") var password = ctx.getKey("firebase_password") + var googleIdToken: String? = null setLoading(true) lifecycleScope.launch { try { val user = FirebaseAuth.getInstance().currentUser - if (password.isNullOrBlank() && user != null && user.email != null) { - val generatedPassword = java.util.UUID.randomUUID().toString().replace("-", "") + "A1!" + if (user == null) { + Toast.makeText(ctx, "Please log in first to pair TV.", Toast.LENGTH_LONG).show() + setLoading(false) + return@launch + } + + if (password.isNullOrBlank() && user.email != null) { try { - user.updatePassword(generatedPassword).await() - ctx.setKey("firebase_password", generatedPassword) - ctx.setKey("firebase_email", user.email) - email = user.email - password = generatedPassword + val gso = com.google.android.gms.auth.api.signin.GoogleSignInOptions.Builder(com.google.android.gms.auth.api.signin.GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(ctx.getString(R.string.default_web_client_id)) + .requestEmail() + .build() + val googleSignInClient = com.google.android.gms.auth.api.signin.GoogleSignIn.getClient(ctx, gso) + val account = com.google.android.gms.tasks.Tasks.await(googleSignInClient.silentSignIn()) + googleIdToken = account.idToken + email = account.email } catch (e: Exception) { logError(e) Toast.makeText(ctx, "Please log out and log back in to pair TV (Recent login required).", Toast.LENGTH_LONG).show() @@ -75,7 +93,7 @@ class FragmentPairTv : Fragment() { } } - if (email.isNullOrBlank() || password.isNullOrBlank()) { + if (googleIdToken.isNullOrBlank() && (email.isNullOrBlank() || password.isNullOrBlank())) { Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() setLoading(false) return@launch @@ -100,13 +118,17 @@ class FragmentPairTv : Fragment() { return@launch } - // Update the document to authorize the TV - val updateData = hashMapOf( - "status" to "authorized", - "email" to email, - "password" to password + val updateData = hashMapOf( + "status" to "authorized" ) - docRef.update(updateData as Map).await() + if (!googleIdToken.isNullOrBlank()) { + updateData["googleIdToken"] = googleIdToken!! + } else { + updateData["email"] = email!! + updateData["password"] = password!! + } + + docRef.update(updateData).await() Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() activity?.onBackPressed() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt index 01b10607c98..1c32a8d7a71 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt @@ -231,7 +231,12 @@ class LoginFragment : Fragment() { if (status == "authorized") { val email = snapshot.getString("email") val password = snapshot.getString("password") - if (!email.isNullOrBlank() && !password.isNullOrBlank()) { + val googleIdToken = snapshot.getString("googleIdToken") + + if (!googleIdToken.isNullOrBlank()) { + stopPairingListener() + loginWithGoogleIdToken(googleIdToken, code) + } else if (!email.isNullOrBlank() && !password.isNullOrBlank()) { stopPairingListener() loginWithCredentials(email, password, code) } @@ -245,6 +250,23 @@ class LoginFragment : Fragment() { pairingListener = null } + private fun loginWithGoogleIdToken(idToken: String, code: String) { + val act = activity ?: return + setLoading(true) + val credential = com.google.firebase.auth.GoogleAuthProvider.getCredential(idToken, null) + auth.signInWithCredential(credential) + .addOnCompleteListener(act) { task -> + if (task.isSuccessful) { + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + firestore.collection("pairing_codes").document(code).delete() + onLoginSuccess() + } else { + Toast.makeText(context, "Pairing Google login failed.", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } + } + private fun loginWithCredentials(email: String, password: String, code: String) { val act = activity ?: return setLoading(true) From e0b7e70b3a58db3a38bdb57726578784688176f9 Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Tue, 26 May 2026 04:28:26 +0300 Subject: [PATCH 22/32] Add Prioritize Subtitles toggle to search filters --- .../cloudstream3/ui/search/SearchFragment.kt | 16 ++++- .../cloudstream3/ui/search/SearchViewModel.kt | 58 +++++++++++++------ .../main/res/layout/home_select_mainpage.xml | 9 +++ 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index 5f5b064b543..7e5ee2ead77 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -311,6 +311,15 @@ class SearchFragment : BaseFragment( val cancelBtt = dialog.findViewById(R.id.cancel_btt) val applyBtt = dialog.findViewById(R.id.apply_btt) + + val prioritizeSubsToggle = dialog.findViewById(R.id.search_prioritize_subs_toggle) + val prefManager = PreferenceManager.getDefaultSharedPreferences(ctx) + var prioritizeSubs = prefManager.getBoolean("prioritize_subtitles", false) + prioritizeSubsToggle?.isChecked = prioritizeSubs + + prioritizeSubsToggle?.setOnCheckedChangeListener { _, isChecked -> + prioritizeSubs = isChecked + } val listView = dialog.findViewById(R.id.listview1) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) @@ -392,11 +401,16 @@ class SearchFragment : BaseFragment( } dialog.setOnDismissListener { + val oldPrioritizeSubs = prefManager.getBoolean("prioritize_subtitles", false) + if (prioritizeSubs != oldPrioritizeSubs) { + prefManager.edit().putBoolean("prioritize_subtitles", prioritizeSubs).apply() + } + DataStoreHelper.searchPreferenceProviders = currentSelectedApis.toList() selectedApis = currentSelectedApis // run search when dialog is close - if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { + if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet() || prioritizeSubs != oldPrioritizeSubs) { search(binding.mainSearch.query.toString()) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index 27db8d1ae5e..085ef9d5ea4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -170,26 +170,50 @@ class SearchViewModel : ViewModel() { } private fun bundleSearch(lists: MutableMap): ExpandableSearchList { + var list = ArrayList() if (lists.size == 1) { - return lists.values.first() - } - - val list = ArrayList() - val nestedList = - lists.map { it.value.list } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ + list.addAll(lists.values.first().list) + } else { + val nestedList = lists.map { it.value.list } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } } + if (added == 0) break + index++ } - if (added == 0) break - index++ + } + + val prioritizeSubs = com.lagradost.cloudstream3.CloudStreamApp.context?.let { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(it).getBoolean("prioritize_subtitles", false) + } ?: false + + if (prioritizeSubs) { + val subbedKeywords = listOf("[SUB]", "(SUB)", "SUBBED", "MULTI-SUB", "SUBTITLE") + val dubbedKeywords = listOf("[DUB]", "(DUB)", "DUBBED") + + list = ArrayList(list.sortedWith(Comparator { a, b -> + val aName = a.name.uppercase() + val bName = b.name.uppercase() + + val aHasSub = subbedKeywords.any { aName.contains(it) } + val aHasDub = dubbedKeywords.any { aName.contains(it) } + + val bHasSub = subbedKeywords.any { bName.contains(it) } + val bHasDub = dubbedKeywords.any { bName.contains(it) } + + val aScore = if (aHasSub) 1 else if (aHasDub) -1 else 0 + val bScore = if (bHasSub) 1 else if (bHasDub) -1 else 0 + + bScore.compareTo(aScore) + })) } return ExpandableSearchList(list, 1, false) diff --git a/app/src/main/res/layout/home_select_mainpage.xml b/app/src/main/res/layout/home_select_mainpage.xml index dc47b786e88..2e9a8e9ae8b 100644 --- a/app/src/main/res/layout/home_select_mainpage.xml +++ b/app/src/main/res/layout/home_select_mainpage.xml @@ -5,6 +5,15 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> + Date: Tue, 26 May 2026 04:52:42 +0300 Subject: [PATCH 23/32] Fix UI overlap for subtitle toggle switch --- .../main/res/layout/home_select_mainpage.xml | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/app/src/main/res/layout/home_select_mainpage.xml b/app/src/main/res/layout/home_select_mainpage.xml index 2e9a8e9ae8b..39e3cf33e4b 100644 --- a/app/src/main/res/layout/home_select_mainpage.xml +++ b/app/src/main/res/layout/home_select_mainpage.xml @@ -5,30 +5,36 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> - + android:layout_height="match_parent" + android:orientation="vertical" + android:layout_marginBottom="60dp"> - + + + + Date: Tue, 26 May 2026 09:47:50 +0300 Subject: [PATCH 24/32] Fix TV UI crash, add interactive Google Sign-In fallback for pairing --- .../cloudstream3/ui/home/HomeFragment.kt | 123 ++++++++++++++--- .../cloudstream3/ui/sync/FragmentPairTv.kt | 130 +++++++++++++++--- app/src/main/res/layout/fragment_home_tv.xml | 44 ++++++ 3 files changed, 256 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 72c6f76be0d..5476f85cca6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -603,6 +603,27 @@ class HomeFragment : BaseFragment( private var currentApiName: String? = null private var toggleRandomButton = false + // Stores the pairing code so we can use it after interactive sign-in completes + private var pendingPairingCode: String? = null + + // Interactive Google Sign-In launcher (fallback when silentSignIn fails) + private val googleSignInForPairingLauncher = + registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()) { result -> + val task = com.google.android.gms.auth.api.signin.GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(com.google.android.gms.common.api.ApiException::class.java)!! + val code = pendingPairingCode + if (code != null && account.idToken != null) { + completePairingWithToken(code, account.idToken!!) + } else { + Toast.makeText(context, "Google sign in did not return a token.", Toast.LENGTH_SHORT).show() + } + } catch (e: com.google.android.gms.common.api.ApiException) { + logError(e) + Toast.makeText(context, "Google sign in was cancelled or failed.", Toast.LENGTH_SHORT).show() + } + } + private val barcodeLauncher = registerForActivityResult( ScanContract() ) { result: ScanIntentResult -> @@ -622,11 +643,10 @@ class HomeFragment : BaseFragment( rawCode } - var email = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_email") - var password = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_password") - var googleIdToken: String? = null + val email = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_email") + val password = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_password") - kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { + viewLifecycleOwner.lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { try { val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser if (user == null) { @@ -636,7 +656,14 @@ class HomeFragment : BaseFragment( return@launch } - if (password.isNullOrBlank() && user.email != null) { + // If we have email/password credentials, use those directly + if (!email.isNullOrBlank() && !password.isNullOrBlank()) { + completePairingWithCredentials(code, email, password) + return@launch + } + + // Otherwise try to get a Google ID token + if (user.email != null) { try { val gso = com.google.android.gms.auth.api.signin.GoogleSignInOptions.Builder(com.google.android.gms.auth.api.signin.GoogleSignInOptions.DEFAULT_SIGN_IN) .requestIdToken(ctx.getString(R.string.default_web_client_id)) @@ -644,24 +671,86 @@ class HomeFragment : BaseFragment( .build() val googleSignInClient = com.google.android.gms.auth.api.signin.GoogleSignIn.getClient(ctx, gso) val account = com.google.android.gms.tasks.Tasks.await(googleSignInClient.silentSignIn()) - googleIdToken = account.idToken - email = account.email + completePairingWithToken(code, account.idToken!!) } catch (e: Exception) { + // Silent sign-in failed — launch interactive sign-in on UI thread logError(e) + pendingPairingCode = code kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - Toast.makeText(ctx, "Please log out and log back in to pair TV (Recent login required).", Toast.LENGTH_LONG).show() + val gso = com.google.android.gms.auth.api.signin.GoogleSignInOptions.Builder(com.google.android.gms.auth.api.signin.GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(ctx.getString(R.string.default_web_client_id)) + .requestEmail() + .build() + val googleSignInClient = com.google.android.gms.auth.api.signin.GoogleSignIn.getClient(ctx, gso) + googleSignInForPairingLauncher.launch(googleSignInClient.signInIntent) } - return@launch } + } else { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + } + } + + } catch (e: Exception) { + logError(e) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() } + } + } + } - if (googleIdToken.isNullOrBlank() && (email.isNullOrBlank() || password.isNullOrBlank())) { + /** Complete pairing using a Google ID token */ + private fun completePairingWithToken(code: String, googleIdToken: String) { + viewLifecycleOwner.lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + val ctx = context ?: return@launch + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + val docRef = firestore.collection("pairing_codes").document(code) + val snapshot = com.google.android.gms.tasks.Tasks.await(docRef.get()) + + if (!snapshot.exists()) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + Toast.makeText(ctx, "Invalid or expired pairing code.", Toast.LENGTH_SHORT).show() } return@launch } + val createdAt = snapshot.getLong("createdAt") ?: 0L + val status = snapshot.getString("status") + + if (status != "pending" || System.currentTimeMillis() - createdAt > 5 * 60 * 1000) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "Pairing code has expired or is already paired.", Toast.LENGTH_SHORT).show() + } + return@launch + } + + val updateData = hashMapOf( + "status" to "authorized", + "googleIdToken" to googleIdToken + ) + + com.google.android.gms.tasks.Tasks.await(docRef.update(updateData)) + + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + } + + } catch (e: Exception) { + logError(e) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + Toast.makeText(context, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + } + } + } + } + + /** Complete pairing using email/password credentials */ + private fun completePairingWithCredentials(code: String, email: String, password: String) { + viewLifecycleOwner.lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + val ctx = context ?: return@launch val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() val docRef = firestore.collection("pairing_codes").document(code) val snapshot = com.google.android.gms.tasks.Tasks.await(docRef.get()) @@ -684,14 +773,10 @@ class HomeFragment : BaseFragment( } val updateData = hashMapOf( - "status" to "authorized" + "status" to "authorized", + "email" to email, + "password" to password ) - if (!googleIdToken.isNullOrBlank()) { - updateData["googleIdToken"] = googleIdToken!! - } else { - updateData["email"] = email!! - updateData["password"] = password!! - } com.google.android.gms.tasks.Tasks.await(docRef.update(updateData)) @@ -702,7 +787,7 @@ class HomeFragment : BaseFragment( } catch (e: Exception) { logError(e) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - Toast.makeText(ctx, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt index e64f3e2971a..406bf582241 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt @@ -5,16 +5,19 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentPairTvBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.DataStore.getKey -import kotlinx.coroutines.tasks.await import com.lagradost.cloudstream3.utils.DataStore.setKey import kotlinx.coroutines.tasks.await import kotlinx.coroutines.launch @@ -23,6 +26,30 @@ class FragmentPairTv : Fragment() { private var _binding: FragmentPairTvBinding? = null private val binding get() = _binding!! + // Stores the pairing code so we can use it after interactive sign-in completes + private var pendingPairingCode: String? = null + + // Interactive Google Sign-In launcher (fallback when silentSignIn fails) + private val googleSignInLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java)!! + val code = pendingPairingCode + if (code != null && account.idToken != null) { + // We got a fresh token, now complete the pairing + completePairing(code, account.idToken!!, account.email) + } else { + Toast.makeText(context, "Google sign in did not return a token.", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } catch (e: ApiException) { + logError(e) + Toast.makeText(context, "Google sign in was cancelled or failed.", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -60,9 +87,8 @@ class FragmentPairTv : Fragment() { rawCode } - var email = ctx.getKey("firebase_email") - var password = ctx.getKey("firebase_password") - var googleIdToken: String? = null + val email = ctx.getKey("firebase_email") + val password = ctx.getKey("firebase_password") setLoading(true) @@ -75,30 +101,94 @@ class FragmentPairTv : Fragment() { return@launch } - if (password.isNullOrBlank() && user.email != null) { + // If we have email/password credentials, use those directly + if (!email.isNullOrBlank() && !password.isNullOrBlank()) { + completePairingWithCredentials(code, email, password) + return@launch + } + + // Otherwise try to get a Google ID token + if (user.email != null) { try { - val gso = com.google.android.gms.auth.api.signin.GoogleSignInOptions.Builder(com.google.android.gms.auth.api.signin.GoogleSignInOptions.DEFAULT_SIGN_IN) + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestIdToken(ctx.getString(R.string.default_web_client_id)) .requestEmail() .build() - val googleSignInClient = com.google.android.gms.auth.api.signin.GoogleSignIn.getClient(ctx, gso) + val googleSignInClient = GoogleSignIn.getClient(ctx, gso) val account = com.google.android.gms.tasks.Tasks.await(googleSignInClient.silentSignIn()) - googleIdToken = account.idToken - email = account.email + completePairing(code, account.idToken!!, account.email) } catch (e: Exception) { + // Silent sign-in failed — launch interactive sign-in logError(e) - Toast.makeText(ctx, "Please log out and log back in to pair TV (Recent login required).", Toast.LENGTH_LONG).show() - setLoading(false) - return@launch + pendingPairingCode = code + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(ctx.getString(R.string.default_web_client_id)) + .requestEmail() + .build() + val googleSignInClient = GoogleSignIn.getClient(ctx, gso) + googleSignInLauncher.launch(googleSignInClient.signInIntent) } + } else { + Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + setLoading(false) } - if (googleIdToken.isNullOrBlank() && (email.isNullOrBlank() || password.isNullOrBlank())) { - Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + } catch (e: Exception) { + logError(e) + Toast.makeText(ctx, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + setLoading(false) + } + } + } + + /** Complete pairing using a Google ID token */ + private fun completePairing(code: String, googleIdToken: String, email: String?) { + lifecycleScope.launch { + try { + val ctx = context ?: return@launch + val firestore = FirebaseFirestore.getInstance() + val docRef = firestore.collection("pairing_codes").document(code) + val snapshot = docRef.get().await() + + if (!snapshot.exists()) { + Toast.makeText(ctx, "Invalid or expired pairing code.", Toast.LENGTH_SHORT).show() setLoading(false) return@launch } + val createdAt = snapshot.getLong("createdAt") ?: 0L + val status = snapshot.getString("status") + + if (status != "pending" || System.currentTimeMillis() - createdAt > 5 * 60 * 1000) { + Toast.makeText(ctx, "Pairing code has expired or is already paired.", Toast.LENGTH_SHORT).show() + setLoading(false) + return@launch + } + + val updateData = hashMapOf( + "status" to "authorized", + "googleIdToken" to googleIdToken + ) + + docRef.update(updateData).await() + + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + activity?.onBackPressed() + + } catch (e: Exception) { + logError(e) + Toast.makeText(context, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + } finally { + setLoading(false) + } + } + } + + /** Complete pairing using email/password credentials */ + private fun completePairingWithCredentials(code: String, email: String, password: String) { + lifecycleScope.launch { + try { + val ctx = context ?: return@launch val firestore = FirebaseFirestore.getInstance() val docRef = firestore.collection("pairing_codes").document(code) val snapshot = docRef.get().await() @@ -119,14 +209,10 @@ class FragmentPairTv : Fragment() { } val updateData = hashMapOf( - "status" to "authorized" + "status" to "authorized", + "email" to email, + "password" to password ) - if (!googleIdToken.isNullOrBlank()) { - updateData["googleIdToken"] = googleIdToken!! - } else { - updateData["email"] = email!! - updateData["password"] = password!! - } docRef.update(updateData).await() @@ -135,7 +221,7 @@ class FragmentPairTv : Fragment() { } catch (e: Exception) { logError(e) - Toast.makeText(ctx, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "An error occurred during pairing.", Toast.LENGTH_SHORT).show() } finally { setLoading(false) } diff --git a/app/src/main/res/layout/fragment_home_tv.xml b/app/src/main/res/layout/fragment_home_tv.xml index d1d5c9e3bd9..471d269588d 100644 --- a/app/src/main/res/layout/fragment_home_tv.xml +++ b/app/src/main/res/layout/fragment_home_tv.xml @@ -279,4 +279,48 @@ app:icon="@drawable/ic_baseline_play_arrow_24" tools:ignore="ContentDescription" tools:visibility="visible" /> + + + + + + + + \ No newline at end of file From 305f117777050dd4ddf23fea838ab56396bd0fba Mon Sep 17 00:00:00 2001 From: Smart Habesha Developer Date: Tue, 26 May 2026 11:37:25 +0300 Subject: [PATCH 25/32] Feature: Add MDBList IMDb and Rotten Tomatoes ratings to movie/series results --- app/build.gradle.kts | 5 +++ .../metadataproviders/MdbListResponse.kt | 20 +++++++++ .../metadataproviders/MetadataRepository.kt | 44 +++++++++++++++++++ .../ui/result/ResultFragmentPhone.kt | 23 +++++++++- .../ui/result/ResultFragmentTv.kt | 21 +++++++++ .../ui/result/ResultViewModel2.kt | 16 +++++++ app/src/main/res/layout/fragment_result.xml | 14 ++++++ .../main/res/layout/fragment_result_tv.xml | 14 ++++++ 8 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MdbListResponse.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MetadataRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 390742f1c7e..963d720c951 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,11 @@ android { "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) + buildConfigField( + "String", + "MDBLIST_API_KEY", + "\"" + (System.getenv("MDBLIST_API_KEY") ?: localProperties["MDBLIST_API_KEY"] ?: "") + "\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MdbListResponse.kt b/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MdbListResponse.kt new file mode 100644 index 00000000000..4dd62b02f57 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MdbListResponse.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3.metadataproviders + +import com.fasterxml.jackson.annotation.JsonProperty + +data class MdbListResponse( + @JsonProperty("response") val response: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("year") val year: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("score") val score: Int? = null, // average MDBList score + @JsonProperty("score_average") val scoreAverage: Double? = null, + @JsonProperty("ratings") val ratings: List? = null +) + +data class MdbListRating( + @JsonProperty("source") val source: String? = null, // "imdb", "tomatoes", "tomatoesaudience", "metacritic" + @JsonProperty("value") val value: Double? = null, + @JsonProperty("score") val score: Int? = null, + @JsonProperty("votes") val votes: Long? = null +) diff --git a/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MetadataRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MetadataRepository.kt new file mode 100644 index 00000000000..e1a973ca3a0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MetadataRepository.kt @@ -0,0 +1,44 @@ +package com.lagradost.cloudstream3.metadataproviders + +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.app +import java.util.concurrent.TimeUnit +import android.util.Log + +object MetadataRepository { + private const val MDBLIST_API_URL = "https://mdblist.com/api/" + private const val TAG = "MetadataRepository" + + /** + * Fetches ratings from MDBList. + * Uses Cloudstream's native `app.get` with built-in caching. + * Cached for 7 days so we don't spam the MDBList API. + */ + suspend fun getRatings(title: String, year: Int? = null, imdbId: String? = null): MdbListResponse? { + val apiKey = BuildConfig.MDBLIST_API_KEY + if (apiKey.isBlank()) { + Log.w(TAG, "MDBList API Key is missing in BuildConfig") + return null + } + + return try { + val url = if (!imdbId.isNullOrBlank()) { + "$MDBLIST_API_URL?apikey=$apiKey&i=$imdbId" + } else { + "$MDBLIST_API_URL?apikey=$apiKey&s=$title" + (if (year != null) "&y=$year" else "") + } + + // `app.get` automatically handles OkHttp caching + val response = app.get( + url = url, + cacheTime = 7, + cacheUnit = TimeUnit.DAYS + ) + + response.parsedSafe() + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch MDBList ratings: ${e.message}") + null + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 90ae76f6535..35dfca515ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -1082,7 +1082,28 @@ open class ResultFragmentPhone : BaseFragment( } observeNullable(viewModel.episodesCountText) { count -> - resultBinding?.resultEpisodesText.setText(count) + resultBinding?.resultEpisodesText?.setText(count) + } + + observeNullable(viewModel.ratingsData) { ratings -> + resultBinding?.apply { + val imdb = ratings?.imdbScore + val rt = ratings?.rottenTomatoesScore + + if (imdb != null && imdb > 0.0) { + resultMetaImdb.text = "⭐ $imdb" + resultMetaImdb.isVisible = true + } else { + resultMetaImdb.isGone = true + } + + if (rt != null && rt > 0) { + resultMetaRottenTomatoes.text = "🍅 $rt%" + resultMetaRottenTomatoes.isVisible = true + } else { + resultMetaRottenTomatoes.isGone = true + } + } } observeNullable(viewModel.selectPopup) { popup -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index cfbacc5d13f..eff98d94e97 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -764,6 +764,27 @@ class ResultFragmentTv : BaseFragment( binding.resultEpisodesText.setText(count) } + observeNullable(viewModel.ratingsData) { ratings -> + binding.apply { + val imdb = ratings?.imdbScore + val rt = ratings?.rottenTomatoesScore + + if (imdb != null && imdb > 0.0) { + resultMetaImdb.text = "⭐ $imdb" + resultMetaImdb.isVisible = true + } else { + resultMetaImdb.isGone = true + } + + if (rt != null && rt > 0) { + resultMetaRottenTomatoes.text = "🍅 $rt%" + resultMetaRottenTomatoes.isVisible = true + } else { + resultMetaRottenTomatoes.isGone = true + } + } + } + observe(viewModel.selectedRangeIndex) { selected -> binding.resultRangeSelection.select(selected) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index cc3eef97830..a37ee9b1e8e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -507,6 +507,10 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(mutableListOf()) val trailers: LiveData> = _trailers + private val _ratingsData: MutableLiveData = + MutableLiveData(null) + val ratingsData: LiveData = _ratingsData + private val _dubSubSelections: MutableLiveData>> = MutableLiveData(emptyList()) val dubSubSelections: LiveData>> = _dubSubSelections @@ -2158,6 +2162,7 @@ class ResultViewModel2 : ViewModel() { postPage(loadResponse, apiRepository) postSubscription(loadResponse) postFavorites(loadResponse) + loadRatings(loadResponse) val currentState = getResultWatchState(mainId) _watchStatus.postValue(currentState) @@ -2446,6 +2451,17 @@ class ResultViewModel2 : ViewModel() { ) // we dont want to fetch too many trailers } + private fun loadRatings(loadResponse: LoadResponse) = ioSafe { + _ratingsData.postValue(null) + val imdbId = getImdbIdFromSyncData(loadResponse.syncData) + val title = loadResponse.name + val year = loadResponse.year + val ratings = com.lagradost.cloudstream3.metadataproviders.MetadataRepository.getRatings(title, year, imdbId) + if (ratings != null) { + _ratingsData.postValue(ratings) + } + } + private suspend fun getTrailers( loadResponse: LoadResponse, limit: Int = 0 diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index a5c933d6a03..b1db4bcc85c 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -409,6 +409,20 @@ android:id="@+id/result_meta_duration" style="@style/ResultInfoText" tools:text="121min" /> + + + +