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/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index b5960d5d942..ab209152448 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -58,6 +58,12 @@ jobs: echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + - name: Setup Google Services + run: | + if [ -n "${{ secrets.GOOGLE_SERVICES_JSON }}" ]; then + echo "${{ secrets.GOOGLE_SERVICES_JSON }}" > app/google-services.json + fi + - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: @@ -72,6 +78,7 @@ jobs: SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + MDBLIST_API_KEY: ${{ secrets.MDBLIST_API_KEY }} - uses: actions/checkout@v6 with: diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index d9a20a04b2b..f0fc6f22b34 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: @@ -19,14 +20,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 +31,39 @@ 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 Google Services + run: | + if [ -n "${{ secrets.GOOGLE_SERVICES_JSON }}" ]; then + echo "${{ secrets.GOOGLE_SERVICES_JSON }}" > app/google-services.json + fi - 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 + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar > gradle.log 2>&1 || (cat gradle.log ; exit 1) 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" + MDBLIST_API_KEY: ${{ secrets.MDBLIST_API_KEY }} + + - 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b1aefab258..f284048cb30 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()) @@ -87,9 +88,13 @@ android { if (System.getenv("SIGNING_KEY_ALIAS") != null) { create("prerelease") { val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" - val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() + val githubActionsStoreFile = File(tmpFilePath).listFiles()?.firstOrNull() + val envStoreFile = System.getenv("SIGNING_STORE_FILE")?.let { File(it) } + val rootStoreFile = File(rootDir, "keystore.jks") + + val prereleaseStoreFile = githubActionsStoreFile ?: envStoreFile ?: rootStoreFile - storeFile = prereleaseStoreFile?.let { file(it) } + storeFile = prereleaseStoreFile storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -103,8 +108,8 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 68 - versionName = "4.7.0" + versionCode = 71 + versionName = "4.7.3" manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() @@ -126,6 +131,11 @@ android { "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) + buildConfigField( + "String", + "MDBLIST_API_KEY", + "\"aorzuy9py52y89nl78oaoskad\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -162,7 +172,6 @@ android { } else { logger.warn("No prerelease signing config!") } - versionNameSuffix = "-PRE" versionCode = (System.currentTimeMillis() / 60000).toInt() } } @@ -248,6 +257,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 @@ -269,9 +279,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/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/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..0944c404fc7 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 { @@ -1750,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 @@ -1788,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, @@ -1981,9 +2002,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa handleAppIntent(intent) - ioSafe { - runAutoUpdate() - } + FcastManager().init(this, false) @@ -2021,16 +2040,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/metadataproviders/MdbListResponse.kt b/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MdbListResponse.kt new file mode 100644 index 00000000000..d12765c6a9f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/metadataproviders/MdbListResponse.kt @@ -0,0 +1,23 @@ +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 +) { + val imdbScore: Double? get() = ratings?.find { it.source == "imdb" }?.value + val rottenTomatoesScore: Int? get() = ratings?.find { it.source == "tomatoes" }?.value?.toInt() +} + +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/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/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/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..091197819ff --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseSyncManager.kt @@ -0,0 +1,478 @@ +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) + } + } + } + + 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) + 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 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() + 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..9ed894b44c9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveSyncManager.kt @@ -0,0 +1,756 @@ +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() + 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 + } + } 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..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 @@ -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() } @@ -299,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/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt index ad323c7d124..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 @@ -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,33 @@ 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() + } + } + + @SuppressLint("GestureBackNavigation") + @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..15f1e1a5006 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 @@ -21,12 +23,19 @@ 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 import androidx.preference.PreferenceManager 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 @@ -48,6 +57,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 @@ -80,6 +90,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" @@ -592,6 +603,173 @@ 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 -> + if (result.contents != null) { + submitPairingCode(result.contents) + } + } + + 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 + } + + val email = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_email") + val password = com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey("firebase_password") + + viewLifecycleOwner.lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser + 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 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) { + 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) + + googleSignInClient.silentSignIn().addOnSuccessListener { account -> + completePairingWithToken(code, account.idToken!!) + }.addOnFailureListener { e -> + logError(e) + pendingPairingCode = code + val interactiveGso = 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 interactiveClient = com.google.android.gms.auth.api.signin.GoogleSignIn.getClient(ctx, interactiveGso) + googleSignInForPairingLauncher.launch(interactiveClient.signInIntent) + } + } else { + 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, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_LONG).show() + } + } + } + } + + /** Complete pairing using a Google ID token */ + private fun completePairingWithToken(code: String, googleIdToken: String) { + val ctx = context ?: return + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + val docRef = firestore.collection("pairing_codes").document(code) + + docRef.get().addOnSuccessListener { snapshot -> + if (!snapshot.exists()) { + Toast.makeText(ctx, "Invalid or expired pairing code.", Toast.LENGTH_SHORT).show() + return@addOnSuccessListener + } + + 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() + return@addOnSuccessListener + } + + val updateData = hashMapOf( + "status" to "authorized", + "googleIdToken" to googleIdToken + ) + + docRef.update(updateData).addOnSuccessListener { + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + }.addOnFailureListener { e -> + logError(e) + Toast.makeText(ctx, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() + } + }.addOnFailureListener { e -> + logError(e) + Toast.makeText(ctx, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() + } + } + + /** Complete pairing using email/password credentials */ + private fun completePairingWithCredentials(code: String, email: String, password: String) { + val ctx = context ?: return + val firestore = com.google.firebase.firestore.FirebaseFirestore.getInstance() + val docRef = firestore.collection("pairing_codes").document(code) + + docRef.get().addOnSuccessListener { snapshot -> + if (!snapshot.exists()) { + Toast.makeText(ctx, "Invalid or expired pairing code.", Toast.LENGTH_SHORT).show() + return@addOnSuccessListener + } + + 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() + return@addOnSuccessListener + } + + val updateData = hashMapOf( + "status" to "authorized", + "email" to email, + "password" to password + ) + + docRef.update(updateData).addOnSuccessListener { + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + }.addOnFailureListener { e -> + logError(e) + Toast.makeText(ctx, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() + } + }.addOnFailureListener { e -> + logError(e) + Toast.makeText(ctx, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() + } + } + private var bottomSheetDialog: BottomSheetDialog? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null @@ -647,17 +825,62 @@ 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( - homeViewModel, accountViewModel + homeViewModel, accountViewModel, qrScannerCallback = { + barcodeLauncher.launch(ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan TV QR Code") + setBeepEnabled(false) + setOrientationLocked(true) + setCaptureActivity(com.lagradost.cloudstream3.ui.sync.CustomScannerActivity::class.java) + }) + } ) homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.adapter = homeMasterAdapter 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() + homeUpdateProgressPercentage.isVisible = true + homeUpdateProgressPercentage.text = "${(percentage * 100f).toInt()}%" + } else { + homeUpdateProgressPercentage.isVisible = false + } + 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, @@ -874,6 +1097,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/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 959806e566c..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 @@ -61,10 +61,14 @@ 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, - private val accountViewModel: AccountViewModel + private val accountViewModel: AccountViewModel, + private val qrScannerCallback: (() -> Unit)? = null ) : ParentItemAdapter( id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { @@ -104,7 +108,7 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, accountViewModel) + return HeaderViewHolder(binding, viewModel, accountViewModel, qrScannerCallback) } override fun onBindHeader(holder: ViewHolderState) { @@ -131,6 +135,7 @@ class HomeParentItemAdapterPreview( val binding: ViewBinding, val viewModel: HomeViewModel, accountViewModel: AccountViewModel, + val qrScannerCallback: (() -> Unit)? = null ) : ViewHolderState(binding) { @@ -325,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? = @@ -543,12 +550,50 @@ 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() + } + } + + homeQrScannerBtn?.setOnClickListener { + qrScannerCallback?.invoke() } fun showAccountEditBox(context: Context): Boolean { @@ -578,7 +623,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/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..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 @@ -64,6 +65,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 @@ -304,12 +306,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 - subtitleOffset.times(1000), - newCue.durationUs + scaledStartTimeUs, + scaledDurationUs ) output.accept(updatedCues) 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/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/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..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 @@ -340,6 +340,7 @@ open class ResultFragmentPhone : BaseFragment( } updateUIEvent -= ::updateUI + playerHostView?.exitFullscreen() playerHostView?.release() playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) @@ -967,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, @@ -1072,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 7dfe3cf5988..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,7 +2162,10 @@ class ResultViewModel2 : ViewModel() { postPage(loadResponse, apiRepository) postSubscription(loadResponse) postFavorites(loadResponse) - _watchStatus.postValue(getResultWatchState(mainId)) + loadRatings(loadResponse) + + val currentState = getResultWatchState(mainId) + _watchStatus.postValue(currentState) if (updateEpisodes) postEpisodes(loadResponse, mainId, updateFillers) @@ -2444,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/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/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..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 @@ -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/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 new file mode 100644 index 00000000000..6f57ec7f373 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/FragmentPairTv.kt @@ -0,0 +1,240 @@ +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.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 com.lagradost.cloudstream3.utils.DataStore.setKey +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.launch + +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? + ): 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(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 + } + + val email = ctx.getKey("firebase_email") + val password = ctx.getKey("firebase_password") + + setLoading(true) + + lifecycleScope.launch { + try { + val user = FirebaseAuth.getInstance().currentUser + if (user == null) { + Toast.makeText(ctx, "Please log in first to pair TV.", Toast.LENGTH_LONG).show() + setLoading(false) + return@launch + } + + // 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) { + 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) + + googleSignInClient.silentSignIn().addOnSuccessListener { account -> + completePairing(code, account.idToken!!, account.email) + }.addOnFailureListener { e -> + logError(e) + pendingPairingCode = code + val interactiveGso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(ctx.getString(R.string.default_web_client_id)) + .requestEmail() + .build() + val interactiveClient = GoogleSignIn.getClient(ctx, interactiveGso) + googleSignInLauncher.launch(interactiveClient.signInIntent) + } + } else { + Toast.makeText(ctx, "Please log in using email/password first to pair TV.", Toast.LENGTH_LONG).show() + } + setLoading(false) + + } catch (e: Exception) { + logError(e) + Toast.makeText(ctx, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_LONG).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() + + 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", + "email" to email, + "password" to password + ) + + docRef.update(updateData).await() + + Toast.makeText(ctx, "TV paired successfully!", Toast.LENGTH_LONG).show() + activity?.onBackPressed() + + } catch (e: Exception) { + logError(e) + Toast.makeText(context, "Pairing error: ${e.localizedMessage}", Toast.LENGTH_LONG).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..1c32a8d7a71 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/sync/LoginFragment.kt @@ -0,0 +1,300 @@ +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") + val googleIdToken = snapshot.getString("googleIdToken") + + if (!googleIdToken.isNullOrBlank()) { + stopPairingListener() + loginWithGoogleIdToken(googleIdToken, code) + } else if (!email.isNullOrBlank() && !password.isNullOrBlank()) { + stopPairingListener() + loginWithCredentials(email, password, code) + } + } + } + } + } + + private fun stopPairingListener() { + pairingListener?.remove() + 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) + 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..c950d04130f --- /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..7ef65766fc7 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? { @@ -654,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 -> { @@ -672,33 +714,39 @@ 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 { // 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 8bcd1b88e70..1d8055aea42 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" @@ -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 @@ -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("Update available: ${update.updateVersion}") builder.apply { setPositiveButton(R.string.update) { _, _ -> // Forcefully start any delayed installations @@ -308,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) { @@ -336,17 +326,13 @@ object InAppUpdater { } } - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit { - putString( - getString(R.string.skip_update_key), update.updateNodeId ?: "" - ) - } + setNegativeButton(R.string.cancel) { _, _ -> + settingsManager.edit { + putString(getString(R.string.skip_update_key), update.updateNodeId) } } + + } builder.show().setDefaultFocus() } 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..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,38 +110,20 @@ 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( 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/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/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/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_home.xml b/app/src/main/res/layout/fragment_home.xml index 99a764deee8..40657b20c36 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -254,4 +254,49 @@ android:layout_width="0dp" android:layout_height="0dp" android:visibility="gone" /> + + + + + + + + + \ No newline at end of file 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 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_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" /> + + + + + + + + + + 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/layout/rail_footer.xml b/app/src/main/res/layout/rail_footer.xml index e1d604266e2..4bd0262856a 100644 --- a/app/src/main/res/layout/rail_footer.xml +++ b/app/src/main/res/layout/rail_footer.xml @@ -5,31 +5,12 @@ 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" android:visibility="gone" tools:visibility="visible"> - - - - - + \ No newline at end of file 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/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..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 @@ -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/github_log.txt b/github_log.txt new file mode 100644 index 00000000000..f1d09733bda Binary files /dev/null and b/github_log.txt differ 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"] diff --git a/jobs.json b/jobs.json new file mode 100644 index 00000000000..a75a2c61057 Binary files /dev/null and b/jobs.json differ diff --git a/keystore.jks b/keystore.jks new file mode 100644 index 00000000000..715d6f43f3b Binary files /dev/null and b/keystore.jks differ 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 00000000000..004895416f4 Binary files /dev/null and b/runs.json differ