mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-28 02:33:03 +02:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f693f1e6b3 | |||
| e118bc09b9 | |||
| 5ba77b60c8 | |||
| 19b63ba372 | |||
| 5fc39d3bb3 | |||
| 1d046538f8 | |||
| 9f10b86861 | |||
| d1336c711a | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 | |||
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 | |||
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 8536861e09 | |||
| 71262da3c2 | |||
| 60cd5976cc | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 5c162083d5 | |||
| 3230e7c0b4 | |||
| 8437825dd1 | |||
| 0fbe0bb438 | |||
| 34d2e62314 | |||
| 09bc180d4f |
+39
-19
@@ -1,37 +1,57 @@
|
|||||||
variables:
|
|
||||||
GIT_SUBMODULE_STRATEGY: recursive
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- buildAndDeployApkUnstable
|
|
||||||
- buildAndDeployApkStable
|
|
||||||
- buildAndDeployPlaystore
|
|
||||||
|
|
||||||
buildAndDeployApkUnstable:
|
buildAndDeployApkUnstable:
|
||||||
stage: buildAndDeployApkUnstable
|
stage: build
|
||||||
script:
|
script:
|
||||||
- sh deploy-unstable.sh
|
- sh deploy-unstable.sh
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
|
||||||
- ^(dev)
|
|
||||||
when: manual
|
when: manual
|
||||||
|
needs: []
|
||||||
|
allow_failure: true
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/apk/unstable/release/*.apk
|
||||||
|
|
||||||
buildAndDeployApkStable:
|
buildAndDeployApkStable:
|
||||||
stage: buildAndDeployApkStable
|
stage: build
|
||||||
script:
|
script:
|
||||||
- sh deploy-stable.sh
|
- sh deploy-stable.sh
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
|
||||||
- branches
|
|
||||||
when: manual
|
when: manual
|
||||||
|
needs: []
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/apk/stable/release/*.apk
|
||||||
|
|
||||||
buildAndDeployPlaystore:
|
buildAndDeployPlaystore:
|
||||||
stage: buildAndDeployPlaystore
|
stage: deploy
|
||||||
script:
|
script:
|
||||||
- sh deploy-playstore.sh
|
- sh build-playstore.sh
|
||||||
|
- bash tools/venv_playstore.sh
|
||||||
|
- . .venv-playstore/bin/activate
|
||||||
|
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
when: on_success
|
||||||
- branches
|
needs:
|
||||||
when: manual
|
- buildAndDeployApkStable
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/bundle/playstoreRelease/*.aab
|
||||||
|
|
||||||
|
updateFdroidRepo:
|
||||||
|
stage: deploy
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
when: on_success
|
||||||
|
needs:
|
||||||
|
- job: buildAndDeployApkStable
|
||||||
|
artifacts: true
|
||||||
|
script:
|
||||||
|
- python3 update_fdroid_index.py
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
|
||||||
|
size 6342128
|
||||||
+8
-13
@@ -184,13 +184,13 @@ dependencies {
|
|||||||
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||||
|
|
||||||
//Exoplayer
|
//Exoplayer
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
|
||||||
implementation 'androidx.media3:media3-ui:1.8.0'
|
implementation 'androidx.media3:media3-ui:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
|
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
|
||||||
implementation 'androidx.media3:media3-transformer:1.8.0'
|
implementation 'androidx.media3:media3-transformer:1.9.0'
|
||||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
||||||
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
||||||
implementation 'androidx.media:media:1.7.1'
|
implementation 'androidx.media:media:1.7.1'
|
||||||
@@ -206,6 +206,7 @@ dependencies {
|
|||||||
implementation 'com.google.zxing:core:3.5.3'
|
implementation 'com.google.zxing:core:3.5.3'
|
||||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation 'androidx.webkit:webkit:1.15.0'
|
||||||
|
|
||||||
//Protobuf
|
//Protobuf
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
|
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
|
||||||
@@ -230,10 +231,4 @@ dependencies {
|
|||||||
testImplementation "org.mockito:mockito-core:5.20.0"
|
testImplementation "org.mockito:mockito-core:5.20.0"
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||||
|
|
||||||
//Rust casting SDK
|
|
||||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
|
|
||||||
// Polycentricandroid includes this
|
|
||||||
exclude group: 'net.java.dev.jna'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
android:windowSoftInputMode="adjustPan"
|
android:windowSoftInputMode="adjustPan"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||||
@@ -387,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
fun getPrimaryLanguage(context: Context): String? {
|
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||||
return when(primaryLanguage) {
|
return when(primaryLanguage) {
|
||||||
0 -> "en";
|
0 -> "en";
|
||||||
1 -> "es";
|
1 -> "es";
|
||||||
@@ -400,10 +401,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
8 -> "id";
|
8 -> "id";
|
||||||
9 -> "hi";
|
9 -> "hi";
|
||||||
10 -> "ar";
|
10 -> "ar";
|
||||||
11 -> "tu";
|
11 -> "tr";
|
||||||
12 -> "ru";
|
12 -> "ru";
|
||||||
13 -> "pt";
|
13 -> "pt";
|
||||||
14 -> "zh";
|
14 -> "zh";
|
||||||
|
15 -> "it";
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,11 +727,6 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var allowLinkLocalIpv4: Boolean = false;
|
var allowLinkLocalIpv4: Boolean = false;
|
||||||
|
|
||||||
@AdvancedField
|
|
||||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
|
||||||
var experimentalCasting: Boolean = true
|
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
@@ -792,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||||
@Transient
|
|
||||||
var plugins = Plugins();
|
var plugins = Plugins();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Plugins {
|
class Plugins {
|
||||||
@@ -801,6 +797,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
||||||
var checkDisabledPluginsForUpdates: Boolean = false;
|
var checkDisabledPluginsForUpdates: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
|
||||||
|
var clearCookiesAfterLogin: Boolean = true;
|
||||||
@AdvancedField
|
@AdvancedField
|
||||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||||
var clearCookiesOnLogout: Boolean = true;
|
var clearCookiesOnLogout: Boolean = true;
|
||||||
@@ -810,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||||
cookieManager.removeAllCookies(null);
|
cookieManager.removeAllCookies(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shouldClearWebviewCookies(): Boolean {
|
||||||
|
return clearCookiesAfterLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||||
fun reinstallEmbedded() {
|
fun reinstallEmbedded() {
|
||||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||||
@@ -957,18 +962,31 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
class Backup {
|
class Backup {
|
||||||
@Serializable(with = OffsetDateTimeSerializer::class)
|
@Serializable(with = OffsetDateTimeSerializer::class)
|
||||||
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
||||||
var didAskAutoBackup: Boolean = true;
|
var didAskAutoBackup: Boolean = false;
|
||||||
|
var autoBackupEnabled: Boolean = false
|
||||||
var autoBackupPassword: String? = null;
|
var autoBackupPassword: String? = null;
|
||||||
fun shouldAutomaticBackup() = autoBackupPassword != null;
|
fun shouldAutomaticBackup() = autoBackupEnabled
|
||||||
|
|
||||||
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
||||||
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
||||||
|
|
||||||
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
||||||
fun configureAutomaticBackup() {
|
fun configureAutomaticBackup() {
|
||||||
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
|
StateApp.instance.activity?.let { activity ->
|
||||||
SettingsFragment.currentView?.reloadSettings();
|
if(!Settings.instance.storage.isStorageMainValid(activity)) {
|
||||||
};
|
UIDialogs.toast("Missing general directory")
|
||||||
|
StateApp.instance.changeExternalGeneralDirectory(activity) {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
||||||
fun restoreAutomaticBackup() {
|
fun restoreAutomaticBackup() {
|
||||||
@@ -1052,7 +1070,6 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||||
var polycentricLocalCache: Boolean = true;
|
var polycentricLocalCache: Boolean = true;
|
||||||
|
|
||||||
var showPrivacyModeDialog: Boolean = true;
|
var showPrivacyModeDialog: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@@ -165,27 +166,42 @@ class UIDialogs {
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
|
||||||
val dialogAction: ()->Unit = {
|
val dialogAction: () -> Unit = {
|
||||||
val dialog = AutomaticBackupDialog(context);
|
val dialog = AutomaticBackupDialog(context)
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog)
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
|
dialog.setOnDismissListener {
|
||||||
dialog.show();
|
registerDialogClosed(dialog)
|
||||||
};
|
onClosed?.invoke()
|
||||||
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
|
}
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
|
dialog.show()
|
||||||
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
|
}
|
||||||
UIDialogs.Action(context.getString(R.string.override), {
|
|
||||||
dialogAction();
|
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context,
|
||||||
|
R.drawable.ic_move_up,
|
||||||
|
context.getString(R.string.an_old_backup_is_available),
|
||||||
|
context.getString(R.string.would_you_like_to_restore_this_backup),
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
UIDialogs.Action(context.getString(R.string.cancel), {}),
|
||||||
|
UIDialogs.Action(context.getString(R.string.continue_anyway), {
|
||||||
|
dialogAction()
|
||||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||||
UIDialogs.Action(context.getString(R.string.restore), {
|
UIDialogs.Action(context.getString(R.string.restore), {
|
||||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
|
||||||
|
?: StateApp.instance.scopeOrNull
|
||||||
|
?: StateApp.instance.scope
|
||||||
|
|
||||||
|
UIDialogs.showAutomaticRestoreDialog(context, scope)
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
)
|
||||||
else {
|
} else {
|
||||||
dialogAction();
|
dialogAction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
||||||
val dialog = AutomaticRestoreDialog(context, scope);
|
val dialog = AutomaticRestoreDialog(context, scope);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||||
|
import kotlin.collections.toList
|
||||||
|
|
||||||
class UISlideOverlays {
|
class UISlideOverlays {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -573,6 +576,51 @@ class UISlideOverlays {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||||
|
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||||
|
lang -> videoSources
|
||||||
|
.filter { v -> v.language == lang }
|
||||||
|
.map { it.height * it.width }
|
||||||
|
.distinct()
|
||||||
|
.map { res -> Pair(res, lang) }
|
||||||
|
} else listOf();
|
||||||
|
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||||
|
var selectedLanguage: String? = null;
|
||||||
|
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||||
|
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
|
||||||
|
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||||
|
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||||
|
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||||
|
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||||
|
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||||
|
|
||||||
|
if(english != null)
|
||||||
|
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||||
|
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||||
|
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||||
|
if(originalLanguage != null)
|
||||||
|
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||||
|
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||||
|
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||||
|
setButtons(languageFilterLabels, selectedLanguage);
|
||||||
|
onClick.subscribe { selected ->
|
||||||
|
setSelected(selected);
|
||||||
|
|
||||||
|
videoSourceItems.forEach {
|
||||||
|
val item = it.itemTag;
|
||||||
|
if(item is IVideoSource) {
|
||||||
|
if(item.language == selected)
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
else
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else null;
|
||||||
|
|
||||||
|
if(languageFilters != null) items.add(languageFilters)
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
@@ -609,7 +657,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is JSDashManifestRawSource -> {
|
is JSDashManifestRawSource -> {
|
||||||
@@ -629,7 +683,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IHLSManifestSource -> {
|
is IHLSManifestSource -> {
|
||||||
@@ -643,7 +703,13 @@ class UISlideOverlays {
|
|||||||
showHlsPicker(video, it, it.url, container)
|
showHlsPicker(video, it, it.url, container)
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import android.os.IBinder
|
|||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -14,6 +18,7 @@ import java.io.File
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class UpdateDownloadService : Service() {
|
class UpdateDownloadService : Service() {
|
||||||
|
|
||||||
@@ -85,13 +90,16 @@ class UpdateDownloadService : Service() {
|
|||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
val force = progress == 100 && !indeterminate
|
val force = progress == 100 && !indeterminate
|
||||||
|
|
||||||
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||||
lastProgressUpdateElapsedMs = now
|
lastProgressUpdateElapsedMs = now
|
||||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
|
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||||
|
|
||||||
|
if(onProgress != null)
|
||||||
|
onProgress.invoke(progress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +107,7 @@ class UpdateDownloadService : Service() {
|
|||||||
val apkFile = StateUpdate.getApkFile(this, version)
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
|
|
||||||
|
var announcement: SessionAnnouncement? = null;
|
||||||
try {
|
try {
|
||||||
if (apkFile.exists() && apkFile.length() > 0L) {
|
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||||
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||||
@@ -106,6 +115,14 @@ class UpdateDownloadService : Service() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||||
|
ImageVariable.fromResource(R.drawable.foreground));
|
||||||
|
}
|
||||||
|
catch(ex: Exception){
|
||||||
|
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||||
|
}
|
||||||
|
|
||||||
var backoffMs = INITIAL_BACKOFF_MS
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
for (attempt in 0 until MAX_RETRIES) {
|
for (attempt in 0 until MAX_RETRIES) {
|
||||||
@@ -115,7 +132,13 @@ class UpdateDownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
performDownload(StateUpdate.APK_URL, partialFile, version)
|
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
||||||
|
try {
|
||||||
|
if (announcement != null)
|
||||||
|
announcement?.setProgress(it);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {}
|
||||||
|
})
|
||||||
|
|
||||||
if (!cancelRequested) {
|
if (!cancelRequested) {
|
||||||
if (apkFile.exists()) {
|
if (apkFile.exists()) {
|
||||||
@@ -145,6 +168,12 @@ class UpdateDownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
try {
|
||||||
|
if (announcement != null) {
|
||||||
|
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable){}
|
||||||
isDownloading = false
|
isDownloading = false
|
||||||
cancelRequested = false
|
cancelRequested = false
|
||||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
@@ -152,7 +181,7 @@ class UpdateDownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performDownload(url: String, partialFile: File, version: Int) {
|
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||||
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||||
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||||
|
|
||||||
@@ -204,7 +233,7 @@ class UpdateDownloadService : Service() {
|
|||||||
progress > 100 -> 100
|
progress > 100 -> 100
|
||||||
else -> progress
|
else -> progress
|
||||||
}
|
}
|
||||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||||
@@ -250,6 +279,18 @@ class UpdateDownloadService : Service() {
|
|||||||
UpdateNotificationManager.cancelAll(ctx)
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
}, ActionStyle.PRIMARY, true));
|
}, ActionStyle.PRIMARY, true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
|
||||||
|
AnnouncementType.SESSION,
|
||||||
|
OffsetDateTime.now(), "update", "Install", {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
|
||||||
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||||
updateDownloadedDialog = null
|
updateDownloadedDialog = null
|
||||||
|
|||||||
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
|
|||||||
intent.getStringExtra("body");
|
intent.getStringExtra("body");
|
||||||
else null;
|
else null;
|
||||||
|
|
||||||
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (captchaConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = captchaConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
|
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
|
||||||
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
_callback = null;
|
_callback = null;
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
else throw IllegalStateException("No valid configuration?");
|
else throw IllegalStateException("No valid configuration?");
|
||||||
//TODO: Backwards compat removal?
|
//TODO: Backwards compat removal?
|
||||||
|
|
||||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (authConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = authConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
|
||||||
|
|
||||||
webViewClient.onLogin.subscribe { auth ->
|
webViewClient.onLogin.subscribe { auth ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
|
|||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.views.ToastView
|
import com.futo.platformplayer.views.ToastView
|
||||||
|
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||||
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||||
|
lateinit var _fragNotifications: NotificationOverlayView.Frag;
|
||||||
lateinit var _fragSettings: SettingsFragment;
|
lateinit var _fragSettings: SettingsFragment;
|
||||||
lateinit var _fragDeveloper: DeveloperFragment;
|
lateinit var _fragDeveloper: DeveloperFragment;
|
||||||
lateinit var _fragLogin: LoginFragment;
|
lateinit var _fragLogin: LoginFragment;
|
||||||
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||||
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||||
|
_fragNotifications = NotificationOverlayView.Frag();
|
||||||
_fragSettings = SettingsFragment.newInstance();
|
_fragSettings = SettingsFragment.newInstance();
|
||||||
_fragDeveloper = DeveloperFragment.newInstance();
|
_fragDeveloper = DeveloperFragment.newInstance();
|
||||||
_fragLogin = LoginFragment.newInstance();
|
_fragLogin = LoginFragment.newInstance();
|
||||||
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||||
_fragSettings.topBar = _fragTopBarNavigation;
|
_fragSettings.topBar = _fragTopBarNavigation;
|
||||||
_fragDeveloper.topBar = _fragTopBarNavigation;
|
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||||
|
_fragNotifications.topBar = _fragTopBarGeneral;
|
||||||
|
|
||||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||||
|
|
||||||
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||||
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||||
|
NotificationOverlayView.Frag::class -> _fragNotifications as T;
|
||||||
SettingsFragment:: class -> _fragSettings as T;
|
SettingsFragment:: class -> _fragSettings as T;
|
||||||
DeveloperFragment::class -> _fragDeveloper as T;
|
DeveloperFragment::class -> _fragDeveloper as T;
|
||||||
LoginFragment::class -> _fragLogin as T;
|
LoginFragment::class -> _fragLogin as T;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
|||||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
|
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
|
||||||
|
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
@@ -156,6 +157,7 @@ open class JSClient : IPlatformClient {
|
|||||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||||
|
_plugin.bridge.descriptor = descriptor;
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
|
|
||||||
@@ -189,6 +191,7 @@ open class JSClient : IPlatformClient {
|
|||||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||||
|
_plugin.bridge.descriptor = descriptor;
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
_plugin.withScript(script);
|
_plugin.withScript(script);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toEncrypted(): String{
|
fun toEncrypted(): String{
|
||||||
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun serialize(): String {
|
private fun serialize(): String {
|
||||||
return Json.encodeToString(SerializedAuth(cookieMap, headers));
|
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = "SourceAuth";
|
val TAG = "SourceAuth";
|
||||||
|
private val _json = Json { ignoreUnknownKeys = true };
|
||||||
|
|
||||||
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
||||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deserialize(str: String): SourceAuth {
|
private fun deserialize(str: String): SourceAuth {
|
||||||
val data = Json.decodeFromString<SerializedAuth>(str);
|
val data = _json.decodeFromString<SerializedAuth>(str);
|
||||||
return SourceAuth(data.cookieMap, data.headers);
|
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
||||||
val headers: Map<String, Map<String, String>> = mapOf())
|
val headers: Map<String, Map<String, String>> = mapOf(),
|
||||||
|
val userAgent: String? = null)
|
||||||
}
|
}
|
||||||
+8
-6
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toEncrypted(): String{
|
fun toEncrypted(): String{
|
||||||
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun serialize(): String {
|
private fun serialize(): String {
|
||||||
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
|
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = "SourceCaptchaData";
|
val TAG = "SourceCaptchaData";
|
||||||
|
private val _json = Json { ignoreUnknownKeys = true };
|
||||||
|
|
||||||
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
||||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deserialize(str: String): SourceCaptchaData {
|
fun deserialize(str: String): SourceCaptchaData {
|
||||||
val data = Json.decodeFromString<SerializedCaptchaData>(str);
|
val data = _json.decodeFromString<SerializedCaptchaData>(str);
|
||||||
return SourceCaptchaData(data.cookieMap, data.headers);
|
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
||||||
val headers: Map<String, Map<String, String>> = mapOf())
|
val headers: Map<String, Map<String, String>> = mapOf(),
|
||||||
|
val userAgent: String? = null)
|
||||||
}
|
}
|
||||||
+13
-1
@@ -61,6 +61,11 @@ class SourcePluginConfig(
|
|||||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||||
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
||||||
|
|
||||||
|
fun isOfficialAuthor(): Boolean {
|
||||||
|
return scriptSignature != null &&
|
||||||
|
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
||||||
if(url == null)
|
if(url == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -165,6 +170,12 @@ class SourcePluginConfig(
|
|||||||
"Unrestricted Http Header access",
|
"Unrestricted Http Header access",
|
||||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||||
))
|
))
|
||||||
|
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
|
||||||
|
list.add(Pair(
|
||||||
|
"Browser Interop",
|
||||||
|
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -224,7 +235,8 @@ class SourcePluginConfig(
|
|||||||
val variable: String? = null,
|
val variable: String? = null,
|
||||||
val dependency: String? = null,
|
val dependency: String? = null,
|
||||||
val warningDialog: String? = null,
|
val warningDialog: String? = null,
|
||||||
val options: List<String>? = null
|
val options: List<String>? = null,
|
||||||
|
val isAdvanced: Boolean? = null
|
||||||
) {
|
) {
|
||||||
val variableOrName: String get() = variable ?: name;
|
val variableOrName: String get() = variable ?: name;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
||||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
||||||
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||||
hasGenerate = _obj.has("generate");
|
hasGenerate = _obj.has("generate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||||||
language = _obj.getOrThrow(config, "language", contextName);
|
language = _obj.getOrThrow(config, "language", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: https://nto.github.io/AirPlay
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = false;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _sessionId: String? = null;
|
|
||||||
private val _client = ManagedHttpClient();
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
if (resumePosition > 0.0) {
|
|
||||||
val pos = resumePosition / duration;
|
|
||||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
|
||||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
|
||||||
} else {
|
|
||||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (speed != null) {
|
|
||||||
changeSpeed(speed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
throw NotImplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("scrub?position=${timeSeconds}");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = true;
|
|
||||||
post("rate?value=1.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = false;
|
|
||||||
post("rate?value=0.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("stop");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("stop");
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
_scopeIO?.launch {
|
|
||||||
try {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
if (connectedSocket == null) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress;
|
|
||||||
localAddress = connectedSocket.localAddress;
|
|
||||||
connectedSocket.close();
|
|
||||||
_sessionId = UUID.randomUUID().toString();
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
|
||||||
delay(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val progressInfo = getProgress();
|
|
||||||
if (progressInfo == null) {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
|
||||||
|
|
||||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
|
||||||
if (progressIndex == -1) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
|
||||||
setTime(progress);
|
|
||||||
|
|
||||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
|
||||||
if (durationIndex == -1) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
|
||||||
setDuration(duration);
|
|
||||||
delay(1000);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
_scopeIO = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
setSpeed(speed)
|
|
||||||
post("rate?value=$speed")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getProgress(): String? {
|
|
||||||
val info = get("scrub");
|
|
||||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPlaybackInfo(): String? {
|
|
||||||
val playbackInfo = get("playback-info");
|
|
||||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
|
||||||
return playbackInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getServerInfo(): String? {
|
|
||||||
val serverInfo = get("server-info");
|
|
||||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
|
||||||
return serverInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(path: String): Boolean {
|
|
||||||
try {
|
|
||||||
val sessionId = _sessionId ?: return false;
|
|
||||||
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"Content-Length" to "0",
|
|
||||||
"X-Apple-Session-ID" to sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "POST $url");
|
|
||||||
val response = _client.post(url, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to POST $path");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
|
||||||
try {
|
|
||||||
val sessionId = _sessionId ?: return false;
|
|
||||||
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"X-Apple-Session-ID" to sessionId,
|
|
||||||
"Content-Type" to contentType
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "POST $url:\n$body");
|
|
||||||
val response = _client.post(url, body, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to POST $path $body");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(path: String): String? {
|
|
||||||
val sessionId = _sessionId ?: return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"Content-Length" to "0",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"X-Apple-Session-ID" to sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "GET $url");
|
|
||||||
val response = _client.get(url, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.body == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.body.string();
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to GET $path");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch { action(); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "AirPlayCastingDevice";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +1,289 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import org.fcast.sender_sdk.ApplicationInfo
|
||||||
|
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
|
||||||
|
import org.fcast.sender_sdk.KeyEvent
|
||||||
|
import org.fcast.sender_sdk.MediaEvent
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
import org.fcast.sender_sdk.PlaybackState
|
||||||
|
import org.fcast.sender_sdk.Source
|
||||||
|
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||||
|
import org.fcast.sender_sdk.DeviceConnectionState
|
||||||
|
import org.fcast.sender_sdk.DeviceFeature
|
||||||
|
import org.fcast.sender_sdk.EventSubscription
|
||||||
|
import org.fcast.sender_sdk.IpAddr
|
||||||
|
import org.fcast.sender_sdk.LoadRequest
|
||||||
|
import org.fcast.sender_sdk.MediaItemEventType
|
||||||
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
|
|
||||||
abstract class CastingDevice {
|
enum class CastConnectionState {
|
||||||
abstract val isReady: Boolean
|
DISCONNECTED,
|
||||||
abstract val usedRemoteAddress: InetAddress?
|
CONNECTING,
|
||||||
abstract val localAddress: InetAddress?
|
CONNECTED
|
||||||
abstract val name: String?
|
}
|
||||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
|
||||||
abstract val onPlayChanged: Event1<Boolean>
|
|
||||||
abstract val onTimeChanged: Event1<Double>
|
|
||||||
abstract val onDurationChanged: Event1<Double>
|
|
||||||
abstract val onVolumeChanged: Event1<Double>
|
|
||||||
abstract val onSpeedChanged: Event1<Double>
|
|
||||||
abstract val onMediaItemEnd: Event0
|
|
||||||
abstract var connectionState: CastConnectionState
|
|
||||||
abstract val protocolType: CastProtocolType
|
|
||||||
abstract var isPlaying: Boolean
|
|
||||||
abstract val expectedCurrentTime: Double
|
|
||||||
abstract var speed: Double
|
|
||||||
abstract var time: Double
|
|
||||||
abstract var duration: Double
|
|
||||||
abstract var volume: Double
|
|
||||||
abstract fun canSetVolume(): Boolean
|
|
||||||
abstract fun canSetSpeed(): Boolean
|
|
||||||
|
|
||||||
@Throws
|
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||||
abstract fun resumePlayback()
|
enum class CastProtocolType {
|
||||||
|
CHROMECAST,
|
||||||
|
AIRPLAY,
|
||||||
|
FCAST;
|
||||||
|
|
||||||
@Throws
|
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||||
abstract fun pausePlayback()
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||||
|
|
||||||
@Throws
|
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||||
abstract fun stopPlayback()
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||||
abstract fun seekTo(timeSeconds: Double)
|
val name = decoder.decodeString()
|
||||||
|
return when (name) {
|
||||||
|
"FASTCAST" -> FCAST // Handle the renamed case
|
||||||
|
else -> CastProtocolType.valueOf(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||||
abstract fun changeVolume(timeSeconds: Double)
|
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||||
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@Throws
|
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||||
abstract fun changeSpeed(speed: Double)
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte(),
|
||||||
|
addr.o5.toByte(),
|
||||||
|
addr.o6.toByte(),
|
||||||
|
addr.o7.toByte(),
|
||||||
|
addr.o8.toByte(),
|
||||||
|
addr.o9.toByte(),
|
||||||
|
addr.o10.toByte(),
|
||||||
|
addr.o11.toByte(),
|
||||||
|
addr.o12.toByte(),
|
||||||
|
addr.o13.toByte(),
|
||||||
|
addr.o14.toByte(),
|
||||||
|
addr.o15.toByte(),
|
||||||
|
addr.o16.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
// abstract class CastingDevice {
|
||||||
abstract fun connect()
|
class CastingDevice(val device: RsCastingDevice) {
|
||||||
|
// abstract val isReady: Boolean
|
||||||
|
// abstract val usedRemoteAddress: InetAddress?
|
||||||
|
// abstract val localAddress: InetAddress?
|
||||||
|
// abstract val name: String?
|
||||||
|
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||||
|
// abstract val onPlayChanged: Event1<Boolean>
|
||||||
|
// abstract val onTimeChanged: Event1<Double>
|
||||||
|
// abstract val onDurationChanged: Event1<Double>
|
||||||
|
// abstract val onVolumeChanged: Event1<Double>
|
||||||
|
// abstract val onSpeedChanged: Event1<Double>
|
||||||
|
// abstract val onMediaItemEnd: Event0
|
||||||
|
// abstract var connectionState: CastConnectionState
|
||||||
|
// abstract val protocolType: CastProtocolType
|
||||||
|
// abstract var isPlaying: Boolean
|
||||||
|
// abstract val expectedCurrentTime: Double
|
||||||
|
// abstract var speed: Double
|
||||||
|
// abstract var time: Double
|
||||||
|
// abstract var duration: Double
|
||||||
|
// abstract var volume: Double
|
||||||
|
// abstract fun canSetVolume(): Boolean
|
||||||
|
// abstract fun canSetSpeed(): Boolean
|
||||||
|
|
||||||
@Throws
|
// @Throws
|
||||||
abstract fun disconnect()
|
// abstract fun resumePlayback()
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
|
||||||
abstract fun getAddresses(): List<InetAddress>
|
|
||||||
|
|
||||||
@Throws
|
// @Throws
|
||||||
abstract fun loadVideo(
|
// abstract fun pausePlayback()
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun stopPlayback()
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun seekTo(timeSeconds: Double)
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun changeVolume(timeSeconds: Double)
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun changeSpeed(speed: Double)
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun connect()
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun disconnect()
|
||||||
|
// abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||||
|
// abstract fun getAddresses(): List<InetAddress>
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// abstract fun loadVideo(
|
||||||
|
// streamType: String,
|
||||||
|
// contentType: String,
|
||||||
|
// contentId: String,
|
||||||
|
// resumePosition: Double,
|
||||||
|
// duration: Double,
|
||||||
|
// speed: Double?,
|
||||||
|
// metadata: Metadata?
|
||||||
|
// )
|
||||||
|
|
||||||
|
// @Throws
|
||||||
|
// fun loadContent(
|
||||||
|
// contentType: String,
|
||||||
|
// content: String,
|
||||||
|
// resumePosition: Double,
|
||||||
|
// duration: Double,
|
||||||
|
// speed: Double?,
|
||||||
|
// metadata: Metadata?
|
||||||
|
// )
|
||||||
|
|
||||||
|
// fun ensureThreadStarted()
|
||||||
|
|
||||||
|
class EventHandler : RsDeviceEventHandler {
|
||||||
|
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||||
|
var onPlayChanged = Event1<Boolean>()
|
||||||
|
var onTimeChanged = Event1<Double>()
|
||||||
|
var onDurationChanged = Event1<Double>()
|
||||||
|
var onVolumeChanged = Event1<Double>()
|
||||||
|
var onSpeedChanged = Event1<Double>()
|
||||||
|
var onMediaItemEnd = Event0()
|
||||||
|
|
||||||
|
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||||
|
onConnectionStateChanged.emit(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun volumeChanged(volume: Double) {
|
||||||
|
onVolumeChanged.emit(volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun timeChanged(time: Double) {
|
||||||
|
onTimeChanged.emit(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackStateChanged(state: PlaybackState) {
|
||||||
|
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun durationChanged(duration: Double) {
|
||||||
|
onDurationChanged.emit(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun speedChanged(speed: Double) {
|
||||||
|
onSpeedChanged.emit(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sourceChanged(source: Source) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun keyEvent(event: KeyEvent) {
|
||||||
|
// Unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mediaEvent(event: MediaEvent) {
|
||||||
|
if (event.type == MediaItemEventType.END) {
|
||||||
|
onMediaItemEnd.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackError(message: String) {
|
||||||
|
Logger.e(TAG, "Playback error: $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventHandler = EventHandler()
|
||||||
|
val isReady: Boolean
|
||||||
|
get() = device.isReady()
|
||||||
|
val name: String
|
||||||
|
get() = device.name()
|
||||||
|
var usedRemoteAddress: InetAddress? = null
|
||||||
|
var localAddress: InetAddress? = null
|
||||||
|
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||||
|
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||||
|
|
||||||
|
val onConnectionStateChanged =
|
||||||
|
Event1<CastConnectionState>()
|
||||||
|
val onPlayChanged: Event1<Boolean>
|
||||||
|
get() = eventHandler.onPlayChanged
|
||||||
|
val onTimeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onTimeChanged
|
||||||
|
val onDurationChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onDurationChanged
|
||||||
|
val onVolumeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onVolumeChanged
|
||||||
|
val onSpeedChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onSpeedChanged
|
||||||
|
val onMediaItemEnd: Event0
|
||||||
|
get() = eventHandler.onMediaItemEnd
|
||||||
|
|
||||||
|
fun resumePlayback() = device.resumePlayback()
|
||||||
|
fun pausePlayback() = device.pausePlayback()
|
||||||
|
fun stopPlayback() = device.stopPlayback()
|
||||||
|
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||||
|
fun changeVolume(newVolume: Double) {
|
||||||
|
device.changeVolume(newVolume)
|
||||||
|
volume = newVolume
|
||||||
|
}
|
||||||
|
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||||
|
fun connect() = device.connect(
|
||||||
|
ApplicationInfo(
|
||||||
|
"Grayjay Android",
|
||||||
|
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||||
|
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||||
|
),
|
||||||
|
eventHandler,
|
||||||
|
1000.toULong()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun disconnect() = device.disconnect()
|
||||||
|
|
||||||
|
fun getDeviceInfo(): CastingDeviceInfo {
|
||||||
|
val info = device.getDeviceInfo()
|
||||||
|
return CastingDeviceInfo(
|
||||||
|
info.name,
|
||||||
|
when (info.protocol) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
},
|
||||||
|
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||||
|
port = info.port.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||||
|
ipAddrToInetAddress(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadVideo(
|
||||||
streamType: String,
|
streamType: String,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
contentId: String,
|
contentId: String,
|
||||||
@@ -64,18 +291,107 @@ abstract class CastingDevice {
|
|||||||
duration: Double,
|
duration: Double,
|
||||||
speed: Double?,
|
speed: Double?,
|
||||||
metadata: Metadata?
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Video(
|
||||||
|
contentType = contentType,
|
||||||
|
url = contentId,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Throws
|
fun loadContent(
|
||||||
abstract fun loadContent(
|
|
||||||
contentType: String,
|
contentType: String,
|
||||||
content: String,
|
content: String,
|
||||||
resumePosition: Double,
|
resumePosition: Double,
|
||||||
duration: Double,
|
duration: Double,
|
||||||
speed: Double?,
|
speed: Double?,
|
||||||
metadata: Metadata?
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Content(
|
||||||
|
contentType = contentType,
|
||||||
|
content = content,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
abstract fun ensureThreadStarted()
|
var connectionState = CastConnectionState.DISCONNECTED
|
||||||
}
|
val protocolType: CastProtocolType
|
||||||
|
get() = when (device.castingProtocol()) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
}
|
||||||
|
var volume: Double = 1.0
|
||||||
|
var duration: Double = 0.0
|
||||||
|
private var lastTimeChangeTime_ms: Long = 0
|
||||||
|
var time: Double = 0.0
|
||||||
|
var speed: Double = 0.0
|
||||||
|
var isPlaying: Boolean = false
|
||||||
|
|
||||||
|
val expectedCurrentTime: Double
|
||||||
|
get() {
|
||||||
|
val diff =
|
||||||
|
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||||
|
return time + diff
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||||
|
when (newState) {
|
||||||
|
is DeviceConnectionState.Connected -> {
|
||||||
|
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||||
|
try {
|
||||||
|
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||||
|
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||||
|
connectionState = CastConnectionState.CONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||||
|
connectionState = CastConnectionState.CONNECTING
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Disconnected -> {
|
||||||
|
connectionState = CastConnectionState.DISCONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState == DeviceConnectionState.Disconnected) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Stopping device")
|
||||||
|
device.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to stop device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||||
|
eventHandler.onTimeChanged.subscribe {
|
||||||
|
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||||
|
time = it
|
||||||
|
}
|
||||||
|
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||||
|
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||||
|
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureThreadStarted() {}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "CastingDeviceExp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
|
||||||
import com.futo.platformplayer.constructs.Event0
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.polycentric.core.Event
|
|
||||||
import org.fcast.sender_sdk.ApplicationInfo
|
|
||||||
import org.fcast.sender_sdk.KeyEvent
|
|
||||||
import org.fcast.sender_sdk.MediaEvent
|
|
||||||
import org.fcast.sender_sdk.PlaybackState
|
|
||||||
import org.fcast.sender_sdk.Source
|
|
||||||
import java.net.InetAddress
|
|
||||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
|
||||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
|
||||||
import org.fcast.sender_sdk.DeviceConnectionState
|
|
||||||
import org.fcast.sender_sdk.DeviceFeature
|
|
||||||
import org.fcast.sender_sdk.EventSubscription
|
|
||||||
import org.fcast.sender_sdk.IpAddr
|
|
||||||
import org.fcast.sender_sdk.LoadRequest
|
|
||||||
import org.fcast.sender_sdk.MediaItemEventType
|
|
||||||
import org.fcast.sender_sdk.Metadata
|
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
|
||||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.Inet6Address
|
|
||||||
|
|
||||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
|
||||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
|
||||||
byteArrayOf(
|
|
||||||
addr.o1.toByte(),
|
|
||||||
addr.o2.toByte(),
|
|
||||||
addr.o3.toByte(),
|
|
||||||
addr.o4.toByte()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
|
||||||
byteArrayOf(
|
|
||||||
addr.o1.toByte(),
|
|
||||||
addr.o2.toByte(),
|
|
||||||
addr.o3.toByte(),
|
|
||||||
addr.o4.toByte(),
|
|
||||||
addr.o5.toByte(),
|
|
||||||
addr.o6.toByte(),
|
|
||||||
addr.o7.toByte(),
|
|
||||||
addr.o8.toByte(),
|
|
||||||
addr.o9.toByte(),
|
|
||||||
addr.o10.toByte(),
|
|
||||||
addr.o11.toByte(),
|
|
||||||
addr.o12.toByte(),
|
|
||||||
addr.o13.toByte(),
|
|
||||||
addr.o14.toByte(),
|
|
||||||
addr.o15.toByte(),
|
|
||||||
addr.o16.toByte()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|
||||||
class EventHandler : RsDeviceEventHandler {
|
|
||||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>()
|
|
||||||
var onTimeChanged = Event1<Double>()
|
|
||||||
var onDurationChanged = Event1<Double>()
|
|
||||||
var onVolumeChanged = Event1<Double>()
|
|
||||||
var onSpeedChanged = Event1<Double>()
|
|
||||||
var onMediaItemEnd = Event0()
|
|
||||||
|
|
||||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
|
||||||
onConnectionStateChanged.emit(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun volumeChanged(volume: Double) {
|
|
||||||
onVolumeChanged.emit(volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun timeChanged(time: Double) {
|
|
||||||
onTimeChanged.emit(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playbackStateChanged(state: PlaybackState) {
|
|
||||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun durationChanged(duration: Double) {
|
|
||||||
onDurationChanged.emit(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun speedChanged(speed: Double) {
|
|
||||||
onSpeedChanged.emit(speed)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun sourceChanged(source: Source) {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun keyEvent(event: KeyEvent) {
|
|
||||||
// Unreachable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun mediaEvent(event: MediaEvent) {
|
|
||||||
if (event.type == MediaItemEventType.END) {
|
|
||||||
onMediaItemEnd.emit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playbackError(message: String) {
|
|
||||||
Logger.e(TAG, "Playback error: $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val eventHandler = EventHandler()
|
|
||||||
override val isReady: Boolean
|
|
||||||
get() = device.isReady()
|
|
||||||
override val name: String
|
|
||||||
get() = device.name()
|
|
||||||
override var usedRemoteAddress: InetAddress? = null
|
|
||||||
override var localAddress: InetAddress? = null
|
|
||||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
|
||||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
|
||||||
|
|
||||||
override val onConnectionStateChanged =
|
|
||||||
Event1<CastConnectionState>()
|
|
||||||
override val onPlayChanged: Event1<Boolean>
|
|
||||||
get() = eventHandler.onPlayChanged
|
|
||||||
override val onTimeChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onTimeChanged
|
|
||||||
override val onDurationChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onDurationChanged
|
|
||||||
override val onVolumeChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onVolumeChanged
|
|
||||||
override val onSpeedChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onSpeedChanged
|
|
||||||
override val onMediaItemEnd: Event0
|
|
||||||
get() = eventHandler.onMediaItemEnd
|
|
||||||
|
|
||||||
override fun resumePlayback() = device.resumePlayback()
|
|
||||||
override fun pausePlayback() = device.pausePlayback()
|
|
||||||
override fun stopPlayback() = device.stopPlayback()
|
|
||||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
|
||||||
override fun changeVolume(newVolume: Double) {
|
|
||||||
device.changeVolume(newVolume)
|
|
||||||
volume = newVolume
|
|
||||||
}
|
|
||||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
|
||||||
override fun connect() = device.connect(
|
|
||||||
ApplicationInfo(
|
|
||||||
"Grayjay Android",
|
|
||||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
|
||||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
|
||||||
),
|
|
||||||
eventHandler,
|
|
||||||
1000.toULong()
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun disconnect() = device.disconnect()
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
val info = device.getDeviceInfo()
|
|
||||||
return CastingDeviceInfo(
|
|
||||||
info.name,
|
|
||||||
when (info.protocol) {
|
|
||||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
|
||||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
|
||||||
},
|
|
||||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
|
||||||
port = info.port.toInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
|
||||||
ipAddrToInetAddress(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = device.load(
|
|
||||||
LoadRequest.Video(
|
|
||||||
contentType = contentType,
|
|
||||||
url = contentId,
|
|
||||||
resumePosition = resumePosition,
|
|
||||||
speed = speed,
|
|
||||||
volume = volume,
|
|
||||||
metadata = metadata,
|
|
||||||
requestHeaders = null,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = device.load(
|
|
||||||
LoadRequest.Content(
|
|
||||||
contentType = contentType,
|
|
||||||
content = content,
|
|
||||||
resumePosition = resumePosition,
|
|
||||||
speed = speed,
|
|
||||||
volume = volume,
|
|
||||||
metadata = metadata,
|
|
||||||
requestHeaders = null,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override var connectionState = CastConnectionState.DISCONNECTED
|
|
||||||
override val protocolType: CastProtocolType
|
|
||||||
get() = when (device.castingProtocol()) {
|
|
||||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
|
||||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
|
||||||
}
|
|
||||||
override var volume: Double = 1.0
|
|
||||||
override var duration: Double = 0.0
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
|
||||||
override var time: Double = 0.0
|
|
||||||
override var speed: Double = 0.0
|
|
||||||
override var isPlaying: Boolean = false
|
|
||||||
|
|
||||||
override val expectedCurrentTime: Double
|
|
||||||
get() {
|
|
||||||
val diff =
|
|
||||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
|
||||||
return time + diff
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
|
||||||
when (newState) {
|
|
||||||
is DeviceConnectionState.Connected -> {
|
|
||||||
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
|
||||||
try {
|
|
||||||
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
|
||||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
|
||||||
connectionState = CastConnectionState.CONNECTED
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
|
||||||
connectionState = CastConnectionState.CONNECTING
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceConnectionState.Disconnected -> {
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newState == DeviceConnectionState.Disconnected) {
|
|
||||||
try {
|
|
||||||
Logger.i(TAG, "Stopping device")
|
|
||||||
device.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to stop device: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
|
||||||
eventHandler.onTimeChanged.subscribe {
|
|
||||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
|
||||||
time = it
|
|
||||||
}
|
|
||||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
|
||||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
|
||||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun ensureThreadStarted() {}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "CastingDeviceExp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import com.futo.platformplayer.constructs.Event0
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
import org.fcast.sender_sdk.Metadata
|
|
||||||
import java.net.InetAddress
|
|
||||||
|
|
||||||
enum class CastConnectionState {
|
|
||||||
DISCONNECTED,
|
|
||||||
CONNECTING,
|
|
||||||
CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
|
||||||
enum class CastProtocolType {
|
|
||||||
CHROMECAST,
|
|
||||||
AIRPLAY,
|
|
||||||
FCAST;
|
|
||||||
|
|
||||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
|
||||||
val name = decoder.decodeString()
|
|
||||||
return when (name) {
|
|
||||||
"FASTCAST" -> FCAST // Handle the renamed case
|
|
||||||
else -> CastProtocolType.valueOf(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class CastingDeviceLegacy {
|
|
||||||
abstract val protocol: CastProtocolType;
|
|
||||||
abstract val isReady: Boolean;
|
|
||||||
abstract var usedRemoteAddress: InetAddress?;
|
|
||||||
abstract var localAddress: InetAddress?;
|
|
||||||
abstract val canSetVolume: Boolean;
|
|
||||||
abstract val canSetSpeed: Boolean;
|
|
||||||
|
|
||||||
var name: String? = null;
|
|
||||||
var isPlaying: Boolean = false
|
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
if (changed) {
|
|
||||||
onPlayChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
|
||||||
var time: Double = 0.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
|
||||||
time = value
|
|
||||||
lastTimeChangeTime_ms = changeTime_ms
|
|
||||||
onTimeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastDurationChangeTime_ms: Long = 0
|
|
||||||
var duration: Double = 0.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
|
||||||
duration = value
|
|
||||||
lastDurationChangeTime_ms = changeTime_ms
|
|
||||||
onDurationChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastVolumeChangeTime_ms: Long = 0
|
|
||||||
var volume: Double = 1.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
|
||||||
volume = value
|
|
||||||
lastVolumeChangeTime_ms = changeTime_ms
|
|
||||||
onVolumeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastSpeedChangeTime_ms: Long = 0
|
|
||||||
var speed: Double = 1.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
|
||||||
speed = value
|
|
||||||
lastSpeedChangeTime_ms = changeTime_ms
|
|
||||||
onSpeedChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val expectedCurrentTime: Double
|
|
||||||
get() {
|
|
||||||
val diff =
|
|
||||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
|
||||||
return time + diff;
|
|
||||||
};
|
|
||||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
onConnectionStateChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>();
|
|
||||||
var onTimeChanged = Event1<Double>();
|
|
||||||
var onDurationChanged = Event1<Double>();
|
|
||||||
var onVolumeChanged = Event1<Double>();
|
|
||||||
var onSpeedChanged = Event1<Double>();
|
|
||||||
|
|
||||||
abstract fun stopCasting();
|
|
||||||
|
|
||||||
abstract fun seekVideo(timeSeconds: Double);
|
|
||||||
abstract fun stopVideo();
|
|
||||||
abstract fun pauseVideo();
|
|
||||||
abstract fun resumeVideo();
|
|
||||||
abstract fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?
|
|
||||||
);
|
|
||||||
|
|
||||||
abstract fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?
|
|
||||||
);
|
|
||||||
|
|
||||||
open fun changeVolume(volume: Double) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun changeSpeed(speed: Double) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun start();
|
|
||||||
abstract fun stop();
|
|
||||||
|
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
|
||||||
|
|
||||||
abstract fun getAddresses(): List<InetAddress>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
|
||||||
override val isReady: Boolean get() = inner.isReady
|
|
||||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
|
||||||
override val localAddress: InetAddress? get() = inner.localAddress
|
|
||||||
override val name: String? get() = inner.name
|
|
||||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
|
||||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
|
||||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
|
||||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
|
||||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
|
||||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
|
||||||
override val onMediaItemEnd: Event0 = Event0()
|
|
||||||
override var connectionState: CastConnectionState
|
|
||||||
get() = inner.connectionState
|
|
||||||
set(_) = Unit
|
|
||||||
override val protocolType: CastProtocolType get() = inner.protocol
|
|
||||||
override var isPlaying: Boolean
|
|
||||||
get() = inner.isPlaying
|
|
||||||
set(_) = Unit
|
|
||||||
override val expectedCurrentTime: Double
|
|
||||||
get() = inner.expectedCurrentTime
|
|
||||||
override var speed: Double
|
|
||||||
get() = inner.speed
|
|
||||||
set(_) = Unit
|
|
||||||
override var time: Double
|
|
||||||
get() = inner.time
|
|
||||||
set(_) = Unit
|
|
||||||
override var duration: Double
|
|
||||||
get() = inner.duration
|
|
||||||
set(_) = Unit
|
|
||||||
override var volume: Double
|
|
||||||
get() = inner.volume
|
|
||||||
set(_) = Unit
|
|
||||||
|
|
||||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
|
||||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
|
||||||
override fun resumePlayback() = inner.resumeVideo()
|
|
||||||
override fun pausePlayback() = inner.pauseVideo()
|
|
||||||
override fun stopPlayback() = inner.stopVideo()
|
|
||||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
|
||||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
|
||||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
|
||||||
override fun connect() = inner.start()
|
|
||||||
override fun disconnect() = inner.stop()
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
|
||||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
|
||||||
override fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
|
||||||
|
|
||||||
override fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
|
||||||
|
|
||||||
override fun ensureThreadStarted() = when (inner) {
|
|
||||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
|
||||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,736 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.protos.ChromeCast
|
|
||||||
import com.futo.platformplayer.toHexString
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManager
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = true;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _streamType: String? = null;
|
|
||||||
private var _contentType: String? = null;
|
|
||||||
private var _contentId: String? = null;
|
|
||||||
|
|
||||||
private var _socket: SSLSocket? = null;
|
|
||||||
private var _outputStream: DataOutputStream? = null;
|
|
||||||
private var _outputStreamLock = Object();
|
|
||||||
private var _inputStream: DataInputStream? = null;
|
|
||||||
private var _inputStreamLock = Object();
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _requestId = 1;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _sessionId: String? = null;
|
|
||||||
private var _transportId: String? = null;
|
|
||||||
private var _launching = false;
|
|
||||||
private var _mediaSessionId: Int? = null;
|
|
||||||
private var _thread: Thread? = null;
|
|
||||||
private var _pingThread: Thread? = null;
|
|
||||||
private var _launchRetries = 0
|
|
||||||
private val MAX_LAUNCH_RETRIES = 3
|
|
||||||
private var _lastLaunchTime_ms = 0L
|
|
||||||
private var _retryJob: Job? = null
|
|
||||||
private var _autoLaunchEnabled = true
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
_streamType = streamType;
|
|
||||||
_contentType = contentType;
|
|
||||||
_contentId = contentId;
|
|
||||||
|
|
||||||
playVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
//TODO: Can maybe be implemented by sending data:contentType,base64...
|
|
||||||
throw NotImplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun connectMediaChannel(transportId: String) {
|
|
||||||
val connectObject = JSONObject();
|
|
||||||
connectObject.put("type", "CONNECT");
|
|
||||||
connectObject.put("connType", 0);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMediaStatus() {
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "GET_STATUS");
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playVideo() {
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val contentId = _contentId ?: return;
|
|
||||||
val streamType = _streamType ?: return;
|
|
||||||
val contentType = _contentType ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "LOAD");
|
|
||||||
|
|
||||||
val mediaObject = JSONObject();
|
|
||||||
mediaObject.put("contentId", contentId);
|
|
||||||
mediaObject.put("streamType", streamType);
|
|
||||||
mediaObject.put("contentType", contentType);
|
|
||||||
|
|
||||||
if (time > 0.0) {
|
|
||||||
val seekTime = time;
|
|
||||||
loadObject.put("currentTime", seekTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadObject.put("media", mediaObject);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
|
|
||||||
|
|
||||||
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
|
|
||||||
val json = loadObject.toString().replace("\\/","/");
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
|
|
||||||
|
|
||||||
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
|
|
||||||
setSpeed(speedClamped)
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return
|
|
||||||
val transportId = _transportId ?: return
|
|
||||||
val setSpeedObject = JSONObject().apply {
|
|
||||||
put("type", "SET_PLAYBACK_RATE")
|
|
||||||
put("mediaSessionId", mediaSessionId)
|
|
||||||
put("playbackRate", speedClamped)
|
|
||||||
put("requestId", _requestId++)
|
|
||||||
}
|
|
||||||
|
|
||||||
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeVolume(volume: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume)
|
|
||||||
val setVolumeObject = JSONObject();
|
|
||||||
setVolumeObject.put("type", "SET_VOLUME");
|
|
||||||
|
|
||||||
val volumeObject = JSONObject();
|
|
||||||
volumeObject.put("level", volume)
|
|
||||||
setVolumeObject.put("volume", volumeObject);
|
|
||||||
|
|
||||||
setVolumeObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "SEEK");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
loadObject.put("currentTime", timeSeconds);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "PLAY");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "PAUSE");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
_contentId = null;
|
|
||||||
_contentType = null;
|
|
||||||
_streamType = null;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "STOP");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchPlayer() {
|
|
||||||
if (invokeInIOScopeIfRequired(::launchPlayer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "LAUNCH");
|
|
||||||
launchObject.put("appId", "CC1AD845");
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatus() {
|
|
||||||
if (invokeInIOScopeIfRequired(::getStatus)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "GET_STATUS");
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch { action(); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessionId = _sessionId;
|
|
||||||
if (sessionId != null) {
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "STOP");
|
|
||||||
launchObject.put("sessionId", sessionId);
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
|
|
||||||
_contentId = null;
|
|
||||||
_contentType = null;
|
|
||||||
_streamType = null;
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_transportId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_autoLaunchEnabled = true
|
|
||||||
_started = true;
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_mediaSessionId = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
_launching = true;
|
|
||||||
|
|
||||||
ensureThreadsStarted();
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureThreadsStarted() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
|
|
||||||
val thread = _thread
|
|
||||||
val pingThread = _pingThread
|
|
||||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
|
||||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
|
||||||
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
var connectedSocket: Socket? = null
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
if (resultSocket == null) {
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedSocket = resultSocket
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress;
|
|
||||||
localAddress = connectedSocket.localAddress;
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sslContext = SSLContext.getInstance("TLS");
|
|
||||||
sslContext.init(null, trustAllCerts, null);
|
|
||||||
|
|
||||||
val factory = sslContext.socketFactory;
|
|
||||||
|
|
||||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
|
||||||
|
|
||||||
//Connection loop
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_mediaSessionId = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "Connecting to Chromecast.");
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
if (connectedSocket != null) {
|
|
||||||
Logger.i(TAG, "Using connected socket.")
|
|
||||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
|
||||||
connectedSocket = null
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Using new socket.")
|
|
||||||
val s = Socket().apply { this.connect(address, 2000) }
|
|
||||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
|
||||||
}
|
|
||||||
|
|
||||||
_socket?.startHandshake();
|
|
||||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
|
||||||
|
|
||||||
try {
|
|
||||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
|
||||||
_inputStream = DataInputStream(_socket?.inputStream);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
localAddress = _socket?.localAddress;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val connectObject = JSONObject();
|
|
||||||
connectObject.put("type", "CONNECT");
|
|
||||||
connectObject.put("connType", 0);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
|
||||||
_socket?.close();
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus();
|
|
||||||
|
|
||||||
val buffer = ByteArray(409600);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started receiving.");
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val inputStream = _inputStream ?: break;
|
|
||||||
|
|
||||||
val message = synchronized(_inputStreamLock)
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Receiving next packet...");
|
|
||||||
val b1 = inputStream.readUnsignedByte();
|
|
||||||
val b2 = inputStream.readUnsignedByte();
|
|
||||||
val b3 = inputStream.readUnsignedByte();
|
|
||||||
val b4 = inputStream.readUnsignedByte();
|
|
||||||
val size =
|
|
||||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
|
||||||
if (size > buffer.size) {
|
|
||||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
|
||||||
inputStream.skip(size.toLong());
|
|
||||||
return@synchronized null
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
|
||||||
inputStream.read(buffer, 0, size);
|
|
||||||
|
|
||||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
|
||||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
|
||||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
|
||||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
|
||||||
Logger.i(TAG, "Received message: $msg");
|
|
||||||
}
|
|
||||||
return@synchronized msg
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message != null) {
|
|
||||||
try {
|
|
||||||
handleMessage(message);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to handle message.", e);
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: java.net.SocketException) {
|
|
||||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Exception while receiving.", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped connection loop.");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}.apply { start() };
|
|
||||||
|
|
||||||
//Start ping loop
|
|
||||||
_pingThread = Thread {
|
|
||||||
Logger.i(TAG, "Started ping loop.")
|
|
||||||
|
|
||||||
val pingObject = JSONObject();
|
|
||||||
pingObject.put("type", "PING");
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to send ping.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.sleep(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped ping loop.");
|
|
||||||
}.apply { start() };
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Threads still alive, not restarted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
|
||||||
try {
|
|
||||||
val castMessage = ChromeCast.CastMessage.newBuilder()
|
|
||||||
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
|
||||||
.setSourceId(sourceId)
|
|
||||||
.setDestinationId(destinationId)
|
|
||||||
.setNamespace(namespace)
|
|
||||||
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
|
||||||
.setPayloadUtf8(json)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
sendMessage(castMessage.toByteArray());
|
|
||||||
|
|
||||||
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
|
||||||
//Log.d(TAG, "Sent channel message: $castMessage");
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMessage(message: ChromeCast.CastMessage) {
|
|
||||||
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
|
||||||
val jsonObject = JSONObject(message.payloadUtf8);
|
|
||||||
val type = jsonObject.getString("type");
|
|
||||||
if (type == "RECEIVER_STATUS") {
|
|
||||||
val status = jsonObject.getJSONObject("status");
|
|
||||||
|
|
||||||
var sessionIsRunning = false;
|
|
||||||
if (status.has("applications")) {
|
|
||||||
val applications = status.getJSONArray("applications");
|
|
||||||
|
|
||||||
for (i in 0 until applications.length()) {
|
|
||||||
val applicationUpdate = applications.getJSONObject(i);
|
|
||||||
|
|
||||||
val appId = applicationUpdate.getString("appId");
|
|
||||||
Logger.i(TAG, "Status update received appId (appId: $appId)");
|
|
||||||
|
|
||||||
if (appId == "CC1AD845") {
|
|
||||||
sessionIsRunning = true;
|
|
||||||
_autoLaunchEnabled = false
|
|
||||||
|
|
||||||
if (_sessionId == null) {
|
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
|
||||||
_sessionId = applicationUpdate.getString("sessionId");
|
|
||||||
_launchRetries = 0
|
|
||||||
|
|
||||||
val transportId = applicationUpdate.getString("transportId");
|
|
||||||
connectMediaChannel(transportId);
|
|
||||||
Logger.i(TAG, "Connected to media channel $transportId");
|
|
||||||
_transportId = transportId;
|
|
||||||
|
|
||||||
requestMediaStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionIsRunning) {
|
|
||||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
|
||||||
_sessionId = null
|
|
||||||
_mediaSessionId = null
|
|
||||||
_transportId = null
|
|
||||||
|
|
||||||
if (_autoLaunchEnabled) {
|
|
||||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
|
||||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
|
||||||
_launchRetries++
|
|
||||||
launchPlayer()
|
|
||||||
} else {
|
|
||||||
// Maybe the first GET_STATUS came back empty; still try launching
|
|
||||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
|
||||||
_launching = true
|
|
||||||
_launchRetries++
|
|
||||||
launchPlayer()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
|
||||||
Logger.i(TAG, "Unable to start media receiver on device")
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (_retryJob == null) {
|
|
||||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
|
||||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
|
||||||
delay(5000)
|
|
||||||
getStatus()
|
|
||||||
_retryJob = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_launching = false
|
|
||||||
_launchRetries = 0
|
|
||||||
_autoLaunchEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
val volume = status.getJSONObject("volume");
|
|
||||||
//val volumeControlType = volume.getString("controlType");
|
|
||||||
val volumeLevel = volume.getString("level").toDouble();
|
|
||||||
val volumeMuted = volume.getBoolean("muted");
|
|
||||||
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
|
||||||
setVolume(if (volumeMuted) 0.0 else volumeLevel);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
|
|
||||||
} else if (type == "MEDIA_STATUS") {
|
|
||||||
val statuses = jsonObject.getJSONArray("status");
|
|
||||||
for (i in 0 until statuses.length()) {
|
|
||||||
val status = statuses.getJSONObject(i);
|
|
||||||
_mediaSessionId = status.getInt("mediaSessionId");
|
|
||||||
|
|
||||||
val playerState = status.getString("playerState");
|
|
||||||
val currentTime = status.getDouble("currentTime");
|
|
||||||
if (status.has("media")) {
|
|
||||||
val media = status.getJSONObject("media")
|
|
||||||
if (media.has("duration")) {
|
|
||||||
setDuration(media.getDouble("duration"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = playerState == "PLAYING";
|
|
||||||
if (isPlaying || playerState == "PAUSED") {
|
|
||||||
setTime(currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackRate = status.getInt("playbackRate");
|
|
||||||
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
|
|
||||||
|
|
||||||
if (_contentType == null) {
|
|
||||||
stopVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
|
||||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
|
||||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
|
||||||
playVideo()
|
|
||||||
}
|
|
||||||
} else if (type == "CLOSE") {
|
|
||||||
if (message.sourceId == "receiver-0") {
|
|
||||||
Logger.i(TAG, "Close received.");
|
|
||||||
stopCasting();
|
|
||||||
} else if (_transportId == message.sourceId) {
|
|
||||||
throw Exception("Transport id closed.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw Exception("Payload type ${message.payloadType} is not implemented.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendMessage(data: ByteArray) {
|
|
||||||
val outputStream = _outputStream;
|
|
||||||
if (outputStream == null) {
|
|
||||||
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_outputStreamLock)
|
|
||||||
{
|
|
||||||
val serializedSizeBE = ByteArray(4);
|
|
||||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
|
||||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
|
||||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
|
||||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
|
||||||
outputStream.write(serializedSizeBE);
|
|
||||||
outputStream.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
|
|
||||||
_contentId = null
|
|
||||||
_contentType = null
|
|
||||||
_streamType = null
|
|
||||||
|
|
||||||
_retryJob?.cancel()
|
|
||||||
_retryJob = null
|
|
||||||
|
|
||||||
val socket = _socket;
|
|
||||||
val scopeIO = _scopeIO;
|
|
||||||
|
|
||||||
if (scopeIO != null && socket != null) {
|
|
||||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
|
||||||
|
|
||||||
scopeIO.launch {
|
|
||||||
socket.close();
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
scopeIO.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_pingThread = null;
|
|
||||||
_thread = null;
|
|
||||||
_scopeIO = null;
|
|
||||||
_socket = null;
|
|
||||||
_outputStream = null;
|
|
||||||
_inputStream = null;
|
|
||||||
_mediaSessionId = null;
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "ChromecastCastingDevice";
|
|
||||||
|
|
||||||
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
|
||||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSeekMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
|
||||||
import com.futo.platformplayer.ensureNotMainThread
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.toHexString
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.math.BigInteger
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.KeyPair
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.spec.X509EncodedKeySpec
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.KeyAgreement
|
|
||||||
import javax.crypto.spec.DHParameterSpec
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
enum class Opcode(val value: Byte) {
|
|
||||||
None(0),
|
|
||||||
Play(1),
|
|
||||||
Pause(2),
|
|
||||||
Resume(3),
|
|
||||||
Stop(4),
|
|
||||||
Seek(5),
|
|
||||||
PlaybackUpdate(6),
|
|
||||||
VolumeUpdate(7),
|
|
||||||
SetVolume(8),
|
|
||||||
PlaybackError(9),
|
|
||||||
SetSpeed(10),
|
|
||||||
Version(11),
|
|
||||||
Ping(12),
|
|
||||||
Pong(13);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val _map = entries.associateBy { it.value }
|
|
||||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FCastCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: TODO
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = true;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _socket: Socket? = null;
|
|
||||||
private var _outputStream: OutputStream? = null;
|
|
||||||
private var _inputStream: InputStream? = null;
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _version: Long = 1;
|
|
||||||
private var _thread: Thread? = null
|
|
||||||
private var _pingThread: Thread? = null
|
|
||||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
|
||||||
private var _outputStreamLock = Object()
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Remove this later, temporary for the transition
|
|
||||||
if (_version <= 1L) {
|
|
||||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
send(Opcode.Play, FCastPlayMessage(
|
|
||||||
container = contentType,
|
|
||||||
url = contentId,
|
|
||||||
time = resumePosition,
|
|
||||||
speed = speed
|
|
||||||
));
|
|
||||||
|
|
||||||
setSpeed(speed ?: 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Remove this later, temporary for the transition
|
|
||||||
if (_version <= 1L) {
|
|
||||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
send(Opcode.Play, FCastPlayMessage(
|
|
||||||
container = contentType,
|
|
||||||
content = content,
|
|
||||||
time = resumePosition,
|
|
||||||
speed = speed
|
|
||||||
));
|
|
||||||
|
|
||||||
setSpeed(speed ?: 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeVolume(volume: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume);
|
|
||||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSpeed(speed);
|
|
||||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Seek, FCastSeekMessage(
|
|
||||||
time = timeSeconds
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Resume);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Stop);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch {
|
|
||||||
try {
|
|
||||||
action();
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopVideo();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
ensureThreadStarted();
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureThreadStarted() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
|
|
||||||
val thread = _thread
|
|
||||||
val pingThread = _pingThread
|
|
||||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
|
||||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
|
||||||
|
|
||||||
_scopeIO?.let {
|
|
||||||
it.cancel()
|
|
||||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Log.i(TAG, "Connection thread started.")
|
|
||||||
|
|
||||||
var connectedSocket: Socket? = null
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
|
||||||
|
|
||||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
|
|
||||||
if (resultSocket == null) {
|
|
||||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Connection succeeded.")
|
|
||||||
|
|
||||||
connectedSocket = resultSocket
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress
|
|
||||||
localAddress = connectedSocket.localAddress
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
|
||||||
|
|
||||||
//Connection loop
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
Logger.i(TAG, "Connecting to FastCast.");
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
if (connectedSocket != null) {
|
|
||||||
Logger.i(TAG, "Using connected socket.");
|
|
||||||
_socket = connectedSocket
|
|
||||||
connectedSocket = null
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Using new socket.");
|
|
||||||
_socket = Socket().apply { this.connect(address, 2000) };
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
|
||||||
|
|
||||||
_outputStream = _socket?.outputStream;
|
|
||||||
_inputStream = _socket?.inputStream;
|
|
||||||
} catch (e: IOException) {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
localAddress = _socket?.localAddress
|
|
||||||
_lastPongTime = System.currentTimeMillis()
|
|
||||||
connectionState = CastConnectionState.CONNECTED
|
|
||||||
|
|
||||||
val buffer = ByteArray(4096);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started receiving.");
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val inputStream = _inputStream ?: break;
|
|
||||||
Log.d(TAG, "Receiving next packet...");
|
|
||||||
|
|
||||||
var headerBytesRead = 0
|
|
||||||
while (headerBytesRead < 4) {
|
|
||||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
|
||||||
if (read == -1)
|
|
||||||
throw Exception("Stream closed")
|
|
||||||
headerBytesRead += read
|
|
||||||
}
|
|
||||||
|
|
||||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
|
||||||
if (size > buffer.size) {
|
|
||||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
|
||||||
var bytesRead = 0
|
|
||||||
while (bytesRead < size) {
|
|
||||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
|
||||||
if (read == -1)
|
|
||||||
throw Exception("Stream closed")
|
|
||||||
bytesRead += read
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
|
||||||
|
|
||||||
val opcode = messageBytes[0];
|
|
||||||
var json: String? = null;
|
|
||||||
if (size > 1) {
|
|
||||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
handleMessage(Opcode.find(opcode), json);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to handle message.", e)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (e: java.net.SocketException) {
|
|
||||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
|
||||||
break
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Exception while receiving.", e);
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped connection loop.");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}.apply { start() }
|
|
||||||
|
|
||||||
_pingThread = Thread {
|
|
||||||
Logger.i(TAG, "Started ping loop.")
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
if (connectionState == CastConnectionState.CONNECTED) {
|
|
||||||
try {
|
|
||||||
send(Opcode.Ping)
|
|
||||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
|
||||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to send ping.")
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Thread.sleep(5000)
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Stopped ping loop.")
|
|
||||||
}.apply { start() }
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Thread was still alive, not restarted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
|
||||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
|
||||||
|
|
||||||
when (opcode) {
|
|
||||||
Opcode.PlaybackUpdate -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
|
||||||
setTime(playbackUpdate.time, playbackUpdate.generationTime);
|
|
||||||
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
|
|
||||||
isPlaying = when (playbackUpdate.state) {
|
|
||||||
1 -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Opcode.VolumeUpdate -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
|
||||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
|
||||||
}
|
|
||||||
Opcode.PlaybackError -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
|
||||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
|
||||||
}
|
|
||||||
Opcode.Version -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
|
|
||||||
_version = version.version;
|
|
||||||
Logger.i(TAG, "Remote version received: $version")
|
|
||||||
}
|
|
||||||
Opcode.Ping -> send(Opcode.Pong)
|
|
||||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
|
||||||
else -> { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun send(opcode: Opcode, message: String? = null) {
|
|
||||||
ensureNotMainThread()
|
|
||||||
|
|
||||||
synchronized (_outputStreamLock) {
|
|
||||||
try {
|
|
||||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
|
||||||
val size = 1 + data.size
|
|
||||||
val outputStream = _outputStream
|
|
||||||
if (outputStream == null) {
|
|
||||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val serializedSizeLE = ByteArray(4)
|
|
||||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
|
||||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
|
||||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
|
||||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
|
||||||
outputStream.write(serializedSizeLE)
|
|
||||||
|
|
||||||
val opcodeBytes = ByteArray(1)
|
|
||||||
opcodeBytes[0] = opcode.value
|
|
||||||
outputStream.write(opcodeBytes)
|
|
||||||
|
|
||||||
if (data.isNotEmpty()) {
|
|
||||||
outputStream.write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.i(TAG, "Failed to send message.", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
|
||||||
try {
|
|
||||||
send(opcode, message?.let { Json.encodeToString(it) })
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.i(TAG, "Failed to encode message to string.", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
//TODO: Kill and/or join thread?
|
|
||||||
_thread = null;
|
|
||||||
_pingThread = null;
|
|
||||||
|
|
||||||
val socket = _socket;
|
|
||||||
val scopeIO = _scopeIO;
|
|
||||||
|
|
||||||
if (scopeIO != null && socket != null) {
|
|
||||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
|
||||||
|
|
||||||
scopeIO.launch {
|
|
||||||
socket.close();
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
scopeIO.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO = null;
|
|
||||||
_socket = null;
|
|
||||||
_outputStream = null;
|
|
||||||
_inputStream = null;
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "FCastCastingDevice";
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
|
||||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generateKeyPair(): KeyPair {
|
|
||||||
//modp14
|
|
||||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
|
||||||
val g = BigInteger("2", 16)
|
|
||||||
val dhSpec = DHParameterSpec(p, g)
|
|
||||||
|
|
||||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
|
||||||
keyGen.initialize(dhSpec)
|
|
||||||
|
|
||||||
return keyGen.generateKeyPair()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
|
||||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
|
||||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
|
||||||
|
|
||||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
|
||||||
keyAgreement.init(privateKey)
|
|
||||||
keyAgreement.doPhase(receivedPublicKey, true)
|
|
||||||
|
|
||||||
val sharedSecret = keyAgreement.generateSecret()
|
|
||||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
|
||||||
val hashedSecret = sha256.digest(sharedSecret)
|
|
||||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
|
||||||
|
|
||||||
return SecretKeySpec(hashedSecret, "AES")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
|
||||||
val iv = cipher.iv
|
|
||||||
val json = Json.encodeToString(decryptedMessage)
|
|
||||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
|
||||||
return FCastEncryptedMessage(
|
|
||||||
version = 1,
|
|
||||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
|
||||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
|
||||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
|
||||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
|
||||||
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
|
||||||
val decryptedJson = cipher.doFinal(encrypted)
|
|
||||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import android.util.Log
|
|||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -57,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.fcast.sender_sdk.CastContext
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||||
|
|
||||||
abstract class StateCasting {
|
class StateCasting {
|
||||||
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||||
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||||
@@ -92,15 +99,163 @@ abstract class StateCasting {
|
|||||||
val isCasting: Boolean get() = activeDevice != null;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
private val _castId = AtomicInteger(0)
|
private val _castId = AtomicInteger(0)
|
||||||
|
|
||||||
abstract fun handleUrl(url: String)
|
private val _context = CastContext()
|
||||||
abstract fun onStop()
|
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||||
abstract fun start(context: Context)
|
|
||||||
abstract fun stop()
|
|
||||||
|
|
||||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
|
class DiscoveryEventHandler(
|
||||||
abstract fun startUpdateTimeJob(
|
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
private val onDeviceRemoved: (String) -> Unit,
|
||||||
): Job?
|
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||||
|
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||||
|
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceAdded(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceUpdated(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceRemoved(deviceName: String) {
|
||||||
|
onDeviceRemoved(deviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleUrl(url: String) {
|
||||||
|
try {
|
||||||
|
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||||
|
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||||
|
connectDevice(CastingDevice(foundDevice))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle URL: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStop() {
|
||||||
|
val ad = activeDevice ?: return
|
||||||
|
_resumeCastingDevice = ad.getDeviceInfo()
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||||
|
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||||
|
try {
|
||||||
|
ad.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (_started)
|
||||||
|
return
|
||||||
|
_started = true
|
||||||
|
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||||
|
_resumeCastingDevice = null
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService starting...")
|
||||||
|
|
||||||
|
_castServer.start()
|
||||||
|
enableDeveloper(true)
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService started.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||||
|
context,
|
||||||
|
DiscoveryEventHandler(
|
||||||
|
{ deviceInfo -> // Added
|
||||||
|
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||||
|
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||||
|
val deviceHandle = CastingDevice(device)
|
||||||
|
devices[deviceHandle.device.name()] = deviceHandle
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceAdded.emit(deviceHandle)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceName -> // Removed
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
if (devices.containsKey(deviceName)) {
|
||||||
|
val device = devices.remove(deviceName)
|
||||||
|
if (device != null) {
|
||||||
|
onDeviceRemoved.emit(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceInfo -> // Updated
|
||||||
|
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||||
|
val handle = devices[deviceInfo.name]
|
||||||
|
if (handle != null && handle is CastingDevice) {
|
||||||
|
handle.device.setPort(deviceInfo.port)
|
||||||
|
handle.device.setAddresses(deviceInfo.addresses)
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceChanged.emit(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop() {
|
||||||
|
if (!_started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = false
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopping.")
|
||||||
|
|
||||||
|
_scopeIO.cancel()
|
||||||
|
_scopeMain.cancel()
|
||||||
|
|
||||||
|
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||||
|
val d = activeDevice
|
||||||
|
activeDevice = null
|
||||||
|
try {
|
||||||
|
d?.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.stop()
|
||||||
|
_castServer.removeAllHandlers()
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startUpdateTimeJob(
|
||||||
|
onTimeJobTimeChanged_s: Event1<Long>,
|
||||||
|
setTime: (Long) -> Unit
|
||||||
|
): Job? = null
|
||||||
|
|
||||||
|
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
|
||||||
|
try {
|
||||||
|
val rsAddrs =
|
||||||
|
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||||
|
val rsDeviceInfo = RsDeviceInfo(
|
||||||
|
name = deviceInfo.name,
|
||||||
|
protocol = when (deviceInfo.type) {
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||||
|
else -> throw IllegalArgumentException()
|
||||||
|
},
|
||||||
|
addresses = rsAddrs,
|
||||||
|
port = deviceInfo.port.toUShort(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onResume() {
|
fun onResume() {
|
||||||
val ad = activeDevice
|
val ad = activeDevice
|
||||||
@@ -1241,6 +1396,47 @@ abstract class StateCasting {
|
|||||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun escapeXml(s: String): String =
|
||||||
|
s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
|
||||||
|
private fun injectSubtitleAdaptationSet(
|
||||||
|
mpd: String,
|
||||||
|
subtitleUrl: String,
|
||||||
|
mimeType: String,
|
||||||
|
lang: String = "und",
|
||||||
|
label: String = "Subtitles"
|
||||||
|
): String {
|
||||||
|
val mt = mimeType.lowercase()
|
||||||
|
val codecs = when (mt) {
|
||||||
|
"text/vtt", "text/webvtt" -> "wvtt"
|
||||||
|
"application/ttml+xml", "application/ttml" -> "stpp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
|
||||||
|
|
||||||
|
val adaptation = """
|
||||||
|
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
|
||||||
|
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
|
||||||
|
<Label>${escapeXml(label)}</Label>
|
||||||
|
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||||
|
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
return if (periodClose.containsMatchIn(mpd)) {
|
||||||
|
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
|
||||||
|
} else {
|
||||||
|
mpd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
@@ -1262,30 +1458,42 @@ abstract class StateCasting {
|
|||||||
val videoUrl = url + videoPath
|
val videoUrl = url + videoPath
|
||||||
val audioUrl = url + audioPath
|
val audioUrl = url + audioPath
|
||||||
|
|
||||||
|
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
|
||||||
|
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
|
||||||
|
|
||||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||||
return@withContext subtitleSource.getSubtitlesURI();
|
subtitleSource.getSubtitlesURI()
|
||||||
} else null;
|
} else null
|
||||||
|
|
||||||
var subtitlesUrl: String? = null;
|
var subtitlesUrl: String? = null
|
||||||
if (subtitlesUri != null) {
|
if (subtitlesUri != null) {
|
||||||
if(subtitlesUri.scheme == "file") {
|
when (subtitlesUri.scheme) {
|
||||||
var content: String? = null;
|
"file", "content" -> {
|
||||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
val content = withContext(Dispatchers.IO) {
|
||||||
inputStream?.use { stream ->
|
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
|
||||||
val reader = stream.bufferedReader();
|
stream.bufferedReader().use { it.readText() }
|
||||||
content = reader.use { it.readText() };
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.isNullOrEmpty()) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||||
|
true
|
||||||
|
).withTag("castDashRaw")
|
||||||
|
|
||||||
|
subtitlesUrl = url + subtitlePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
"http", "https" -> {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
// Receiver will fetch directly (works only if it doesn’t need auth/headers)
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
subtitlesUrl = subtitlesUri.toString()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
|
||||||
).withTag("cast");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitlesUrl = url + subtitlePath;
|
else -> {
|
||||||
} else {
|
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
|
||||||
subtitlesUrl = subtitlesUri.toString();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1331,6 +1539,14 @@ abstract class StateCasting {
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subtitlesUrl != null) {
|
||||||
|
dashContent = injectSubtitleAdaptationSet(
|
||||||
|
dashContent,
|
||||||
|
subtitlesUrl!!,
|
||||||
|
subtitleMimeTypeForMpd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var hasAudioInDash = false
|
var hasAudioInDash = false
|
||||||
for (representation in representationRegex.findAll(dashContent)) {
|
for (representation in representationRegex.findAll(dashContent)) {
|
||||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||||
@@ -1471,11 +1687,7 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
|
var instance = StateCasting()
|
||||||
StateCastingExp()
|
|
||||||
} else {
|
|
||||||
StateCastingLegacy()
|
|
||||||
}
|
|
||||||
private val representationRegex = Regex(
|
private val representationRegex = Regex(
|
||||||
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
||||||
RegexOption.DOT_MATCHES_ALL
|
RegexOption.DOT_MATCHES_ALL
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
|
||||||
import org.fcast.sender_sdk.CastContext
|
|
||||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
|
||||||
|
|
||||||
class StateCastingExp : StateCasting() {
|
|
||||||
private val _context = CastContext()
|
|
||||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
|
||||||
|
|
||||||
class DiscoveryEventHandler(
|
|
||||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
|
||||||
private val onDeviceRemoved: (String) -> Unit,
|
|
||||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
|
||||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
|
||||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
|
||||||
onDeviceAdded(deviceInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
|
||||||
onDeviceUpdated(deviceInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceRemoved(deviceName: String) {
|
|
||||||
onDeviceRemoved(deviceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleUrl(url: String) {
|
|
||||||
try {
|
|
||||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
|
||||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
|
||||||
connectDevice(CastingDeviceExp(foundDevice))
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to handle URL: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
val ad = activeDevice ?: return
|
|
||||||
_resumeCastingDevice = ad.getDeviceInfo()
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
|
||||||
try {
|
|
||||||
ad.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun start(context: Context) {
|
|
||||||
if (_started)
|
|
||||||
return
|
|
||||||
_started = true
|
|
||||||
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
|
||||||
_resumeCastingDevice = null
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...")
|
|
||||||
|
|
||||||
_castServer.start()
|
|
||||||
enableDeveloper(true)
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.")
|
|
||||||
|
|
||||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
|
||||||
context,
|
|
||||||
DiscoveryEventHandler(
|
|
||||||
{ deviceInfo -> // Added
|
|
||||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
|
||||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
|
||||||
val deviceHandle = CastingDeviceExp(device)
|
|
||||||
devices[deviceHandle.device.name()] = deviceHandle
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
onDeviceAdded.emit(deviceHandle)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deviceName -> // Removed
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
if (devices.containsKey(deviceName)) {
|
|
||||||
val device = devices.remove(deviceName)
|
|
||||||
if (device != null) {
|
|
||||||
onDeviceRemoved.emit(device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deviceInfo -> // Updated
|
|
||||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
|
||||||
val handle = devices[deviceInfo.name]
|
|
||||||
if (handle != null && handle is CastingDeviceExp) {
|
|
||||||
handle.device.setPort(deviceInfo.port)
|
|
||||||
handle.device.setAddresses(deviceInfo.addresses)
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
onDeviceChanged.emit(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun stop() {
|
|
||||||
if (!_started) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = false
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
|
||||||
|
|
||||||
_scopeIO.cancel()
|
|
||||||
_scopeMain.cancel()
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
|
||||||
val d = activeDevice
|
|
||||||
activeDevice = null
|
|
||||||
try {
|
|
||||||
d?.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
|
||||||
}
|
|
||||||
|
|
||||||
_castServer.stop()
|
|
||||||
_castServer.removeAllHandlers()
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
|
||||||
|
|
||||||
_deviceDiscoverer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startUpdateTimeJob(
|
|
||||||
onTimeJobTimeChanged_s: Event1<Long>,
|
|
||||||
setTime: (Long) -> Unit
|
|
||||||
): Job? = null
|
|
||||||
|
|
||||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
|
|
||||||
try {
|
|
||||||
val rsAddrs =
|
|
||||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
|
||||||
val rsDeviceInfo = RsDeviceInfo(
|
|
||||||
name = deviceInfo.name,
|
|
||||||
protocol = when (deviceInfo.type) {
|
|
||||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
|
||||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
|
||||||
else -> throw IllegalArgumentException()
|
|
||||||
},
|
|
||||||
addresses = rsAddrs,
|
|
||||||
port = deviceInfo.port.toUShort(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "StateCastingExp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.net.nsd.NsdManager
|
|
||||||
import android.net.nsd.NsdServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.net.InetAddress
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
class StateCastingLegacy : StateCasting() {
|
|
||||||
private var _nsdManager: NsdManager? = null
|
|
||||||
|
|
||||||
private val _discoveryListeners = mapOf(
|
|
||||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
|
||||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
|
||||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
|
||||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun handleUrl(url: String) {
|
|
||||||
val uri = Uri.parse(url)
|
|
||||||
if (uri.scheme != "fcast") {
|
|
||||||
throw Exception("Expected scheme to be FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
val type = uri.host
|
|
||||||
if (type != "r") {
|
|
||||||
throw Exception("Expected type r")
|
|
||||||
}
|
|
||||||
|
|
||||||
val connectionInfo = uri.pathSegments[0]
|
|
||||||
val json =
|
|
||||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
|
||||||
.toString(Charsets.UTF_8)
|
|
||||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
|
||||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
|
||||||
|
|
||||||
val foundInfo = addRememberedDevice(
|
|
||||||
CastingDeviceInfo(
|
|
||||||
name = networkConfig.name,
|
|
||||||
type = CastProtocolType.FCAST,
|
|
||||||
addresses = networkConfig.addresses.toTypedArray(),
|
|
||||||
port = tcpService.port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (foundInfo != null) {
|
|
||||||
connectDevice(deviceFromInfo(foundInfo))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
val ad = activeDevice ?: return;
|
|
||||||
_resumeCastingDevice = ad.getDeviceInfo()
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
|
||||||
ad.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun start(context: Context) {
|
|
||||||
if (_started)
|
|
||||||
return;
|
|
||||||
_started = true;
|
|
||||||
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
|
||||||
_resumeCastingDevice = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...");
|
|
||||||
|
|
||||||
_castServer.start();
|
|
||||||
enableDeveloper(true);
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.");
|
|
||||||
|
|
||||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
|
||||||
startDiscovering()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun startDiscovering() {
|
|
||||||
_nsdManager?.apply {
|
|
||||||
_discoveryListeners.forEach {
|
|
||||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun stopDiscovering() {
|
|
||||||
_nsdManager?.apply {
|
|
||||||
_discoveryListeners.forEach {
|
|
||||||
try {
|
|
||||||
stopServiceDiscovery(it.value)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun stop() {
|
|
||||||
if (!_started)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_started = false;
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
|
||||||
|
|
||||||
stopDiscovering()
|
|
||||||
_scopeIO.cancel();
|
|
||||||
_scopeMain.cancel();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
|
||||||
val d = activeDevice;
|
|
||||||
activeDevice = null;
|
|
||||||
d?.disconnect();
|
|
||||||
|
|
||||||
_castServer.stop();
|
|
||||||
_castServer.removeAllHandlers();
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
|
||||||
|
|
||||||
_nsdManager = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
|
||||||
return object : NsdManager.DiscoveryListener {
|
|
||||||
override fun onDiscoveryStarted(regType: String) {
|
|
||||||
Log.d(TAG, "Service discovery started for $regType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDiscoveryStopped(serviceType: String) {
|
|
||||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost(service: NsdServiceInfo) {
|
|
||||||
Log.e(TAG, "service lost: $service")
|
|
||||||
// TODO: Handle service lost, e.g., remove device
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
||||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
|
||||||
try {
|
|
||||||
_nsdManager?.stopServiceDiscovery(this)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
||||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
|
||||||
try {
|
|
||||||
_nsdManager?.stopServiceDiscovery(this)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceFound(service: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
|
||||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
service.hostAddresses.toTypedArray()
|
|
||||||
} else {
|
|
||||||
arrayOf(service.host)
|
|
||||||
}
|
|
||||||
addOrUpdate(service.serviceName, addresses, service.port)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
_nsdManager?.registerServiceInfoCallback(
|
|
||||||
service,
|
|
||||||
{ it.run() },
|
|
||||||
object : NsdManager.ServiceInfoCallback {
|
|
||||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
|
||||||
addOrUpdate(
|
|
||||||
serviceInfo.serviceName,
|
|
||||||
serviceInfo.hostAddresses.toTypedArray(),
|
|
||||||
serviceInfo.port
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost() {
|
|
||||||
Log.v(TAG, "onServiceLost: $service")
|
|
||||||
// TODO: Handle service lost
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
|
||||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceInfoCallbackUnregistered() {
|
|
||||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
|
||||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
|
||||||
Log.v(TAG, "Resolve failed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
|
||||||
addOrUpdate(
|
|
||||||
serviceInfo.serviceName,
|
|
||||||
arrayOf(serviceInfo.host),
|
|
||||||
serviceInfo.port
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startUpdateTimeJob(
|
|
||||||
onTimeJobTimeChanged_s: Event1<Long>,
|
|
||||||
setTime: (Long) -> Unit
|
|
||||||
): Job? {
|
|
||||||
val d = activeDevice;
|
|
||||||
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
|
||||||
return _scopeMain.launch {
|
|
||||||
while (true) {
|
|
||||||
val device = instance.activeDevice
|
|
||||||
if (device == null || !device.isPlaying) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
|
||||||
setTime(time_ms)
|
|
||||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
|
||||||
return CastingDeviceLegacyWrapper(
|
|
||||||
when (deviceInfo.type) {
|
|
||||||
CastProtocolType.CHROMECAST -> {
|
|
||||||
ChromecastCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
CastProtocolType.AIRPLAY -> {
|
|
||||||
AirPlayCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
CastProtocolType.FCAST -> {
|
|
||||||
FCastCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateChromeCastDevice(
|
|
||||||
name: String,
|
|
||||||
addresses: Array<InetAddress>,
|
|
||||||
port: Int
|
|
||||||
) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = {
|
|
||||||
CastingDeviceLegacyWrapper(
|
|
||||||
ChromecastCastingDevice(
|
|
||||||
name,
|
|
||||||
addresses,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
d.inner.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = {
|
|
||||||
CastingDeviceLegacyWrapper(
|
|
||||||
AirPlayCastingDevice(
|
|
||||||
name,
|
|
||||||
addresses,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.port = port;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.port = port;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun addOrUpdateCastDevice(
|
|
||||||
name: String,
|
|
||||||
deviceFactory: () -> CastingDevice,
|
|
||||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
|
||||||
) {
|
|
||||||
var invokeEvents: (() -> Unit)? = null;
|
|
||||||
|
|
||||||
synchronized(devices) {
|
|
||||||
val device = devices[name];
|
|
||||||
if (device != null) {
|
|
||||||
val changed = deviceUpdater(device);
|
|
||||||
if (changed) {
|
|
||||||
invokeEvents = {
|
|
||||||
onDeviceChanged.emit(device);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val newDevice = deviceFactory();
|
|
||||||
this.devices[name] = newDevice
|
|
||||||
|
|
||||||
invokeEvents = {
|
|
||||||
onDeviceAdded.emit(newDevice);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class FCastNetworkConfig(
|
|
||||||
val name: String,
|
|
||||||
val addresses: List<String>,
|
|
||||||
val services: List<FCastService>
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class FCastService(
|
|
||||||
val port: Int,
|
|
||||||
val type: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "StateCastingLegacy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlayMessage(
|
|
||||||
val container: String,
|
|
||||||
val url: String? = null,
|
|
||||||
val content: String? = null,
|
|
||||||
val time: Double? = null,
|
|
||||||
val speed: Double? = null
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSeekMessage(
|
|
||||||
val time: Double
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlaybackUpdateMessage(
|
|
||||||
val generationTime: Long,
|
|
||||||
val time: Double,
|
|
||||||
val duration: Double,
|
|
||||||
val state: Int,
|
|
||||||
val speed: Double
|
|
||||||
) { }
|
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastVolumeUpdateMessage(
|
|
||||||
val generationTime: Long,
|
|
||||||
val volume: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSetVolumeMessage(
|
|
||||||
val volume: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSetSpeedMessage(
|
|
||||||
val speed: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlaybackErrorMessage(
|
|
||||||
val message: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastVersionMessage(
|
|
||||||
val version: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastKeyExchangeMessage(
|
|
||||||
val version: Long,
|
|
||||||
val publicKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastDecryptedMessage(
|
|
||||||
val opcode: Long,
|
|
||||||
val message: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastEncryptedMessage(
|
|
||||||
val version: Long,
|
|
||||||
val iv: String?,
|
|
||||||
val blob: String
|
|
||||||
)
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.futo.platformplayer.dialogs
|
package com.futo.platformplayer.dialogs
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
@@ -17,82 +21,79 @@ import com.google.android.material.button.MaterialButton
|
|||||||
|
|
||||||
|
|
||||||
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
||||||
private lateinit var _buttonStart: LinearLayout;
|
private lateinit var _buttonStart: LinearLayout
|
||||||
private lateinit var _buttonStop: LinearLayout;
|
private lateinit var _buttonStop: LinearLayout
|
||||||
private lateinit var _buttonCancel: ImageButton;
|
private lateinit var _buttonCancel: ImageButton
|
||||||
|
private lateinit var _imm: InputMethodManager
|
||||||
private lateinit var _editPassword: EditText;
|
|
||||||
private lateinit var _editPassword2: EditText;
|
|
||||||
|
|
||||||
private lateinit var _inputMethodManager: InputMethodManager;
|
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
|
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
|
||||||
|
|
||||||
_buttonCancel = findViewById(R.id.button_cancel);
|
_buttonCancel = findViewById(R.id.button_cancel)
|
||||||
_buttonStop = findViewById(R.id.button_stop);
|
_buttonStop = findViewById(R.id.button_stop)
|
||||||
_buttonStart = findViewById(R.id.button_start);
|
_buttonStart = findViewById(R.id.button_start)
|
||||||
_editPassword = findViewById(R.id.edit_password);
|
|
||||||
_editPassword2 = findViewById(R.id.edit_password2);
|
|
||||||
|
|
||||||
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
|
||||||
|
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
_buttonCancel.setOnClickListener {
|
_buttonCancel.setOnClickListener {
|
||||||
clearFocus();
|
dismiss()
|
||||||
dismiss();
|
}
|
||||||
};
|
|
||||||
_buttonStop.setOnClickListener {
|
|
||||||
clearFocus();
|
|
||||||
dismiss();
|
|
||||||
Settings.instance.backup.autoBackupPassword = null;
|
|
||||||
Settings.instance.backup.didAskAutoBackup = true;
|
|
||||||
Settings.instance.save();
|
|
||||||
|
|
||||||
UIDialogs.toast(context, "AutoBackup disabled");
|
_buttonStop.setOnClickListener {
|
||||||
|
dismiss()
|
||||||
|
Settings.instance.backup.autoBackupEnabled = false
|
||||||
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
|
Settings.instance.save()
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonStart.setOnClickListener {
|
_buttonStart.setOnClickListener {
|
||||||
val p1 = _editPassword.text.toString();
|
dismiss()
|
||||||
val p2 = _editPassword2.text.toString();
|
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
|
||||||
if(!(p1?.equals(p2) ?: false)) {
|
|
||||||
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
|
val activity = StateApp.instance.activity as? Activity
|
||||||
return@setOnClickListener;
|
if (activity == null) {
|
||||||
|
UIDialogs.toast(context, "No activity available")
|
||||||
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val pbytes = _editPassword.text.toString().toByteArray();
|
dismiss()
|
||||||
if(pbytes.size < 4 || pbytes.size > 32) {
|
|
||||||
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
|
|
||||||
return@setOnClickListener;
|
|
||||||
}
|
|
||||||
clearFocus();
|
|
||||||
dismiss();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Set AutoBackupPassword");
|
Logger.i(TAG, "Enable AutoBackup")
|
||||||
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
Settings.instance.backup.didAskAutoBackup = true;
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
Settings.instance.save();
|
Settings.instance.save()
|
||||||
|
|
||||||
UIDialogs.toast(context, "AutoBackup enabled");
|
UIDialogs.toast(context, "AutoBackup enabled")
|
||||||
try {
|
try {
|
||||||
StateBackup.startAutomaticBackup(true);
|
StateBackup.startAutomaticBackup(true)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Forced automatic backup failed", ex)
|
||||||
|
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Forced automatic backup failed", ex);
|
Settings.instance.backup.autoBackupEnabled = true
|
||||||
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
|
Settings.instance.save()
|
||||||
|
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
|
||||||
|
try {
|
||||||
|
StateBackup.startAutomaticBackup(true)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Forced automatic backup failed", ex)
|
||||||
|
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearFocus() {
|
|
||||||
_editPassword.clearFocus();
|
|
||||||
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutomaticBackupDialog";
|
private const val TAG = "AutomaticBackupDialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,87 +3,155 @@ package com.futo.platformplayer.dialogs
|
|||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.InputType
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.*
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
|
||||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.dp
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.selectBestImage
|
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.states.StateBackup
|
import com.futo.platformplayer.states.StateBackup
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
|
||||||
import com.futo.polycentric.core.*
|
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import userpackage.Protocol
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
|
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
|
||||||
|
|
||||||
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
|
private lateinit var _buttonStart: LinearLayout
|
||||||
private lateinit var _buttonStart: LinearLayout;
|
private lateinit var _buttonCancel: MaterialButton
|
||||||
private lateinit var _buttonCancel: MaterialButton;
|
private lateinit var _textReason: TextView
|
||||||
|
private lateinit var _editPassword: EditText
|
||||||
private lateinit var _editPassword: EditText;
|
private lateinit var _passwordContainer: LinearLayout
|
||||||
|
private lateinit var _icon: ImageView
|
||||||
private lateinit var _inputMethodManager: InputMethodManager;
|
private lateinit var _progress: ProgressBar
|
||||||
|
private lateinit var _textStart: TextView
|
||||||
|
private lateinit var _imm: InputMethodManager
|
||||||
|
|
||||||
|
private var _needsPassword: Boolean = true
|
||||||
|
private var _detectJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
|
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
|
||||||
|
|
||||||
_buttonCancel = findViewById(R.id.button_cancel);
|
_buttonCancel = findViewById(R.id.button_cancel)
|
||||||
_buttonStart = findViewById(R.id.button_start);
|
_buttonStart = findViewById(R.id.button_start)
|
||||||
_editPassword = findViewById(R.id.edit_password);
|
_editPassword = findViewById(R.id.edit_password)
|
||||||
|
_textReason = findViewById(R.id.text_reason)
|
||||||
|
_passwordContainer = findViewById(R.id.password_container)
|
||||||
|
_icon = findViewById(R.id.image_icon)
|
||||||
|
_progress = findViewById(R.id.progress_restore)
|
||||||
|
_textStart = findViewById(R.id.text_start)
|
||||||
|
|
||||||
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
|
||||||
|
_needsPassword = true
|
||||||
|
applyMode(needsPassword = true)
|
||||||
|
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
|
||||||
|
|
||||||
_buttonCancel.setOnClickListener {
|
_buttonCancel.setOnClickListener {
|
||||||
clearFocus();
|
clearFocus()
|
||||||
dismiss();
|
dismiss()
|
||||||
};
|
}
|
||||||
|
_buttonStart.setOnClickListener { onStartClicked() }
|
||||||
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||||
|
}
|
||||||
|
|
||||||
_buttonStart.setOnClickListener {
|
override fun onStart() {
|
||||||
val pbytes = _editPassword.text.toString().toByteArray();
|
super.onStart()
|
||||||
if(pbytes.size < 4 || pbytes.size > 32) {
|
|
||||||
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
|
_detectJob?.cancel()
|
||||||
return@setOnClickListener;
|
_detectJob = scope.launch(Dispatchers.Main) {
|
||||||
|
val needs = try {
|
||||||
|
StateBackup.requiresPasswordForAutomaticBackup(context)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
clearFocus();
|
|
||||||
|
|
||||||
|
if (!isShowing) return@launch
|
||||||
|
_needsPassword = needs
|
||||||
|
applyMode(needsPassword = needs)
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
_detectJob?.cancel()
|
||||||
|
_detectJob = null
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMode(needsPassword: Boolean) {
|
||||||
|
_textStart.setText(R.string.restore)
|
||||||
|
if (needsPassword) {
|
||||||
|
_icon.setImageResource(R.drawable.ic_lock)
|
||||||
|
_passwordContainer.visibility = View.VISIBLE
|
||||||
|
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
|
||||||
|
} else {
|
||||||
|
_icon.setImageResource(R.drawable.ic_move_up)
|
||||||
|
_passwordContainer.visibility = View.GONE
|
||||||
|
_editPassword.setText("")
|
||||||
|
_textReason.setText(R.string.automatic_backup_found_no_password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStartClicked() {
|
||||||
|
val password = _editPassword.text?.toString() ?: ""
|
||||||
|
|
||||||
|
if (_needsPassword) {
|
||||||
|
val pbytes = password.toByteArray()
|
||||||
|
if (pbytes.size < 4 || pbytes.size > 32) {
|
||||||
|
_editPassword.error = context.getString(R.string.backup_password_length_error)
|
||||||
|
_editPassword.requestFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFocus()
|
||||||
|
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
|
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
|
||||||
dismiss();
|
withContext(Dispatchers.Main) {
|
||||||
|
if (isShowing) dismiss()
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to restore automatic backup", ex)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (!isShowing) return@withContext
|
||||||
|
setBusy(false)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
}
|
||||||
Logger.e(TAG, "Failed to restore automatic backup", ex);
|
}
|
||||||
//UIDialogs.toast(context, "Restore failed due to:\n" + ex.message);
|
|
||||||
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
|
private fun setBusy(busy: Boolean, labelRes: Int = R.string.restore, lockCancel: Boolean = busy) {
|
||||||
|
_progress.visibility = if (busy) View.VISIBLE else View.GONE
|
||||||
|
_buttonCancel.isEnabled = !lockCancel
|
||||||
|
_buttonStart.isEnabled = !busy
|
||||||
|
_editPassword.isEnabled = !busy && _needsPassword
|
||||||
|
_buttonStart.alpha = if (busy) 0.6f else 1.0f
|
||||||
|
_textStart.setText(labelRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearFocus() {
|
private fun clearFocus() {
|
||||||
_editPassword.clearFocus();
|
_editPassword.clearFocus()
|
||||||
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
|
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutomaticRestoreDialog";
|
private const val TAG = "AutomaticRestoreDialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||||
|
|
||||||
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||||
R.array.exp_casting_device_type_array
|
|
||||||
} else {
|
|
||||||
R.array.casting_device_type_array
|
|
||||||
}
|
|
||||||
|
|
||||||
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
|
||||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||||
_spinnerType.adapter = adapter;
|
_spinnerType.adapter = adapter;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
import com.futo.platformplayer.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_textType.text = "AirPlay";
|
_textType.text = "AirPlay";
|
||||||
}
|
}
|
||||||
CastProtocolType.FCAST -> {
|
CastProtocolType.FCAST -> {
|
||||||
_imageDevice.setImageResource(
|
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||||
if (Settings.instance.casting.experimentalCasting) {
|
|
||||||
R.drawable.ic_exp_fc
|
|
||||||
} else {
|
|
||||||
R.drawable.ic_fc
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_textType.text = "FCast";
|
_textType.text = "FCast";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
|||||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
||||||
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
|
|||||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||||
import com.futo.platformplayer.engine.internal.V8Converter
|
import com.futo.platformplayer.engine.internal.V8Converter
|
||||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||||
|
import com.futo.platformplayer.engine.packages.PackageBrowser
|
||||||
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
||||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||||
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
||||||
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
|
|||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateAssets
|
import com.futo.platformplayer.states.StateAssets
|
||||||
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
import com.futo.platformplayer.toList
|
import com.futo.platformplayer.toList
|
||||||
import com.futo.platformplayer.toV8ValueBlocking
|
import com.futo.platformplayer.toV8ValueBlocking
|
||||||
import com.futo.platformplayer.toV8ValueAsync
|
import com.futo.platformplayer.toV8ValueAsync
|
||||||
@@ -83,6 +87,7 @@ class V8Plugin {
|
|||||||
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
|
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
|
||||||
private val _depsPackages : MutableList<V8Package> = mutableListOf();
|
private val _depsPackages : MutableList<V8Package> = mutableListOf();
|
||||||
private var _script : String? = null;
|
private var _script : String? = null;
|
||||||
|
val bridge: PackageBridge;
|
||||||
|
|
||||||
var isStopped = true;
|
var isStopped = true;
|
||||||
val onStopped = Event1<V8Plugin>();
|
val onStopped = Event1<V8Plugin>();
|
||||||
@@ -110,7 +115,8 @@ class V8Plugin {
|
|||||||
this._clientAuth = clientAuth;
|
this._clientAuth = clientAuth;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this._script = script;
|
this._script = script;
|
||||||
withDependency(PackageBridge(this, config));
|
bridge = PackageBridge(this, config);
|
||||||
|
withDependency(bridge);
|
||||||
|
|
||||||
for(pack in config.packages)
|
for(pack in config.packages)
|
||||||
withDependency(getPackage(pack)!!);
|
withDependency(getPackage(pack)!!);
|
||||||
@@ -218,6 +224,9 @@ class V8Plugin {
|
|||||||
if(pack is PackageHttp) {
|
if(pack is PackageHttp) {
|
||||||
pack.cleanup();
|
pack.cleanup();
|
||||||
}
|
}
|
||||||
|
else if(pack is PackageBrowser) {
|
||||||
|
pack.deinitialize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_runtime?.let {
|
_runtime?.let {
|
||||||
@@ -387,6 +396,18 @@ class V8Plugin {
|
|||||||
"HttpImp" -> PackageHttpImp(this, config)
|
"HttpImp" -> PackageHttpImp(this, config)
|
||||||
"Utilities" -> PackageUtilities(this, config)
|
"Utilities" -> PackageUtilities(this, config)
|
||||||
"JSDOM" -> PackageJSDOM(this, config)
|
"JSDOM" -> PackageJSDOM(this, config)
|
||||||
|
"Browser" -> {
|
||||||
|
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
|
||||||
|
|
||||||
|
if(BuildConfig.DEBUG)
|
||||||
|
PackageBrowser(this)
|
||||||
|
else if(isOfficial)
|
||||||
|
PackageBrowser(this)
|
||||||
|
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
|
||||||
|
PackageBrowser(this)
|
||||||
|
else
|
||||||
|
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
|
||||||
|
};
|
||||||
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
@@ -36,6 +37,9 @@ class PackageBridge : V8Package {
|
|||||||
private val _client: ManagedHttpClient
|
private val _client: ManagedHttpClient
|
||||||
@Transient
|
@Transient
|
||||||
private val _clientAuth: ManagedHttpClient
|
private val _clientAuth: ManagedHttpClient
|
||||||
|
// Set by JSClient after construction to provide access to auth/captcha data
|
||||||
|
@Transient
|
||||||
|
var descriptor: SourcePluginDescriptor? = null
|
||||||
|
|
||||||
|
|
||||||
override val name: String get() = "Bridge";
|
override val name: String get() = "Bridge";
|
||||||
@@ -80,6 +84,17 @@ class PackageBridge : V8Package {
|
|||||||
return "android";
|
return "android";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User agent captured during captcha/auth WebView flows, matching Desktop's bridge.captchaUserAgent/bridge.authUserAgent.
|
||||||
|
// Plugins use these to make HTTP requests with the same UA that was used in the WebView.
|
||||||
|
@V8Property
|
||||||
|
fun captchaUserAgent(): String? {
|
||||||
|
return descriptor?.getCaptchaData()?.userAgent
|
||||||
|
}
|
||||||
|
@V8Property
|
||||||
|
fun authUserAgent(): String? {
|
||||||
|
return descriptor?.getAuth()?.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
@V8Property
|
@V8Property
|
||||||
fun supportedFeatures(): Array<String> {
|
fun supportedFeatures(): Array<String> {
|
||||||
return arrayOf(
|
return arrayOf(
|
||||||
@@ -105,6 +120,11 @@ class PackageBridge : V8Package {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun hasPackage(str: String): Boolean {
|
||||||
|
return _plugin.getPackages().any { it.name == str };
|
||||||
|
}
|
||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun dispose(value: V8Value) {
|
fun dispose(value: V8Value) {
|
||||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
||||||
|
|||||||
@@ -0,0 +1,537 @@
|
|||||||
|
package com.futo.platformplayer.engine.packages
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.ValueCallback
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.webkit.ScriptHandler
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import com.caoccao.javet.annotations.V8Function
|
||||||
|
import com.caoccao.javet.utils.JavetResourceUtils
|
||||||
|
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
class PackageBrowser: V8Package {
|
||||||
|
val useAddDocumentStartJavaScript = true
|
||||||
|
|
||||||
|
override val name: String get() = "Browser";
|
||||||
|
override val variableName: String = "browser";
|
||||||
|
|
||||||
|
@Volatile private var _loadToken: String? = null
|
||||||
|
@Volatile private var _expectedMainUrl: String? = null
|
||||||
|
|
||||||
|
private val _json = Json { };
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private var _readySemaphore: Semaphore? = null;
|
||||||
|
@Transient
|
||||||
|
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
|
||||||
|
@Transient
|
||||||
|
private var _browser: WebView? = null;
|
||||||
|
private val browser: WebView get() {
|
||||||
|
if(_browser == null)
|
||||||
|
throw IllegalStateException("Browser not initialized");
|
||||||
|
return _browser!!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var _userAgent: String = ""
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
||||||
|
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun initialize() {
|
||||||
|
if (_browser != null) return
|
||||||
|
|
||||||
|
onMainBlocking {
|
||||||
|
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
|
||||||
|
_userAgent = _browser?.settings?.userAgentString.orEmpty()
|
||||||
|
_browser?.settings?.javaScriptEnabled = true;
|
||||||
|
_browser?.settings?.blockNetworkImage = false;
|
||||||
|
_browser?.settings?.blockNetworkLoads = false;
|
||||||
|
_browser?.settings?.allowContentAccess = false;
|
||||||
|
_browser?.settings?.allowFileAccess = false;
|
||||||
|
//_browser?.settings?.useWideViewPort = true;
|
||||||
|
//_browser?.settings?.loadWithOverviewMode = true;
|
||||||
|
_browser?.webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||||
|
if (view == null || request == null) return null
|
||||||
|
|
||||||
|
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
|
||||||
|
if (!request.isForMainFrame) return null
|
||||||
|
if (!request.method.equals("GET", ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val url = request.url?.toString() ?: return null
|
||||||
|
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
|
||||||
|
val scheme = request.url?.scheme ?: return null
|
||||||
|
if (scheme != "http" && scheme != "https") return null
|
||||||
|
|
||||||
|
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||||
|
if (scripts.isEmpty()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
|
||||||
|
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
|
||||||
|
|
||||||
|
val okReq = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("User-Agent", ua)
|
||||||
|
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
|
||||||
|
.build()
|
||||||
|
|
||||||
|
http.newCall(okReq).execute().use { resp ->
|
||||||
|
val code = resp.code
|
||||||
|
val reason = resp.message.ifBlank { "OK" }
|
||||||
|
if (code in 300..399) return null
|
||||||
|
|
||||||
|
val contentType = resp.header("Content-Type") ?: ""
|
||||||
|
val isHtml =
|
||||||
|
contentType.startsWith("text/html", ignoreCase = true) ||
|
||||||
|
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
|
||||||
|
|
||||||
|
if (!isHtml) return null
|
||||||
|
|
||||||
|
val bodyBytes = resp.body.bytes()
|
||||||
|
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
|
||||||
|
val html = bodyBytes.toString(charset)
|
||||||
|
|
||||||
|
val cspHeader = resp.header("Content-Security-Policy")
|
||||||
|
?: resp.header("Content-Security-Policy-Report-Only")
|
||||||
|
|
||||||
|
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
|
||||||
|
|
||||||
|
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
|
||||||
|
val outBytes = injected.toByteArray(charset)
|
||||||
|
val headers = resp.headers.toMultimap()
|
||||||
|
.mapValues { it.value.joinToString(",") }
|
||||||
|
.toMutableMap()
|
||||||
|
|
||||||
|
headers.remove("Content-Length")
|
||||||
|
val cookieMgr = CookieManager.getInstance()
|
||||||
|
resp.headers.values("Set-Cookie").forEach { sc ->
|
||||||
|
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
try { cookieMgr.flush() } catch (_: Throwable) {}
|
||||||
|
|
||||||
|
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||||
|
super.onPageCommitVisible(view, url)
|
||||||
|
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
|
||||||
|
releaseReadyIfCurrent(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
|
||||||
|
releaseReadyIfCurrent(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_browser?.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||||
|
val raw = consoleMessage?.message().orEmpty()
|
||||||
|
|
||||||
|
val normalized = raw.trim().let { s ->
|
||||||
|
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
|
||||||
|
s.substring(1, s.length - 1)
|
||||||
|
} else s
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
|
||||||
|
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
|
||||||
|
if (handleConsoleBridgeMessage(payload)) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||||
|
val emsg = "Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
|
||||||
|
Logger.e("PackageBrowser", emsg)
|
||||||
|
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||||
|
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", emsg)
|
||||||
|
} else {
|
||||||
|
val imsg = "Browser Log:${consoleMessage?.message()}"
|
||||||
|
Logger.i("PackageBrowser", imsg)
|
||||||
|
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||||
|
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", imsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onConsoleMessage(consoleMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bootstrap = """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (window.__GJ) return;
|
||||||
|
|
||||||
|
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
|
||||||
|
const emit = (obj) => {
|
||||||
|
try {
|
||||||
|
console.info(PREFIX + JSON.stringify(obj));
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "__GJ", {
|
||||||
|
value: {
|
||||||
|
callback: (id, result) => {
|
||||||
|
try {
|
||||||
|
const r = (typeof result === "string")
|
||||||
|
? result
|
||||||
|
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
|
||||||
|
emit({ t: "cb", id: String(id), result: r });
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
log: (msg) => {
|
||||||
|
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enumerable: false,
|
||||||
|
configurable: false,
|
||||||
|
writable: false
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
addScriptOnLoad(bootstrap)
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun deinitialize() {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
_browser?.destroy();
|
||||||
|
}
|
||||||
|
_browser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun getCurrentUrl(): String? {
|
||||||
|
return browser.url;
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun waitTillLoaded(timeout: Int = 1000): Boolean {
|
||||||
|
val acquired = _readySemaphore?.let {
|
||||||
|
if(!it.tryAcquire()) {
|
||||||
|
Logger.i("PackageBrowser", "Waiting for browser to be ready");
|
||||||
|
if(!runBlocking {
|
||||||
|
try {
|
||||||
|
return@runBlocking withTimeout(timeout.toLong(), {
|
||||||
|
it.acquire()
|
||||||
|
return@withTimeout true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch(ex: TimeoutCancellationException) {
|
||||||
|
return@runBlocking false;
|
||||||
|
}
|
||||||
|
}) return@let false;
|
||||||
|
}
|
||||||
|
it.release();
|
||||||
|
return@let true;
|
||||||
|
} ?: true;
|
||||||
|
if(acquired)
|
||||||
|
Logger.i("PackageBrowser", "Browser is ready");
|
||||||
|
else
|
||||||
|
Logger.i("PackageBrowser", "Browser failed wait ready");
|
||||||
|
return acquired;
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun load(url: String) {
|
||||||
|
Logger.i("PackageBrowser", "Browser loading url [$url]")
|
||||||
|
val token = UUID.randomUUID().toString()
|
||||||
|
_loadToken = token
|
||||||
|
_expectedMainUrl = url
|
||||||
|
_readySemaphore = Semaphore(1, acquiredPermits = 1)
|
||||||
|
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
try { browser.loadUrl(url) }
|
||||||
|
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseReadyIfCurrent(url: String?) {
|
||||||
|
if (url == null) return
|
||||||
|
val expected = _expectedMainUrl
|
||||||
|
if (url.trimEnd('/') != expected?.trimEnd('/')) return
|
||||||
|
|
||||||
|
_readySemaphore?.release()
|
||||||
|
_readySemaphore = null
|
||||||
|
_expectedMainUrl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
||||||
|
waitTillLoaded();
|
||||||
|
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||||
|
if(callbackId != null && callback != null) {
|
||||||
|
synchronized(_callbacks) {
|
||||||
|
_callbacks.put(callbackId, {
|
||||||
|
_plugin.busy {
|
||||||
|
funcClone?.callVoid(null, arrayOf(it));
|
||||||
|
}
|
||||||
|
if (!_plugin.isStopped)
|
||||||
|
JavetResourceUtils.safeClose(funcClone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||||
|
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||||
|
override fun onReceiveValue(value: String?) {
|
||||||
|
Logger.i("PackageBrowser", "Browser run finished");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Browser running failed: " + ex.message, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@V8Function
|
||||||
|
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
|
||||||
|
waitTillLoaded();
|
||||||
|
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||||
|
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||||
|
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||||
|
override fun onReceiveValue(value: String?) {
|
||||||
|
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
|
||||||
|
try {
|
||||||
|
_plugin.busy {
|
||||||
|
if (value != null) {
|
||||||
|
val json = _json.decodeFromString<String>(value);
|
||||||
|
funcClone?.callVoid(null, arrayOf(json));
|
||||||
|
} else
|
||||||
|
funcClone?.callVoid(null, arrayOf((null as String?)));
|
||||||
|
}
|
||||||
|
if (!_plugin.isStopped)
|
||||||
|
JavetResourceUtils.safeClose(funcClone);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@V8Function
|
||||||
|
fun addScriptOnLoad(js: String): String {
|
||||||
|
require(js.isNotBlank()) { "Script must be non-empty." }
|
||||||
|
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
onMainBlocking {
|
||||||
|
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||||
|
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
|
||||||
|
_pageLoadScriptRefs[id] = ref
|
||||||
|
} else {
|
||||||
|
_pageLoadScriptsFallback[id] = js
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RequiresFeature")
|
||||||
|
@V8Function
|
||||||
|
fun removeScriptOnLoad(identifier: String): Boolean {
|
||||||
|
if (identifier.isBlank()) return false
|
||||||
|
|
||||||
|
val ref = _pageLoadScriptRefs.remove(identifier)
|
||||||
|
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
|
||||||
|
|
||||||
|
if (ref != null) {
|
||||||
|
onMainBlocking {
|
||||||
|
try { ref.remove() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedFallback) {
|
||||||
|
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RequiresFeature")
|
||||||
|
@V8Function
|
||||||
|
fun clearScriptsOnLoad() {
|
||||||
|
val refs = _pageLoadScriptRefs.values.toList()
|
||||||
|
_pageLoadScriptRefs.clear()
|
||||||
|
_pageLoadScriptsFallback.clear()
|
||||||
|
|
||||||
|
onMainBlocking {
|
||||||
|
for (r in refs) {
|
||||||
|
try { r.remove() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun charsetFromContentType(ct: String): Charset? {
|
||||||
|
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
|
||||||
|
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
|
||||||
|
return runCatching { Charset.forName(name) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
|
||||||
|
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
|
||||||
|
val tag = "<script$nonceAttr>\n$js\n</script>\n"
|
||||||
|
|
||||||
|
val head = Regex("(?i)<head[^>]*>").find(html)
|
||||||
|
if (head != null) {
|
||||||
|
val i = head.range.last + 1
|
||||||
|
return buildString(html.length + tag.length + 8) {
|
||||||
|
append(html, 0, i)
|
||||||
|
append('\n')
|
||||||
|
append(tag)
|
||||||
|
append(html, i, html.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tag + html
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> onMainBlocking(block: () -> T): T {
|
||||||
|
return if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
block()
|
||||||
|
} else runBlocking {
|
||||||
|
withContext(Dispatchers.Main) { block() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNonceFromCsp(csp: String?): String? {
|
||||||
|
if (csp.isNullOrBlank()) return null
|
||||||
|
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
|
||||||
|
return m.groupValues[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNonceFromHtml(html: String): String? {
|
||||||
|
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
|
||||||
|
return m?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escapeHtmlAttr(s: String): String =
|
||||||
|
s.replace("&", "&").replace("\"", """)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ConsoleBridgeMsg(
|
||||||
|
val t: String,
|
||||||
|
val id: String? = null,
|
||||||
|
val result: String? = null,
|
||||||
|
val msg: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private fun handleConsoleBridgeMessage(payload: String): Boolean {
|
||||||
|
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
|
||||||
|
|
||||||
|
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
when (parsed.t) {
|
||||||
|
"cb" -> {
|
||||||
|
val id = parsed.id ?: return true
|
||||||
|
val res = parsed.result
|
||||||
|
|
||||||
|
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { cb.invoke(res) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
"log" -> {
|
||||||
|
val text = parsed.msg.orEmpty()
|
||||||
|
Logger.i("PackageBrowser", "Browser Log: $text")
|
||||||
|
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
|
||||||
|
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else -> return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
|
||||||
|
|
||||||
|
private fun String.quoteForJs(): String {
|
||||||
|
val s = this
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
return "\"$s\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.AdapterView
|
import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
|
|||||||
override val isTab: Boolean = false;
|
override val isTab: Boolean = false;
|
||||||
override val hasBottomBar: Boolean get() = true;
|
override val hasBottomBar: Boolean get() = true;
|
||||||
|
|
||||||
|
private var _root: LinearLayout? = null;
|
||||||
private var _webview: WebView? = null;
|
private var _webview: WebView? = null;
|
||||||
private val _webviewWithoutHandling = object: WebViewClient() {
|
private val _webviewWithoutHandling = object: WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
|
|||||||
|
|
||||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
||||||
|
_root = view.findViewById<LinearLayout>(R.id.root);
|
||||||
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
||||||
this.webViewClient = _webviewWithoutHandling;
|
this.webViewClient = _webviewWithoutHandling;
|
||||||
this.settings.javaScriptEnabled = true;
|
this.settings.javaScriptEnabled = true;
|
||||||
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
|
|||||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
super.onShownWithView(parameter, isBack)
|
super.onShownWithView(parameter, isBack)
|
||||||
|
|
||||||
if(parameter is String) {
|
if(parameter is WebView) {
|
||||||
|
_root?.removeView(_webview);
|
||||||
|
_root?.addView(parameter);
|
||||||
|
_webview = parameter;
|
||||||
|
}
|
||||||
|
else if(parameter is String) {
|
||||||
_webview?.webViewClient = _webviewWithoutHandling;
|
_webview?.webViewClient = _webviewWithoutHandling;
|
||||||
_webview?.loadUrl(parameter);
|
_webview?.loadUrl(parameter);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-14
@@ -1,8 +1,6 @@
|
|||||||
package com.futo.platformplayer.fragment.mainactivity.main
|
package com.futo.platformplayer.fragment.mainactivity.main
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
|
|||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StatePayment
|
import com.futo.platformplayer.states.StatePayment
|
||||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||||
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
|
|||||||
|
|
||||||
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
|
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
|
||||||
if(success) {
|
if(success) {
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
|
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
|
||||||
_fragment.close(true);
|
UIDialogs.Action("Ok", {
|
||||||
|
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
|
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
|
||||||
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
|
|||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
//Calling this function will cache first call
|
//Calling this function will cache first call
|
||||||
try {
|
try {
|
||||||
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
|
// TODO: Restore multi-currency support when payment backend supports it
|
||||||
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
|
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
|
||||||
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
|
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
|
||||||
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
|
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
|
||||||
|
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
|
||||||
|
// if(currency != null && prices.containsKey(currency.id)) {
|
||||||
|
// val price = prices[currency.id]!!;
|
||||||
|
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
|
||||||
|
// }
|
||||||
|
|
||||||
if(currency != null && prices.containsKey(currency.id)) {
|
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
|
||||||
val price = prices[currency.id]!!;
|
withContext(Dispatchers.Main) {
|
||||||
withContext(Dispatchers.Main) {
|
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
|
||||||
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
|
|||||||
fun newInstance() = BuyFragment().apply {}
|
fun newInstance() = BuyFragment().apply {}
|
||||||
private val TAG = "BuyFragment"
|
private val TAG = "BuyFragment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
private val _progressBar: ProgressBar;
|
private val _progressBar: ProgressBar;
|
||||||
private val _spinnerSortBy: Spinner;
|
private val _spinnerSortBy: Spinner;
|
||||||
private val _containerSortBy: LinearLayout;
|
private val _containerSortBy: LinearLayout;
|
||||||
private val _announcementView: AnnouncementView;
|
//private val _announcementView: AnnouncementView;
|
||||||
private val _tagsView: TagsView;
|
private val _tagsView: TagsView;
|
||||||
private val _textCentered: TextView;
|
private val _textCentered: TextView;
|
||||||
private val _emptyPagerContainer: FrameLayout;
|
private val _emptyPagerContainer: FrameLayout;
|
||||||
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
_textCentered = findViewById(R.id.text_centered);
|
_textCentered = findViewById(R.id.text_centered);
|
||||||
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
||||||
_progressBar = findViewById(R.id.progress_bar);
|
_progressBar = findViewById(R.id.progress_bar);
|
||||||
_announcementView = findViewById(R.id.announcement_view)
|
//_announcementView = findViewById(R.id.announcement_view)
|
||||||
_progressBar.inactiveColor = Color.TRANSPARENT;
|
_progressBar.inactiveColor = Color.TRANSPARENT;
|
||||||
|
|
||||||
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
||||||
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun showAnnouncementView() {
|
protected fun showAnnouncementView() {
|
||||||
_announcementView.visibility = View.VISIBLE
|
//_announcementView.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||||
|
|||||||
+2
-2
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if(this.allowMusic) {
|
if(this.allowMusic) {
|
||||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
|
||||||
adapterArtists.setData(artists);
|
adapterArtists.setData(artists);
|
||||||
if (artists.size == 0)
|
if (artists.size == 0)
|
||||||
sectionArtists.setEmpty(
|
sectionArtists.setEmpty(
|
||||||
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(this.allowMusic) {
|
if(this.allowMusic) {
|
||||||
val albums = StateLibrary.instance.getAlbums();
|
val albums = StateLibrary.instance.getAlbums()
|
||||||
adapterAlbums.setData(albums);
|
adapterAlbums.setData(albums);
|
||||||
if (albums.size == 0)
|
if (albums.size == 0)
|
||||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||||
|
|||||||
+6
-2
@@ -96,11 +96,15 @@ class LoginFragment : MainFragment() {
|
|||||||
else throw IllegalStateException("No valid configuration?");
|
else throw IllegalStateException("No valid configuration?");
|
||||||
//TODO: Backwards compat removal?
|
//TODO: Backwards compat removal?
|
||||||
|
|
||||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (authConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = authConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
|
||||||
|
|
||||||
webViewClient.onLogin.subscribe { auth ->
|
webViewClient.onLogin.subscribe { auth ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
|
|||||||
+13
-1
@@ -309,13 +309,14 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
||||||
logoutSource();
|
logoutSource();
|
||||||
},
|
},
|
||||||
|
if(!Settings.instance.plugins.shouldClearWebviewCookies())
|
||||||
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
||||||
logoutSource(false);
|
logoutSource(false);
|
||||||
}.apply {
|
}.apply {
|
||||||
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
||||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||||
};
|
};
|
||||||
}
|
} else null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -518,6 +519,17 @@ class SourceDetailFragment : MainFragment() {
|
|||||||
}
|
}
|
||||||
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
|
Logger.e(TAG, "Failed to set plugin authentication (loginSource, loginWarning)", e)
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
|
||||||
|
try {
|
||||||
|
val cookieManager: CookieManager =
|
||||||
|
CookieManager.getInstance();
|
||||||
|
cookieManager.removeAllCookies(null);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to clear cookies", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
}, UIDialogs.ActionStyle.PRIMARY))
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-20
@@ -33,6 +33,7 @@ import android.widget.ImageButton
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.text.toLowerCase
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -215,6 +216,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
private val _playerProgress: PlayerControlView;
|
private val _playerProgress: PlayerControlView;
|
||||||
private val _timeBar: TimeBar;
|
private val _timeBar: TimeBar;
|
||||||
private var _upNext: UpNextView;
|
private var _upNext: UpNextView;
|
||||||
|
private var _artworkTarget: CustomTarget<Bitmap>? = null
|
||||||
|
|
||||||
private val rootView: ConstraintLayout;
|
private val rootView: ConstraintLayout;
|
||||||
|
|
||||||
@@ -881,6 +883,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onClose.subscribe {
|
onClose.subscribe {
|
||||||
|
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||||
|
_artworkTarget = null
|
||||||
|
_player.setArtwork(null)
|
||||||
checkAndRemoveWatchLater();
|
checkAndRemoveWatchLater();
|
||||||
_lastVideoSource = null;
|
_lastVideoSource = null;
|
||||||
_lastAudioSource = null;
|
_lastAudioSource = null;
|
||||||
@@ -1194,6 +1199,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else if(_didStop) {
|
else if(_didStop) {
|
||||||
_didStop = false;
|
_didStop = false;
|
||||||
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
|
Logger.i(TAG, "loadCurrentVideo _lastPosition=${lastPositionMilliseconds}");
|
||||||
|
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||||
loadCurrentVideo(lastPositionMilliseconds);
|
loadCurrentVideo(lastPositionMilliseconds);
|
||||||
handlePause();
|
handlePause();
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1269,9 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
fun onDestroy() {
|
fun onDestroy() {
|
||||||
Logger.i(TAG, "onDestroy");
|
Logger.i(TAG, "onDestroy");
|
||||||
_destroyed = true;
|
_destroyed = true;
|
||||||
|
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||||
|
_artworkTarget = null
|
||||||
|
_player.setArtwork(null)
|
||||||
_taskLoadVideo.cancel();
|
_taskLoadVideo.cancel();
|
||||||
_commentsList.cancel();
|
_commentsList.cancel();
|
||||||
_player.clear();
|
_player.clear();
|
||||||
@@ -2052,19 +2061,31 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.switchToVideoMode()
|
_player.switchToVideoMode()
|
||||||
isAudioOnlyUserAction = false;
|
isAudioOnlyUserAction = false;
|
||||||
} else {
|
} else {
|
||||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
_artworkTarget?.let { Glide.with(context).clear(it) }
|
||||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
_artworkTarget = null
|
||||||
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
|
||||||
.into(object: CustomTarget<Bitmap>() {
|
val thumbnail = video.thumbnails.getHQThumbnail()
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
val showArtwork = _player.isAudioMode || isAudioOnlyUserAction || (videoSource == null)
|
||||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
|
||||||
}
|
if (showArtwork && !thumbnail.isNullOrBlank()) {
|
||||||
override fun onLoadCleared(placeholder: Drawable?) {
|
val target = object : CustomTarget<Bitmap>() {
|
||||||
_player.setArtwork(null);
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
}
|
_player.setArtwork(BitmapDrawable(resources, resource))
|
||||||
});
|
}
|
||||||
else
|
override fun onLoadCleared(placeholder: Drawable?) {
|
||||||
_player.setArtwork(null);
|
_player.setArtwork(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_artworkTarget = target
|
||||||
|
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(thumbnail)
|
||||||
|
.withMaxSizePx()
|
||||||
|
.into(target)
|
||||||
|
} else {
|
||||||
|
_player.setArtwork(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
@@ -2423,7 +2444,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
val doDedup = Settings.instance.playback.simplifySources;
|
val doDedup = Settings.instance.playback.simplifySources;
|
||||||
|
|
||||||
val allLanguages = videoSources?.map { it.language } ?: listOf();
|
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||||
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||||
lang -> videoSources
|
lang -> videoSources
|
||||||
.filter { v -> v.language == lang }
|
.filter { v -> v.language == lang }
|
||||||
@@ -2432,6 +2453,43 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
.map { res -> Pair(res, lang) }
|
.map { res -> Pair(res, lang) }
|
||||||
} else listOf();
|
} else listOf();
|
||||||
|
|
||||||
|
|
||||||
|
Log.i(TAG, "Language count: ${allLanguages}");
|
||||||
|
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||||
|
var selectedLanguage: String? = null;
|
||||||
|
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||||
|
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
|
||||||
|
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||||
|
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||||
|
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||||
|
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||||
|
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||||
|
|
||||||
|
if(english != null)
|
||||||
|
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||||
|
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||||
|
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||||
|
if(originalLanguage != null)
|
||||||
|
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||||
|
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||||
|
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||||
|
setButtons(languageFilterLabels, selectedLanguage);
|
||||||
|
onClick.subscribe { selected ->
|
||||||
|
setSelected(selected);
|
||||||
|
|
||||||
|
videoSourceItems.forEach {
|
||||||
|
val item = it.itemTag;
|
||||||
|
if(item is IVideoSource) {
|
||||||
|
if(item.language == selected)
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
else
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else null;
|
||||||
|
|
||||||
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
|
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
|
||||||
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||||
@@ -2449,7 +2507,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
||||||
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
||||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||||
R.string.quality), null, true,
|
R.string.quality), null, false,
|
||||||
qualityPlaybackSpeedTitle,
|
qualityPlaybackSpeedTitle,
|
||||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||||
@@ -2539,11 +2597,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
call = { _player.selectAudioTrack(it.bitrate) });
|
call = { _player.selectAudioTrack(it.bitrate) });
|
||||||
}.toList().toTypedArray())
|
}.toList().toTypedArray())
|
||||||
else null,
|
else null,
|
||||||
|
if(languageFilters != null) languageFilters else null,
|
||||||
if(bestVideoSources.isNotEmpty())
|
if(bestVideoSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||||
*bestVideoSources
|
(bestVideoSources.map {
|
||||||
.map {
|
|
||||||
val estSize = VideoHelper.estimateSourceSize(it);
|
val estSize = VideoHelper.estimateSourceSize(it);
|
||||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
SlideUpMenuItem(this.context,
|
SlideUpMenuItem(this.context,
|
||||||
@@ -2552,8 +2609,14 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||||
(prefix + it.codec.trim()).trim(),
|
(prefix + it.codec.trim()).trim(),
|
||||||
tag = it,
|
tag = it,
|
||||||
call = { handleSelectVideoTrack(it) });
|
call = { handleSelectVideoTrack(it) }).apply {
|
||||||
}.toList().toTypedArray())
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}).toList())
|
||||||
else null,
|
else null,
|
||||||
if(bestAudioSources.isNotEmpty())
|
if(bestAudioSources.isNotEmpty())
|
||||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||||
|
|||||||
+52
@@ -7,6 +7,9 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||||
@@ -17,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
||||||
import com.futo.platformplayer.models.SearchType
|
import com.futo.platformplayer.models.SearchType
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.views.casting.CastButton
|
import com.futo.platformplayer.views.casting.CastButton
|
||||||
|
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class GeneralTopBarFragment : TopFragment() {
|
class GeneralTopBarFragment : TopFragment() {
|
||||||
private var _buttonSearch: ImageButton? = null;
|
private var _buttonSearch: ImageButton? = null;
|
||||||
private var _buttonCast: CastButton? = null;
|
private var _buttonCast: CastButton? = null;
|
||||||
|
|
||||||
|
private var _buttonNotifs: ConstraintLayout? = null;
|
||||||
|
private var _buttonNotifIcon: ImageView? = null;
|
||||||
|
private var _buttonNotifCount: TextView? = null;
|
||||||
|
|
||||||
|
init {
|
||||||
|
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
|
||||||
|
lifecycleScope?.launch(Dispatchers.Main) {
|
||||||
|
updateNotifCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotifCount() {
|
||||||
|
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||||
|
if(currentAnnouncements.any())
|
||||||
|
_buttonNotifCount?.let {
|
||||||
|
it.text = currentAnnouncements.size.toString();
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_buttonNotifCount?.let {
|
||||||
|
it.text = currentAnnouncements.size.toString();
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onShown(parameter: Any?) {
|
override fun onShown(parameter: Any?) {
|
||||||
if(currentMain is CreatorsFragment) {
|
if(currentMain is CreatorsFragment) {
|
||||||
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
||||||
} else {
|
} else {
|
||||||
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
||||||
}
|
}
|
||||||
|
if(currentMain is NotificationOverlayView.Frag) {
|
||||||
|
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun onHide() {
|
override fun onHide() {
|
||||||
|
|
||||||
@@ -44,6 +83,19 @@ class GeneralTopBarFragment : TopFragment() {
|
|||||||
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
||||||
_buttonCast = view.findViewById(R.id.button_cast);
|
_buttonCast = view.findViewById(R.id.button_cast);
|
||||||
|
|
||||||
|
_buttonNotifs = view.findViewById(R.id.button_notifs);
|
||||||
|
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
|
||||||
|
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
|
||||||
|
|
||||||
|
updateNotifCount();
|
||||||
|
|
||||||
|
_buttonNotifs?.setOnClickListener {
|
||||||
|
if(currentMain is NotificationOverlayView.Frag)
|
||||||
|
closeSegment();
|
||||||
|
else
|
||||||
|
navigate<NotificationOverlayView.Frag>();
|
||||||
|
}
|
||||||
|
|
||||||
buttonSearch.setOnClickListener {
|
buttonSearch.setOnClickListener {
|
||||||
if(currentMain is CreatorsFragment) {
|
if(currentMain is CreatorsFragment) {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ class CaptchaWebViewClient : WebViewClient {
|
|||||||
|
|
||||||
private val _pluginConfig: SourcePluginConfig?;
|
private val _pluginConfig: SourcePluginConfig?;
|
||||||
private val _captchaConfig: SourcePluginCaptchaConfig;
|
private val _captchaConfig: SourcePluginCaptchaConfig;
|
||||||
|
private val _userAgent: String?;
|
||||||
|
|
||||||
private var _didNotify = false;
|
private var _didNotify = false;
|
||||||
private val _extractor: WebViewRequirementExtractor;
|
private val _extractor: WebViewRequirementExtractor;
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig) : super() {
|
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
|
||||||
_pluginConfig = config;
|
_pluginConfig = config;
|
||||||
_captchaConfig = config.captcha!!;
|
_captchaConfig = config.captcha!!;
|
||||||
|
_userAgent = userAgent;
|
||||||
_extractor = WebViewRequirementExtractor(
|
_extractor = WebViewRequirementExtractor(
|
||||||
config.allowUrls,
|
config.allowUrls,
|
||||||
null,
|
null,
|
||||||
@@ -34,9 +36,10 @@ class CaptchaWebViewClient : WebViewClient {
|
|||||||
Logger.i(TAG, "Captcha [${config.name}]" +
|
Logger.i(TAG, "Captcha [${config.name}]" +
|
||||||
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
|
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
|
||||||
}
|
}
|
||||||
constructor(captcha: SourcePluginCaptchaConfig) : super() {
|
constructor(captcha: SourcePluginCaptchaConfig, userAgent: String? = null) : super() {
|
||||||
_pluginConfig = null;
|
_pluginConfig = null;
|
||||||
_captchaConfig = captcha;
|
_captchaConfig = captcha;
|
||||||
|
_userAgent = userAgent;
|
||||||
_extractor = WebViewRequirementExtractor(
|
_extractor = WebViewRequirementExtractor(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -62,7 +65,8 @@ class CaptchaWebViewClient : WebViewClient {
|
|||||||
_didNotify = true;
|
_didNotify = true;
|
||||||
onCaptchaFinished.emit(SourceCaptchaData(
|
onCaptchaFinished.emit(SourceCaptchaData(
|
||||||
extracted.cookies,
|
extracted.cookies,
|
||||||
extracted.headers
|
extracted.headers,
|
||||||
|
_userAgent
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,23 +24,26 @@ class LoginWebViewClient : WebViewClient {
|
|||||||
|
|
||||||
private val _pluginConfig: SourcePluginConfig?;
|
private val _pluginConfig: SourcePluginConfig?;
|
||||||
private val _authConfig: SourcePluginAuthConfig;
|
private val _authConfig: SourcePluginAuthConfig;
|
||||||
|
private val _userAgent: String?;
|
||||||
|
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
|
|
||||||
val onLogin = Event1<SourceAuth>();
|
val onLogin = Event1<SourceAuth>();
|
||||||
val onPageLoaded = Event2<WebView?, String?>()
|
val onPageLoaded = Event2<WebView?, String?>()
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig) : super() {
|
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
|
||||||
_pluginConfig = config;
|
_pluginConfig = config;
|
||||||
_authConfig = config.authentication!!;
|
_authConfig = config.authentication!!;
|
||||||
|
_userAgent = userAgent;
|
||||||
Logger.i(TAG, "Login [${config.name}]" +
|
Logger.i(TAG, "Login [${config.name}]" +
|
||||||
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
|
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
|
||||||
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
|
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
|
||||||
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
|
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
|
||||||
}
|
}
|
||||||
constructor(auth: SourcePluginAuthConfig) : super() {
|
constructor(auth: SourcePluginAuthConfig, userAgent: String? = null) : super() {
|
||||||
_pluginConfig = null;
|
_pluginConfig = null;
|
||||||
_authConfig = auth;
|
_authConfig = auth;
|
||||||
|
_userAgent = userAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
|
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
|
||||||
@@ -192,13 +195,14 @@ class LoginWebViewClient : WebViewClient {
|
|||||||
if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
|
if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
|
||||||
onLogin.emit(SourceAuth(
|
onLogin.emit(SourceAuth(
|
||||||
cookieMap = cookiesFoundMap,
|
cookieMap = cookiesFoundMap,
|
||||||
headers = headersFoundMap /*.associate { headerToFind ->
|
headers = headersFoundMap, /*.associate { headerToFind ->
|
||||||
headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
|
headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
|
||||||
if (requestHeader.key.equals(headerToFind, ignoreCase = true))
|
if (requestHeader.key.equals(headerToFind, ignoreCase = true))
|
||||||
requestHeader.value
|
requestHeader.value
|
||||||
else null;
|
else null;
|
||||||
}
|
}
|
||||||
} ?: mapOf()*/
|
} ?: mapOf()*/
|
||||||
|
userAgent = _userAgent
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -456,26 +456,19 @@ class MediaPlaybackService : Service() {
|
|||||||
|
|
||||||
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
|
val audioFocusLossDuration = _audioFocusLossTime_ms?.let { System.currentTimeMillis() - it }
|
||||||
_audioFocusLossTime_ms = null
|
_audioFocusLossTime_ms = null
|
||||||
|
|
||||||
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
|
Log.i(TAG, "Audio focus gained (restartPlaybackAfterLoss = ${Settings.instance.playback.restartPlaybackAfterLoss}, _audioFocusLossTime_ms = $_audioFocusLossTime_ms, audioFocusLossDuration = ${audioFocusLossDuration})");
|
||||||
|
|
||||||
if (Settings.instance.playback.restartPlaybackAfterLoss == 1) {
|
if (audioFocusLossDuration == null) return@OnAudioFocusChangeListener
|
||||||
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 10) {
|
when (Settings.instance.playback.restartPlaybackAfterLoss) {
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
1 -> if (audioFocusLossDuration < 10_000) MediaControlReceiver.onPlayReceived.emit()
|
||||||
}
|
2 -> if (audioFocusLossDuration < 30_000) MediaControlReceiver.onPlayReceived.emit()
|
||||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 2) {
|
3 -> MediaControlReceiver.onPlayReceived.emit()
|
||||||
if (audioFocusLossDuration != null && audioFocusLossDuration < 1000 * 30) {
|
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
|
||||||
}
|
|
||||||
} else if (Settings.instance.playback.restartPlaybackAfterLoss == 3) {
|
|
||||||
MediaControlReceiver.onPlayReceived.emit()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
_audioFocusLossTime_ms = if (isPlaying) {
|
val wasPlaying = isPlaying
|
||||||
System.currentTimeMillis()
|
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
_hasFocus = false;
|
_hasFocus = false;
|
||||||
_isTransientLoss = true;
|
_isTransientLoss = true;
|
||||||
@@ -488,11 +481,8 @@ class MediaPlaybackService : Service() {
|
|||||||
_isTransientLoss = true;
|
_isTransientLoss = true;
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
_audioFocusLossTime_ms = if (isPlaying) {
|
val wasPlaying = isPlaying
|
||||||
System.currentTimeMillis()
|
_audioFocusLossTime_ms = if (wasPlaying) System.currentTimeMillis() else null
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaControlReceiver.onPauseReceived.emit();
|
MediaControlReceiver.onPauseReceived.emit();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
package com.futo.platformplayer.states
|
package com.futo.platformplayer.states
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.constructs.Event2
|
||||||
|
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringHashSetStorage
|
import com.futo.platformplayer.stores.StringHashSetStorage
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -110,6 +119,48 @@ class StateAnnouncement {
|
|||||||
onAnnouncementChanged.emit();
|
onAnnouncementChanged.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Special Announcements
|
||||||
|
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
|
||||||
|
val announcement = SessionAnnouncement(
|
||||||
|
"update-plugin-" + UUID.randomUUID().toString(),
|
||||||
|
"${newConfig.name} update v${newConfig.version} available!",
|
||||||
|
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
|
||||||
|
AnnouncementType.SESSION,
|
||||||
|
null, "updates", "Update", StateAnnouncement.ACTION_UPDATE_PLUGIN,
|
||||||
|
null, null,oldConfig.id,
|
||||||
|
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||||
|
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
|
||||||
|
registerAnnouncementSession(announcement);
|
||||||
|
return announcement;
|
||||||
|
}
|
||||||
|
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
|
||||||
|
registerAnnouncementSession(SessionAnnouncement(
|
||||||
|
"updated-plugin-" + UUID.randomUUID().toString(),
|
||||||
|
"${newConfig.name} updated to v${newConfig.version}!",
|
||||||
|
"You have succesfully been updated to v${newConfig.version}.",
|
||||||
|
AnnouncementType.SESSION,
|
||||||
|
null, "updates", null, null,
|
||||||
|
null, null,null,
|
||||||
|
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||||
|
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
|
||||||
|
val id = "loading-" + UUID.randomUUID().toString();
|
||||||
|
val announcement = SessionAnnouncement(
|
||||||
|
customId ?: id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
AnnouncementType.ONGOING,
|
||||||
|
null, "loading", null, null,
|
||||||
|
null, null,null, icon
|
||||||
|
);
|
||||||
|
registerAnnouncementSession(announcement);
|
||||||
|
return announcement;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
|
fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
|
||||||
synchronized(_lock) {
|
synchronized(_lock) {
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
@@ -122,7 +173,9 @@ class StateAnnouncement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeAnnouncement(id: String) {
|
fun closeAnnouncement(id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
val item: Announcement?;
|
val item: Announcement?;
|
||||||
synchronized(_lock) {
|
synchronized(_lock) {
|
||||||
item = _announcementsStore.findItem { it.id == id };
|
item = _announcementsStore.findItem { it.id == id };
|
||||||
@@ -164,6 +217,7 @@ class StateAnnouncement {
|
|||||||
cancelAction?.invoke(item);
|
cancelAction?.invoke(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onAnnouncementChanged?.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAllAnnouncements() {
|
fun deleteAllAnnouncements() {
|
||||||
@@ -194,7 +248,9 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
onAnnouncementChanged.emit();
|
onAnnouncementChanged.emit();
|
||||||
}
|
}
|
||||||
fun neverAnnouncement(id: String) {
|
fun neverAnnouncement(id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
synchronized(_lock) {
|
synchronized(_lock) {
|
||||||
val item = _announcementsStore.findItem { it.id == id };
|
val item = _announcementsStore.findItem { it.id == id };
|
||||||
if (item != null && !_announcementsNever.contains(id))
|
if (item != null && !_announcementsNever.contains(id))
|
||||||
@@ -208,19 +264,26 @@ class StateAnnouncement {
|
|||||||
_announcementsNever.save();
|
_announcementsNever.save();
|
||||||
onAnnouncementChanged.emit();
|
onAnnouncementChanged.emit();
|
||||||
}
|
}
|
||||||
fun actionAnnouncement(id: String) {
|
fun actionAnnouncement(id: String?, extra: Boolean = false) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
|
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
|
||||||
if(item != null)
|
if(item != null)
|
||||||
actionAnnouncement(item);
|
actionAnnouncement(item, extra);
|
||||||
}
|
}
|
||||||
fun actionAnnouncement(item: Announcement) {
|
fun actionAnnouncement(item: Announcement, extra: Boolean = false) {
|
||||||
|
val actionId = if(!extra) item.actionId else if(item is SessionAnnouncement) item.extraActionId else null;
|
||||||
|
val actionData = if(!extra) item.actionData else if(item is SessionAnnouncement) item.extraActionData else null;
|
||||||
|
|
||||||
val action = _sessionActions[item.id];
|
val action = _sessionActions[item.id];
|
||||||
if (action != null) {
|
if (action != null) {
|
||||||
action(item);
|
action(item);
|
||||||
} else {
|
} else {
|
||||||
when (item.actionId) {
|
when (actionId) {
|
||||||
ACTION_NEVER -> neverAnnouncement(item.id);
|
ACTION_NEVER -> neverAnnouncement(item.id);
|
||||||
ACTION_SOMETHING -> actionSomething();
|
ACTION_SOMETHING -> actionSomething();
|
||||||
|
ACTION_CHANGELOG -> actionChangelog(actionData);
|
||||||
|
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +314,84 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun actionChangelog(id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
StateApp.instance.contextOrNull?.let { context ->
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
val plugin = StatePlugins.instance.getPlugin(id);
|
||||||
|
if (plugin == null)
|
||||||
|
return@launch
|
||||||
|
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||||
|
if(update == null)
|
||||||
|
return@launch;
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
|
||||||
|
.mapKeys { it.key.toInt() }
|
||||||
|
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun actionUpdatePlugin(notifId: String?, id: String?) {
|
||||||
|
if(id == null)
|
||||||
|
return;
|
||||||
|
val plugin = StatePlugins.instance.getPlugin(id);
|
||||||
|
if (plugin == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
closeAnnouncement(notifId);
|
||||||
|
val loadingAnnouncement = registerLoading("Updating ${plugin.config.name}..", "An update is in progress for ${plugin.config.name}.",
|
||||||
|
if(plugin.config.absoluteIconUrl != null) ImageVariable.fromUrl(plugin.config.absoluteIconUrl!!) else null);
|
||||||
|
|
||||||
|
val loadingId = loadingAnnouncement.id;
|
||||||
|
|
||||||
|
StateApp.instance.contextOrNull?.let { context ->
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||||
|
if (update == null)
|
||||||
|
return@launch;
|
||||||
|
|
||||||
|
val client = ManagedHttpClient();
|
||||||
|
client.setTimeout(10000);
|
||||||
|
val script = StatePlugins.instance.getScript(plugin.config.id) ?: "";
|
||||||
|
val newScript = client.get(update.absoluteScriptUrl)?.body?.string();
|
||||||
|
if(newScript.isNullOrEmpty())
|
||||||
|
throw IllegalStateException("No script found");
|
||||||
|
|
||||||
|
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
|
||||||
|
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
|
||||||
|
{ text: String, progress: Double -> },
|
||||||
|
{ ex ->
|
||||||
|
if(ex == null) {
|
||||||
|
registerPluginUpdated(update);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UIDialogs.appToast("Update for ${update.name} failed\n" + ex.message);
|
||||||
|
}
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
closeAnnouncement(loadingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
closeAnnouncement(loadingId);
|
||||||
|
UIDialogs.showPluginUpdateDialog(context, plugin.config, update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to trigger update from announcement", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun registerDefaultHandlerAnnouncement() {
|
fun registerDefaultHandlerAnnouncement() {
|
||||||
registerAnnouncement(
|
registerAnnouncement(
|
||||||
"default-url-handler",
|
"default-url-handler",
|
||||||
@@ -279,6 +420,8 @@ class StateAnnouncement {
|
|||||||
|
|
||||||
|
|
||||||
const val ACTION_SOMETHING = "SOMETHING";
|
const val ACTION_SOMETHING = "SOMETHING";
|
||||||
|
const val ACTION_CHANGELOG = "CHANGELOG";
|
||||||
|
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
|
||||||
const val ACTION_NEVER = "NEVER";
|
const val ACTION_NEVER = "NEVER";
|
||||||
private const val TAG = "StateAnnouncement";
|
private const val TAG = "StateAnnouncement";
|
||||||
}
|
}
|
||||||
@@ -294,7 +437,8 @@ open class Announcement(
|
|||||||
val time: OffsetDateTime? = null,
|
val time: OffsetDateTime? = null,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
val actionName: String? = null,
|
val actionName: String? = null,
|
||||||
val actionId: String? = null
|
val actionId: String? = null,
|
||||||
|
val actionData: String? = null
|
||||||
);
|
);
|
||||||
class SessionAnnouncement(
|
class SessionAnnouncement(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -306,7 +450,9 @@ class SessionAnnouncement(
|
|||||||
actionName: String? = null,
|
actionName: String? = null,
|
||||||
actionId: String? = null,
|
actionId: String? = null,
|
||||||
val cancelName: String? = null,
|
val cancelName: String? = null,
|
||||||
val cancelActionId: String? = null
|
val cancelActionId: String? = null,
|
||||||
|
actionData: String? = null,
|
||||||
|
val icon: ImageVariable? = null
|
||||||
): Announcement(
|
): Announcement(
|
||||||
id= id,
|
id= id,
|
||||||
title = title,
|
title = title,
|
||||||
@@ -315,13 +461,40 @@ class SessionAnnouncement(
|
|||||||
time = time,
|
time = time,
|
||||||
category = category,
|
category = category,
|
||||||
actionName = actionName,
|
actionName = actionName,
|
||||||
actionId = actionId
|
actionId = actionId,
|
||||||
);
|
actionData = actionData
|
||||||
|
) {
|
||||||
|
var extraActionName: String? = null;
|
||||||
|
var extraActionId: String? = null;
|
||||||
|
var extraActionData: String? = null;
|
||||||
|
|
||||||
|
var extraObj: Any? = null;
|
||||||
|
|
||||||
|
var progress: Double? = null;
|
||||||
|
val onProgressChanged = Event1<SessionAnnouncement>();
|
||||||
|
|
||||||
|
fun withExtraAction(name: String, id: String, data: String? = null): SessionAnnouncement {
|
||||||
|
extraActionName = name;
|
||||||
|
extraActionId = id;
|
||||||
|
extraActionData = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setProgress(progress: Double) {
|
||||||
|
this.progress = progress;
|
||||||
|
onProgressChanged?.emit(this);
|
||||||
|
}
|
||||||
|
fun setProgress(progress: Int) {
|
||||||
|
this.progress = progress.toDouble().div(100);
|
||||||
|
onProgressChanged?.emit(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class AnnouncementType(val value : Int) {
|
enum class AnnouncementType(val value : Int) {
|
||||||
DELETABLE(0), //Close button deletes announcement (generally for actions)
|
DELETABLE(0), //Close button deletes announcement (generally for actions)
|
||||||
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
|
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
|
||||||
PERMANENT(2), //Shows up until deleted through other means (action)
|
PERMANENT(2), //Shows up until deleted through other means (action)
|
||||||
SESSION(3), //Not persistent, only during this session
|
SESSION(3), //Not persistent, only during this session
|
||||||
SESSION_RECURRING(4); //Not persistent, only during this session, recurring id
|
SESSION_RECURRING(4), //Not persistent, only during this session, recurring id
|
||||||
|
ONGOING(5);
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,12 @@ import android.net.Network
|
|||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.webkit.CookieManager
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -43,6 +46,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
|
|||||||
import com.futo.platformplayer.logging.FileLogConsumer
|
import com.futo.platformplayer.logging.FileLogConsumer
|
||||||
import com.futo.platformplayer.logging.LogLevel
|
import com.futo.platformplayer.logging.LogLevel
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
||||||
import com.futo.platformplayer.services.DownloadService
|
import com.futo.platformplayer.services.DownloadService
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
@@ -306,49 +310,45 @@ class StateApp {
|
|||||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
|
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
|
||||||
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
|
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
|
||||||
}
|
}
|
||||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
|
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?) -> Unit, skipDialog: Boolean = false) {
|
||||||
{
|
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
if(activity is Context)
|
Handler(Looper.getMainLooper()).post {
|
||||||
{
|
requestDirectoryAccess(activity, name, purpose, path, handle, skipDialog)
|
||||||
if(skipDialog) {
|
}
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
return
|
||||||
if(path != null)
|
}
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
|
||||||
|
if (activity is Context) {
|
||||||
|
if (skipDialog) {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||||
|
if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
|
||||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||||
|
|
||||||
activity.launchForResult(intent, 99) {
|
activity.launchForResult(intent, 99) {
|
||||||
if(it.resultCode == Activity.RESULT_OK) {
|
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
|
||||||
handle(it.data?.data);
|
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
|
||||||
}
|
}
|
||||||
else
|
} else {
|
||||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
||||||
UIDialogs.Action("Cancel", {}),
|
UIDialogs.Action("Cancel", {}),
|
||||||
UIDialogs.Action("Ok", {
|
UIDialogs.Action("Ok", {
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||||
if(path != null)
|
if (path != null) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path)
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
|
||||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||||
|
|
||||||
activity.launchForResult(intent, 99) {
|
activity.launchForResult(intent, 99) {
|
||||||
if(it.resultCode == Activity.RESULT_OK) {
|
if (it.resultCode == Activity.RESULT_OK) handle(it.data?.data)
|
||||||
handle(it.data?.data);
|
else UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted")
|
||||||
}
|
}
|
||||||
else
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
)
|
||||||
};
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +447,17 @@ class StateApp {
|
|||||||
_cacheDirectory?.let { ApiMethods.initCache(it) };
|
_cacheDirectory?.let { ApiMethods.initCache(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(Settings.instance.plugins.shouldClearWebviewCookies()) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Clearing cookies on startup");
|
||||||
|
val cookieManager: CookieManager =
|
||||||
|
CookieManager.getInstance();
|
||||||
|
cookieManager.removeAllCookies(null);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(SourceDetailFragment.Companion.TAG, "Failed to clear cookies", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
|
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
|
||||||
ModerationsManager.initialize(context);
|
ModerationsManager.initialize(context);
|
||||||
|
|
||||||
@@ -659,9 +670,7 @@ class StateApp {
|
|||||||
scheduleBackgroundWork(context, interval != 0, interval);
|
scheduleBackgroundWork(context, interval != 0, interval);
|
||||||
|
|
||||||
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
|
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
|
||||||
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
|
|
||||||
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
||||||
/*
|
|
||||||
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
||||||
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||||
UIDialogs.toast("Missing general directory");
|
UIDialogs.toast("Missing general directory");
|
||||||
@@ -678,7 +687,6 @@ class StateApp {
|
|||||||
Settings.instance.backup.didAskAutoBackup = true;
|
Settings.instance.backup.didAskAutoBackup = true;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
});
|
});
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||||
if(context is IWithResultLauncher) {
|
if(context is IWithResultLauncher) {
|
||||||
@@ -732,8 +740,10 @@ class StateApp {
|
|||||||
));
|
));
|
||||||
|
|
||||||
for(update in updateAvailable)
|
for(update in updateAvailable)
|
||||||
if(StatePlatform.instance.isClientEnabled(update.first.id))
|
if(StatePlatform.instance.isClientEnabled(update.first.id)) {
|
||||||
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
//UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
||||||
|
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ class StateBackup {
|
|||||||
|
|
||||||
private val _autoBackupLock = Object();
|
private val _autoBackupLock = Object();
|
||||||
|
|
||||||
|
private val AUTO_MAGIC = byteArrayOf(
|
||||||
|
0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x44.toByte()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val AUTO_MARKER = byteArrayOf(
|
||||||
|
'G'.code.toByte(), 'J'.code.toByte()
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val AUTO_FORMAT_VERSION: Byte = 1
|
||||||
|
private const val FLAG_ENCRYPTED: Byte = 0x01
|
||||||
|
|
||||||
|
private fun ByteArray.startsWithZipSignature(): Boolean =
|
||||||
|
this.size >= 2 && this[0] == 0x50.toByte() && this[1] == 0x4B.toByte()
|
||||||
|
|
||||||
|
private fun ByteArray.hasAutoMagic(): Boolean =
|
||||||
|
this.size >= 4 &&
|
||||||
|
this[0] == AUTO_MAGIC[0] &&
|
||||||
|
this[1] == AUTO_MAGIC[1] &&
|
||||||
|
this[2] == AUTO_MAGIC[2] &&
|
||||||
|
this[3] == AUTO_MAGIC[3]
|
||||||
|
|
||||||
|
private fun ByteArray.hasNewAutoHeader(): Boolean =
|
||||||
|
this.size >= 8 &&
|
||||||
|
this.hasAutoMagic() &&
|
||||||
|
this[4] == AUTO_MARKER[0] &&
|
||||||
|
this[5] == AUTO_MARKER[1]
|
||||||
|
|
||||||
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||||
return Pair(null, null);
|
return Pair(null, null);
|
||||||
@@ -76,14 +103,13 @@ class StateBackup {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requireLegacyBackupPassword(password: String): String {
|
||||||
private fun getAutomaticBackupPassword(customPassword: String? = null): String {
|
val pbytes = password.toByteArray()
|
||||||
val password = customPassword ?: Settings.instance.backup.autoBackupPassword ?: "";
|
if (pbytes.size < 4 || pbytes.size > 32)
|
||||||
val pbytes = password.toByteArray();
|
throw IllegalStateException("Password must be at least 4 bytes and smaller than 32 bytes")
|
||||||
if(pbytes.size < 4 || pbytes.size > 32)
|
return password
|
||||||
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
|
|
||||||
return password;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasAutomaticBackup(): Boolean {
|
fun hasAutomaticBackup(): Boolean {
|
||||||
val context = StateApp.instance.contextOrNull ?: return false;
|
val context = StateApp.instance.contextOrNull ?: return false;
|
||||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||||
@@ -106,8 +132,6 @@ class StateBackup {
|
|||||||
val data = export();
|
val data = export();
|
||||||
val zip = data.asZip();
|
val zip = data.asZip();
|
||||||
|
|
||||||
//Prepend some magic bytes to identify everything version 1 and up
|
|
||||||
val encryptedZip = byteArrayOf(0x11, 0x22, 0x33, 0x44, GPasswordEncryptionProvider.version.toByte()) + GPasswordEncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
|
|
||||||
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
||||||
@@ -118,7 +142,8 @@ class StateBackup {
|
|||||||
val exportFile = backupFiles.first;
|
val exportFile = backupFiles.first;
|
||||||
if (exportFile?.exists() == true && backupFiles.second != null)
|
if (exportFile?.exists() == true && backupFiles.second != null)
|
||||||
exportFile.copyTo(context, backupFiles.second!!);
|
exportFile.copyTo(context, backupFiles.second!!);
|
||||||
exportFile!!.writeBytes(context, encryptedZip);
|
val backupBytes = AUTO_MAGIC + AUTO_MARKER + byteArrayOf(AUTO_FORMAT_VERSION, 0x00.toByte()) + zip
|
||||||
|
exportFile!!.writeBytes(context, backupBytes)
|
||||||
|
|
||||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
@@ -137,69 +162,105 @@ class StateBackup {
|
|||||||
|
|
||||||
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
||||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
||||||
if(ifExists && !hasAutomaticBackup()) {
|
if (ifExists && !hasAutomaticBackup()) {
|
||||||
Logger.i(TAG, "No AutoBackup exists, not restoring");
|
Logger.i(TAG, "No AutoBackup exists, not restoring")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.i(TAG, "Starting AutoBackup restore");
|
Logger.i(TAG, "Starting AutoBackup restore")
|
||||||
synchronized(_autoBackupLock) {
|
var permissionRequest: Pair<IWithResultLauncher, android.net.Uri?>? = null
|
||||||
|
val backupBytesEncrypted: ByteArray? = synchronized(_autoBackupLock) {
|
||||||
|
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false)
|
||||||
|
|
||||||
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
|
fun read(doc: DocumentFile?): ByteArray? = doc?.readBytes(context)
|
||||||
try {
|
try {
|
||||||
if (backupFiles.first?.exists() != true)
|
if (backupFiles.first?.exists() != true)
|
||||||
throw IllegalStateException("Backup file does not exist");
|
throw IllegalStateException("Backup file does not exist")
|
||||||
|
|
||||||
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
|
read(backupFiles.first) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]")
|
||||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
|
} catch (ex: Throwable) {
|
||||||
Logger.i(TAG, "Finished AutoBackup restore");
|
if (ex is FileNotFoundException || ex is SecurityException) {
|
||||||
}
|
val activity = (StateApp.instance.activity as? IWithResultLauncher)
|
||||||
catch (exSec: FileNotFoundException) {
|
?: (if (StateApp.instance.isMainActive) StateApp.instance.contextOrNull as? IWithResultLauncher else null)
|
||||||
Logger.e(TAG, "Failed to access backup file", exSec);
|
|
||||||
val activity = if(StateApp.instance.activity != null)
|
if (activity != null) {
|
||||||
StateApp.instance.activity
|
permissionRequest = Pair(activity, backupFiles.first?.parentFile?.uri)
|
||||||
else if(StateApp.instance.isMainActive)
|
return@synchronized null
|
||||||
StateApp.instance.contextOrNull;
|
}
|
||||||
else null;
|
}
|
||||||
if(activity != null) {
|
|
||||||
if(activity is IWithResultLauncher)
|
// Otherwise, try the .old file
|
||||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
|
if (backupFiles.second?.exists() == true) {
|
||||||
if(it != null) {
|
read(backupFiles.second) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]")
|
||||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
|
} else {
|
||||||
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
|
throw ex
|
||||||
restoreAutomaticBackup(context, scope, password, ifExists);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed main AutoBackup restore", ex)
|
|
||||||
if (backupFiles.second?.exists() != true)
|
|
||||||
throw ex;
|
|
||||||
|
|
||||||
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
|
|
||||||
importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
|
|
||||||
Logger.i(TAG, "Finished AutoBackup restore");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (backupBytesEncrypted == null && permissionRequest != null) {
|
||||||
|
val (activity, initialUri) = permissionRequest
|
||||||
|
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", initialUri) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
restoreAutomaticBackup(context, scope, password, ifExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
importEncryptedZipBytes(context, scope, backupBytesEncrypted!!, password)
|
||||||
|
Logger.i(TAG, "Finished AutoBackup restore")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
|
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
|
||||||
val backupBytes: ByteArray;
|
if (backupBytesEncrypted.startsWithZipSignature()) {
|
||||||
//Check magic bytes indicating version 1 and up
|
importZipBytes(context, scope, backupBytesEncrypted)
|
||||||
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) {
|
return
|
||||||
val version = backupBytesEncrypted[4].toInt();
|
|
||||||
if (version != GPasswordEncryptionProvider.version) {
|
|
||||||
throw Exception("Invalid encryption version");
|
|
||||||
}
|
|
||||||
|
|
||||||
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
|
|
||||||
} else {
|
|
||||||
//Else its a version 0
|
|
||||||
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
importZipBytes(context, scope, backupBytes);
|
// New unencrypted header (magic + "GJ" + format + flags)
|
||||||
|
if (backupBytesEncrypted.hasNewAutoHeader()) {
|
||||||
|
val formatVersion = backupBytesEncrypted[6].toInt()
|
||||||
|
val flags = backupBytesEncrypted[7].toInt()
|
||||||
|
var offset = 8
|
||||||
|
|
||||||
|
if (formatVersion != AUTO_FORMAT_VERSION.toInt()) {
|
||||||
|
throw IllegalStateException("Unsupported backup format version: $formatVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
|
||||||
|
if (!isEncrypted) {
|
||||||
|
val zipBytes = backupBytesEncrypted.copyOfRange(offset, backupBytesEncrypted.size)
|
||||||
|
importZipBytes(context, scope, zipBytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IllegalStateException("Encrypted backups with new header are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old encrypted v1+ header (magic + providerVersion + ciphertext)
|
||||||
|
if (backupBytesEncrypted.hasAutoMagic()) {
|
||||||
|
if (backupBytesEncrypted.size < 6) {
|
||||||
|
throw IllegalStateException("Invalid backup: too small")
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = backupBytesEncrypted[4].toInt()
|
||||||
|
if (version != GPasswordEncryptionProvider.version) {
|
||||||
|
throw Exception("Invalid encryption version")
|
||||||
|
}
|
||||||
|
|
||||||
|
val ciphertext = backupBytesEncrypted.copyOfRange(5, backupBytesEncrypted.size)
|
||||||
|
val plaintextZip = GPasswordEncryptionProvider.instance.decrypt(ciphertext, requireLegacyBackupPassword(password))
|
||||||
|
|
||||||
|
importZipBytes(context, scope, plaintextZip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old encrypted v0 (no magic)
|
||||||
|
val plaintextZip = GPasswordEncryptionProviderV0(requireLegacyBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted)
|
||||||
|
importZipBytes(context, scope, plaintextZip)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveExternalBackup(activity: IWithResultLauncher) {
|
fun saveExternalBackup(activity: IWithResultLauncher) {
|
||||||
@@ -234,6 +295,47 @@ class StateBackup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun requiresPasswordForAutomaticBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val files = getAutomaticBackupDocumentFiles(context, create = false)
|
||||||
|
|
||||||
|
// Prefer main, fallback to .old
|
||||||
|
val doc = when {
|
||||||
|
files.first?.exists() == true -> files.first
|
||||||
|
files.second?.exists() == true -> files.second
|
||||||
|
else -> return@withContext true // if nothing exists, keep old behavior
|
||||||
|
} ?: return@withContext true
|
||||||
|
|
||||||
|
val header = try {
|
||||||
|
context.contentResolver.openInputStream(doc.uri)?.use { input ->
|
||||||
|
val buf = ByteArray(16)
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n <= 0) ByteArray(0) else buf.copyOf(n)
|
||||||
|
} ?: return@withContext true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw zip ("PK") => not encrypted
|
||||||
|
if (header.size >= 2 && header[0] == 0x50.toByte() && header[1] == 0x4B.toByte()) {
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
// New unencrypted header (magic + "GJ" + format + flags)
|
||||||
|
if (header.size >= 8 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3] && header[4] == AUTO_MARKER[0] && header[5] == AUTO_MARKER[1]) {
|
||||||
|
val flags = header[7].toInt()
|
||||||
|
val isEncrypted = (flags and FLAG_ENCRYPTED.toInt()) != 0
|
||||||
|
return@withContext isEncrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old encrypted v1+ header (magic + providerVersion + ciphertext) => needs password
|
||||||
|
if (header.size >= 5 && header[0] == AUTO_MAGIC[0] && header[1] == AUTO_MAGIC[1] && header[2] == AUTO_MAGIC[2] && header[3] == AUTO_MAGIC[3]) {
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise assume legacy v0 encrypted (no magic) => needs password
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
fun export(): ExportStructure {
|
fun export(): ExportStructure {
|
||||||
val exportInfo = mapOf(
|
val exportInfo = mapOf(
|
||||||
Pair("version", "1")
|
Pair("version", "1")
|
||||||
@@ -303,186 +405,172 @@ class StateBackup {
|
|||||||
var doEnablePlugins = false;
|
var doEnablePlugins = false;
|
||||||
var doImportStores = false;
|
var doImportStores = false;
|
||||||
Logger.i(TAG, "Starting import choices");
|
Logger.i(TAG, "Starting import choices");
|
||||||
UIDialogs.multiShowDialog(context, {
|
|
||||||
Logger.i(TAG, "Starting import");
|
|
||||||
if(!doImport)
|
|
||||||
return@multiShowDialog;
|
|
||||||
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
|
|
||||||
|
|
||||||
val onConclusion = {
|
scope.launch(Dispatchers.Main) {
|
||||||
|
UIDialogs.multiShowDialog(context, {
|
||||||
|
Logger.i(TAG, "Starting import");
|
||||||
|
if (!doImport)
|
||||||
|
return@multiShowDialog;
|
||||||
|
val enabledBefore = StatePlatform.instance.getEnabledClients().map { it.id };
|
||||||
|
|
||||||
|
val onConclusion = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp, "Import has finished", null, null,0, UIDialogs.Action("Ok", {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
//TODO: Probably restructure this to be less nested
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
StatePlatform.instance.selectClients(*enabledBefore.toTypedArray());
|
try {
|
||||||
|
if (doImportSettings && export.settings != null) {
|
||||||
withContext(Dispatchers.Main) {
|
Logger.i(TAG, "Importing settings");
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_update_success_251dp,
|
try {
|
||||||
"Import has finished", null, null, 0, UIDialogs.Action("Ok", {}));
|
Settings.replace(export.settings);
|
||||||
}
|
} catch (ex: Throwable) {
|
||||||
}
|
UIDialogs.toast(
|
||||||
};
|
context,
|
||||||
//TODO: Probably restructure this to be less nested
|
"Failed to import settings\n(" + ex.message + ")"
|
||||||
scope.launch(Dispatchers.IO) {
|
);
|
||||||
try {
|
|
||||||
if (doImportSettings && export.settings != null) {
|
|
||||||
Logger.i(TAG, "Importing settings");
|
|
||||||
try {
|
|
||||||
Settings.replace(export.settings);
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
UIDialogs.toast(context, "Failed to import settings\n(" + ex.message + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val afterPluginInstalls = {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
if (doEnablePlugins) {
|
|
||||||
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
|
|
||||||
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
|
|
||||||
|
|
||||||
Logger.i(TAG, "Import enabling plugins [${availableClients.map{it.name}.joinToString(", ")}]");
|
|
||||||
StatePlatform.instance.updateAvailableClients(context, false);
|
|
||||||
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
|
|
||||||
}
|
}
|
||||||
if(doImportPluginSettings) {
|
}
|
||||||
for(settings in export.pluginSettings) {
|
|
||||||
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
|
val afterPluginInstalls = {
|
||||||
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
|
scope.launch(Dispatchers.IO) {
|
||||||
|
if (doEnablePlugins) {
|
||||||
|
val availableClients = StatePlatform.instance.getEnabledClients().toMutableList();
|
||||||
|
availableClients.addAll(StatePlatform.instance.getAvailableClients().filter { !availableClients.contains(it) });
|
||||||
|
Logger.i(TAG, "Import enabling plugins [${availableClients.map { it.name }.joinToString(", ")}]");
|
||||||
|
StatePlatform.instance.updateAvailableClients(context, false);
|
||||||
|
StatePlatform.instance.selectClients(*availableClients.map { it.id }.toTypedArray());
|
||||||
}
|
}
|
||||||
}
|
if (doImportPluginSettings) {
|
||||||
val toAwait = export.stores.map { it.key }.toMutableList();
|
for (settings in export.pluginSettings) {
|
||||||
if(doImportStores) {
|
Logger.i(TAG, "Importing Plugin settings [${settings.key}]");
|
||||||
for(store in export.stores) {
|
StatePlugins.instance.setPluginSettings(settings.key, settings.value);
|
||||||
Logger.i(TAG, "Importing store [${store.key}]");
|
}
|
||||||
if(store.key == "history") {
|
}
|
||||||
withContext(Dispatchers.Main) {
|
val toAwait = export.stores.map { it.key }.toMutableList();
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
|
if (doImportStores) {
|
||||||
UIDialogs.Action("No", {
|
for (store in export.stores) {
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
Logger.i(TAG, "Importing store [${store.key}]");
|
||||||
UIDialogs.Action("Yes", {
|
if (store.key == "history") {
|
||||||
for(historyStr in store.value) {
|
withContext(Dispatchers.Main) {
|
||||||
try {
|
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import History", "Would you like to import history?", null, 0,
|
||||||
val histObj = HistoryVideo.fromReconString(historyStr) { url ->
|
UIDialogs.Action("No", {
|
||||||
return@fromReconString export.cache?.videos?.firstOrNull { it.url == url };
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Yes", {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
for (historyStr in store.value) {
|
||||||
|
try {
|
||||||
|
val histObj = HistoryVideo.fromReconString(historyStr) { url -> return@fromReconString export.cache?.videos?.firstOrNull { it.url == url }; }
|
||||||
|
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
||||||
|
if (hist != null)
|
||||||
|
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val hist = StateHistory.instance.getHistoryByVideo(histObj.video, true, histObj.date);
|
|
||||||
if(hist != null)
|
|
||||||
StateHistory.instance.updateHistoryPosition(histObj.video, hist, true, histObj.position, histObj.date, false, histObj.playlistId);
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
)
|
||||||
|
}
|
||||||
|
} else if (store.key == "subscription_groups") {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
|
||||||
|
UIDialogs.Action("No", {
|
||||||
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
|
UIDialogs.Action("Yes", {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
for (groupStr in store.value) {
|
||||||
|
try {
|
||||||
|
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
|
||||||
|
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
||||||
|
if (existing != null)
|
||||||
|
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
|
||||||
|
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to import subscription group", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val relevantStore = availableStores.find { it.name == store.key };
|
||||||
|
if (relevantStore == null) {
|
||||||
|
Logger.w(TAG, "Unknown store [${store.key}] import");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
|
||||||
|
synchronized(toAwait) {
|
||||||
|
toAwait.remove(store.key);
|
||||||
|
if (toAwait.isEmpty())
|
||||||
|
onConclusion();
|
||||||
}
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if(store.key == "subscription_groups") {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "Import Subscription Groups", "Would you like to import subscription groups?\nExisting groups with the same id will be overridden!", null, 0,
|
|
||||||
UIDialogs.Action("No", {
|
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
|
||||||
UIDialogs.Action("Yes", {
|
|
||||||
for(groupStr in store.value) {
|
|
||||||
try {
|
|
||||||
val group = Json.decodeFromString<SubscriptionGroup>(groupStr);
|
|
||||||
val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id);
|
|
||||||
if(existing != null)
|
|
||||||
StateSubscriptionGroups.instance.deleteSubscriptionGroup(existing.id, false);
|
|
||||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
|
||||||
}
|
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to import subscription group", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
val relevantStore = availableStores.find { it.name == store.key };
|
|
||||||
if (relevantStore == null) {
|
|
||||||
Logger.w(TAG, "Unknown store [${store.key}] import");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
UIDialogs.showImportDialog(context, relevantStore, store.key, store.value, export.cache) {
|
|
||||||
synchronized(toAwait) {
|
|
||||||
toAwait.remove(store.key);
|
|
||||||
if(toAwait.isEmpty())
|
|
||||||
onConclusion();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (doImportPlugins) {
|
if (doImportPlugins) {
|
||||||
Logger.i(TAG, "Importing plugins");
|
Logger.i(TAG, "Importing plugins");
|
||||||
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
|
StatePlugins.instance.installPlugins(context, scope, unknownPlugins.map { it.value }) {
|
||||||
|
afterPluginInstalls();
|
||||||
|
}
|
||||||
|
} else
|
||||||
afterPluginInstalls();
|
afterPluginInstalls();
|
||||||
}
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Import failed", ex);
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
afterPluginInstalls();
|
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
},
|
||||||
Logger.e(TAG, "Import failed", ex);
|
UIDialogs.Descriptor(R.drawable.ic_move_up, "Do you want to import data?", "Several dialogs will follow asking individual parts",
|
||||||
UIDialogs.showGeneralErrorDialog(context, "Import failed", ex);
|
"Settings: ${export.settings != null}\n" +
|
||||||
}
|
"Plugins: ${unknownPlugins.size}\n" +
|
||||||
}
|
"Plugin Settings: ${export.pluginSettings.size}\n" +
|
||||||
},
|
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim(),
|
||||||
UIDialogs.Descriptor(R.drawable.ic_move_up,
|
1,
|
||||||
"Do you want to import data?",
|
UIDialogs.Action("Import", {
|
||||||
"Several dialogs will follow asking individual parts",
|
doImport = true;
|
||||||
"Settings: ${export.settings != null}\n" +
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
"Plugins: ${unknownPlugins.size}\n" +
|
UIDialogs.Action("Cancel", { doImport = false })
|
||||||
"Plugin Settings: ${export.pluginSettings.size}\n" +
|
),
|
||||||
export.stores.map { "${it.key}: ${it.value.size}" }.joinToString("\n").trim()
|
if (export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings, "Would you like to import settings", "These are the settings that configure how your app works", null, 0,
|
||||||
, 1,
|
UIDialogs.Action("Yes", {
|
||||||
UIDialogs.Action("Import", {
|
doImportSettings = true;
|
||||||
doImport = true;
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { doImport = false})
|
).withCondition { doImport } else null,
|
||||||
),
|
if (unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugins?", "Your import contains the following plugins", unknownPlugins.map { it.value }.joinToString("\n"), 1,
|
||||||
if(export.settings != null) UIDialogs.Descriptor(R.drawable.ic_settings,
|
UIDialogs.Action("Yes", {
|
||||||
"Would you like to import settings",
|
doImportPlugins = true;
|
||||||
"These are the settings that configure how your app works",
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
null, 0,
|
).withCondition { doImport } else null,
|
||||||
UIDialogs.Action("Yes", {
|
if (export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to import plugin settings?", null, null, 1,
|
||||||
doImportSettings = true;
|
UIDialogs.Action("Yes", {
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
doImportPluginSettings = true;
|
||||||
).withCondition { doImport } else null,
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
if(unknownPlugins.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
|
).withCondition { doImport } else null,
|
||||||
"Would you like to import plugins?",
|
UIDialogs.Descriptor(R.drawable.ic_sources, "Would you like to enable all plugins?", "Enabling all plugins ensures all required plugins are available during import", null, 0,
|
||||||
"Your import contains the following plugins",
|
UIDialogs.Action("Yes", {
|
||||||
unknownPlugins.map { it.value }.joinToString("\n"), 1,
|
doEnablePlugins = true;
|
||||||
UIDialogs.Action("Yes", {
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
doImportPlugins = true;
|
).withCondition { doImport },
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
if (export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up, "Would you like to import stores", "Stores contain playlists, watch later, subscriptions, etc", null, 0,
|
||||||
).withCondition { doImport } else null,
|
UIDialogs.Action("Yes", {
|
||||||
if(export.pluginSettings.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_sources,
|
doImportStores = true;
|
||||||
"Would you like to import plugin settings?",
|
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
||||||
null, null, 1,
|
).withCondition { doImport } else null
|
||||||
UIDialogs.Action("Yes", {
|
);
|
||||||
doImportPluginSettings = true;
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
|
||||||
).withCondition { doImport } else null,
|
|
||||||
UIDialogs.Descriptor(R.drawable.ic_sources,
|
|
||||||
"Would you like to enable all plugins?",
|
|
||||||
"Enabling all plugins ensures all required plugins are available during import",
|
|
||||||
null, 0,
|
|
||||||
UIDialogs.Action("Yes", {
|
|
||||||
doEnablePlugins = true;
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
|
||||||
).withCondition { doImport },
|
|
||||||
if(export.stores.isNotEmpty()) UIDialogs.Descriptor(R.drawable.ic_move_up,
|
|
||||||
"Would you like to import stores",
|
|
||||||
"Stores contain playlists, watch later, subscriptions, etc",
|
|
||||||
null, 0,
|
|
||||||
UIDialogs.Action("Yes", {
|
|
||||||
doImportStores = true;
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("No", {})
|
|
||||||
).withCondition { doImport } else null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
|
fun importTxt(context: MainActivity, text: String, allowFailure: Boolean = false): Boolean {
|
||||||
|
|||||||
@@ -483,9 +483,9 @@ class StateDownloads {
|
|||||||
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
|
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
|
||||||
if(playlist != null) {
|
if(playlist != null) {
|
||||||
val missing = playlist.videos
|
val missing = playlist.videos
|
||||||
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
|
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
|
||||||
.map { getCachedVideo(it.id) }
|
.map { getCachedVideo(it.id) }
|
||||||
.filterNotNull();
|
.filterNotNull();
|
||||||
if(missing.size > 0)
|
if(missing.size > 0)
|
||||||
localVideos = localVideos + missing;
|
localVideos = localVideos + missing;
|
||||||
};
|
};
|
||||||
@@ -500,7 +500,6 @@ class StateDownloads {
|
|||||||
for (video in localVideos) {
|
for (video in localVideos) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
it.setText("Exporting videos...(${i}/${localVideos.size})");
|
it.setText("Exporting videos...(${i}/${localVideos.size})");
|
||||||
//it.setProgress(i.toDouble() / localVideos.size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQolMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2dJSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYevj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVrs5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8dQIDAQAB";
|
private val VERIFICATION_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJqqETLa42xw4AfbNOLQ" +
|
||||||
|
"olMdMiGgg8DAC4RXEcH4/gytLhaqp1XsjiiMkADi1C7sDtGj6kOuAuQkqXQKpZ2d" +
|
||||||
|
"JSZsO+GPyop6DmgfAM6MQgOgFUpwsb3Lt3SvskJcls8MeOC+jg+GjjcuJI8qOfYe" +
|
||||||
|
"vj4/7wAOpqzAwocTYnJivlK5nrC+qNtUC2HZX93OVu69aU5yvA1SQe9GiiU7vBld" +
|
||||||
|
"+CbzHxTcABCK/THu/BpLtGx0M7W3HNMKK1Z79dopCL9ZZWbWdkGDY8Zf39Gn/WVr" +
|
||||||
|
"s5elBvPzU+AfNYty77vx2r+sKgyohlbz4KVYpnw8HfawKcwuRE/GUyD3F2hUcXy8" +
|
||||||
|
"dQIDAQAB"
|
||||||
|
|
||||||
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
|
private val VERIFICATION_PUBLIC_KEY_TESTING = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqyDuxsRtD5gmBoLCNoZa" +
|
||||||
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
|
"XSRTwyUxgzcPHzLZkvomXVSQqzD+3aOKngcTKAZ83rm4GvoyMlBukxQMLShannSx" +
|
||||||
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
|
"k8GQGTCT7VStQKNc4lKVER5ASB6aEaypaFMIYI3rXN1xLF1LqY/j7cu5GgMsvAuU" +
|
||||||
@@ -34,4 +41,4 @@ class StatePayment : PaymentState(if(!isTestingPayment) VERIFICATION_PUBLIC_KEY
|
|||||||
return _instance!!;
|
return _instance!!;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class StatePlugins {
|
|||||||
_updatesAvailableMap = updatesAvailableFor
|
_updatesAvailableMap = updatesAvailableFor
|
||||||
return@withContext configs;
|
return@withContext configs;
|
||||||
}
|
}
|
||||||
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
||||||
val sourceUrl = c.sourceUrl ?: return@withContext null;
|
val sourceUrl = c.sourceUrl ?: return@withContext null;
|
||||||
|
|
||||||
Logger.i(TAG, "Check for source updates '${c.name}'.");
|
Logger.i(TAG, "Check for source updates '${c.name}'.");
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ class StateUpdate {
|
|||||||
if (!dir.exists()) {
|
if (!dir.exists()) {
|
||||||
dir.mkdirs();
|
dir.mkdirs();
|
||||||
}
|
}
|
||||||
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||||
|
//if(result.exists())
|
||||||
|
// result.delete();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPartialApkFile(context: Context, version: Int): File {
|
fun getPartialApkFile(context: Context, version: Int): File {
|
||||||
@@ -121,7 +124,10 @@ class StateUpdate {
|
|||||||
if (!dir.exists()) {
|
if (!dir.exists()) {
|
||||||
dir.mkdirs();
|
dir.mkdirs();
|
||||||
}
|
}
|
||||||
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||||
|
//if(result.exists())
|
||||||
|
// result.delete();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finish() {
|
fun finish() {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.casting.CastConnectionState
|
import com.futo.platformplayer.casting.CastConnectionState
|
||||||
import com.futo.platformplayer.casting.CastProtocolType
|
import com.futo.platformplayer.casting.CastProtocolType
|
||||||
@@ -91,13 +90,7 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
_textType.text = "AirPlay";
|
_textType.text = "AirPlay";
|
||||||
}
|
}
|
||||||
CastProtocolType.FCAST -> {
|
CastProtocolType.FCAST -> {
|
||||||
_imageDevice.setImageResource(
|
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||||
if (Settings.instance.casting.experimentalCasting) {
|
|
||||||
R.drawable.ic_exp_fc
|
|
||||||
} else {
|
|
||||||
R.drawable.ic_fc
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_textType.text = "FCast";
|
_textType.text = "FCast";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import android.view.View
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.dp
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.Announcement
|
import com.futo.platformplayer.states.Announcement
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
|
|||||||
_textClose.visibility = View.VISIBLE;
|
_textClose.visibility = View.VISIBLE;
|
||||||
_textNever.visibility = View.VISIBLE;
|
_textNever.visibility = View.VISIBLE;
|
||||||
}
|
}
|
||||||
|
AnnouncementType.ONGOING -> {
|
||||||
|
_textClose.visibility = View.GONE;
|
||||||
|
_textNever.visibility = View.GONE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (announcement.time != null) {
|
if (announcement.time != null) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class FieldForm : LinearLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSettingsVisibility(group: GroupField? = null) {
|
fun updateSettingsVisibility(group: GroupField? = null, allowEmptyGroups: Boolean = false) {
|
||||||
val settings = group?.getFields() ?: _fields;
|
val settings = group?.getFields() ?: _fields;
|
||||||
val query = _editSearch.text.toString().lowercase();
|
val query = _editSearch.text.toString().lowercase();
|
||||||
|
|
||||||
@@ -58,7 +58,8 @@ class FieldForm : LinearLayout {
|
|||||||
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
|
val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true;
|
||||||
for(field in settings) {
|
for(field in settings) {
|
||||||
if(field is GroupField) {
|
if(field is GroupField) {
|
||||||
updateSettingsVisibility(field);
|
if(!allowEmptyGroups)
|
||||||
|
updateSettingsVisibility(field);
|
||||||
} else if(field is View && field.descriptor != null) {
|
} else if(field is View && field.descriptor != null) {
|
||||||
if(field.isAdvanced && !_showAdvancedSettings)
|
if(field.isAdvanced && !_showAdvancedSettings)
|
||||||
{
|
{
|
||||||
@@ -73,15 +74,21 @@ class FieldForm : LinearLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if(field is View) {
|
||||||
|
if(field.isAdvanced && !_showAdvancedSettings)
|
||||||
|
field.visibility = View.GONE;
|
||||||
|
else
|
||||||
|
field.visibility = VISIBLE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(group != null) {
|
if(group != null) {
|
||||||
group.visibility = if (groupVisible) View.VISIBLE else View.GONE;
|
group.visibility = if (groupVisible) View.VISIBLE else View.GONE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowAdvancedSettings(show: Boolean) {
|
fun setShowAdvancedSettings(show: Boolean, allowEmptyGroups: Boolean = false) {
|
||||||
_showAdvancedSettings = show;
|
_showAdvancedSettings = show;
|
||||||
updateSettingsVisibility();
|
updateSettingsVisibility(null, allowEmptyGroups);
|
||||||
}
|
}
|
||||||
fun setSearchQuery(query: String) {
|
fun setSearchQuery(query: String) {
|
||||||
_editSearch.setText(query);
|
_editSearch.setText(query);
|
||||||
@@ -141,7 +148,9 @@ class FieldForm : LinearLayout {
|
|||||||
}
|
}
|
||||||
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
|
fun fromPluginSettings(settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, groupTitle: String? = null, groupDescription: String? = null) {
|
||||||
_fieldsContainer.removeAllViews();
|
_fieldsContainer.removeAllViews();
|
||||||
val newFields = getFieldsFromPluginSettings(context, settings, values);
|
val newFields = getFieldsFromPluginSettings(context, settings, values, {
|
||||||
|
setShowAdvancedSettings(it, true);
|
||||||
|
});
|
||||||
if (newFields.isEmpty()) {
|
if (newFields.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,6 +166,7 @@ class FieldForm : LinearLayout {
|
|||||||
_fieldsContainer.addView(v);
|
_fieldsContainer.addView(v);
|
||||||
}
|
}
|
||||||
_fields = newFields.map { it.second };
|
_fields = newFields.map { it.second };
|
||||||
|
updateSettingsVisibility(null, true);
|
||||||
} else {
|
} else {
|
||||||
for(field in newFields) {
|
for(field in newFields) {
|
||||||
finalizePluginSettingField(field.first, field.second, newFields);
|
finalizePluginSettingField(field.first, field.second, newFields);
|
||||||
@@ -164,6 +174,8 @@ class FieldForm : LinearLayout {
|
|||||||
val group = GroupField(context, groupTitle, groupDescription)
|
val group = GroupField(context, groupTitle, groupDescription)
|
||||||
.withFields(newFields.map { it.second });
|
.withFields(newFields.map { it.second });
|
||||||
_fieldsContainer.addView(group as View);
|
_fieldsContainer.addView(group as View);
|
||||||
|
_fields = newFields.map { it.second };
|
||||||
|
updateSettingsVisibility(null, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
|
private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List<Pair<SourcePluginConfig.Setting, IField>>) {
|
||||||
@@ -234,7 +246,7 @@ class FieldForm : LinearLayout {
|
|||||||
private val _json = Json;
|
private val _json = Json;
|
||||||
|
|
||||||
|
|
||||||
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>): List<Pair<SourcePluginConfig.Setting, IField>> {
|
fun getFieldsFromPluginSettings(context: Context, settings: List<SourcePluginConfig.Setting>, values: HashMap<String, String?>, onAdvancedChanged: ((newVal: Boolean)->Unit)? = null): List<Pair<SourcePluginConfig.Setting, IField>> {
|
||||||
val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
|
val fields = mutableListOf<Pair<SourcePluginConfig.Setting, IField>>()
|
||||||
|
|
||||||
for(setting in settings) {
|
for(setting in settings) {
|
||||||
@@ -243,6 +255,7 @@ class FieldForm : LinearLayout {
|
|||||||
val field = when(setting.type.lowercase()) {
|
val field = when(setting.type.lowercase()) {
|
||||||
"header" -> {
|
"header" -> {
|
||||||
val groupField = GroupField(context, setting.name, setting.description);
|
val groupField = GroupField(context, setting.name, setting.description);
|
||||||
|
groupField.isAdvanced = (setting.isAdvanced ?: false);
|
||||||
groupField;
|
groupField;
|
||||||
}
|
}
|
||||||
"boolean" -> {
|
"boolean" -> {
|
||||||
@@ -252,6 +265,7 @@ class FieldForm : LinearLayout {
|
|||||||
field.onChanged.subscribe { _, v, _ ->
|
field.onChanged.subscribe { _, v, _ ->
|
||||||
values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true);
|
values[setting.variableOrName] = _json.encodeToString (v == 1 || v == true);
|
||||||
}
|
}
|
||||||
|
field.isAdvanced = (setting.isAdvanced ?: false);
|
||||||
field;
|
field;
|
||||||
}
|
}
|
||||||
"dropdown" -> {
|
"dropdown" -> {
|
||||||
@@ -261,6 +275,7 @@ class FieldForm : LinearLayout {
|
|||||||
field.onChanged.subscribe { _, v, _ ->
|
field.onChanged.subscribe { _, v, _ ->
|
||||||
values[setting.variableOrName] = v.toString();
|
values[setting.variableOrName] = v.toString();
|
||||||
}
|
}
|
||||||
|
field.isAdvanced = (setting.isAdvanced ?: false);
|
||||||
field;
|
field;
|
||||||
}
|
}
|
||||||
else null;
|
else null;
|
||||||
@@ -272,6 +287,17 @@ class FieldForm : LinearLayout {
|
|||||||
fields.add(Pair(setting, field));
|
fields.add(Pair(setting, field));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(onAdvancedChanged != null && settings.any { it.isAdvanced == true }) {
|
||||||
|
val setting = SourcePluginConfig.Setting("Show Advanced", "See advanced settings, which may be counter productive to change", "boolean", "false");
|
||||||
|
val field = ToggleField(context).withValue(setting.name, setting.description, false);
|
||||||
|
|
||||||
|
field.onChanged.subscribe { field, new, old ->
|
||||||
|
onAdvancedChanged?.invoke(new as Boolean);
|
||||||
|
}
|
||||||
|
fields.add(Pair(setting, field));
|
||||||
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
package com.futo.platformplayer.views.notification
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.Announcement
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.views.AnyAdapterView
|
||||||
|
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||||
|
import com.futo.platformplayer.views.LoaderView
|
||||||
|
import com.futo.platformplayer.views.NoResultsView
|
||||||
|
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class NotificationOverlayView: ConstraintLayout {
|
||||||
|
|
||||||
|
lateinit var recycler: RecyclerView;
|
||||||
|
lateinit var emptyView: NoResultsView;
|
||||||
|
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
|
||||||
|
|
||||||
|
constructor(context: Context) : super(context) {
|
||||||
|
inflate(context, R.layout.overlay_notifications, this)
|
||||||
|
|
||||||
|
recycler = findViewById<RecyclerView>(R.id.container_notifications);
|
||||||
|
emptyView = findViewById<NoResultsView>(R.id.no_results);
|
||||||
|
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
|
||||||
|
|
||||||
|
});
|
||||||
|
emptyView.setText("Nothing to see here", "You don't have any notifications", R.drawable.ic_notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShown(parameter: Any?) {
|
||||||
|
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||||
|
adapterNotifications.adapter.setData(announcements);
|
||||||
|
|
||||||
|
if(announcements.any())
|
||||||
|
emptyView.isVisible = false;
|
||||||
|
else
|
||||||
|
emptyView.isVisible = true;
|
||||||
|
|
||||||
|
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
Logger.i("NotificationOverlayView", "Announcements Changed");
|
||||||
|
val adapter = adapterNotifications;
|
||||||
|
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||||
|
adapter.adapter.setData(announcements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPause() {
|
||||||
|
StateAnnouncement.instance.onAnnouncementChanged.remove(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Announcement>(
|
||||||
|
LayoutInflater.from(_viewGroup.context).inflate(
|
||||||
|
R.layout.list_announcement,
|
||||||
|
_viewGroup, false)) {
|
||||||
|
|
||||||
|
protected var _announcement: Announcement? = null;
|
||||||
|
protected val _textName: TextView
|
||||||
|
protected val _textMetadata: TextView;
|
||||||
|
protected val _icon: ImageView;
|
||||||
|
protected val _buttonIgnore: ImageView
|
||||||
|
protected val _buttonNever: LinearLayout
|
||||||
|
protected val _buttonAction: LinearLayout
|
||||||
|
protected val _buttonActionText: TextView
|
||||||
|
protected val _buttonExtra: LinearLayout
|
||||||
|
protected val _buttonExtraText: TextView
|
||||||
|
protected val _loader: LoaderView;
|
||||||
|
protected val _progress: ProgressBar;
|
||||||
|
|
||||||
|
init {
|
||||||
|
_textName = _view.findViewById(R.id.text_name);
|
||||||
|
_textMetadata = _view.findViewById(R.id.text_metadata);
|
||||||
|
_buttonIgnore = _view.findViewById(R.id.button_ignore);
|
||||||
|
_buttonNever = _view.findViewById(R.id.button_never);
|
||||||
|
_buttonAction = _view.findViewById(R.id.button_action);
|
||||||
|
_buttonActionText = _view.findViewById(R.id.button_action_text);
|
||||||
|
_buttonExtra = _view.findViewById(R.id.button_extra);
|
||||||
|
_buttonExtraText = _view.findViewById(R.id.button_extra_text);
|
||||||
|
_icon = _view.findViewById(R.id.icon);
|
||||||
|
_loader = _view.findViewById(R.id.loader);
|
||||||
|
_progress = _view.findViewById(R.id.progress);
|
||||||
|
|
||||||
|
_buttonIgnore.setOnClickListener {
|
||||||
|
_announcement.let {
|
||||||
|
StateAnnouncement.instance.closeAnnouncement(it?.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_buttonNever.setOnClickListener {
|
||||||
|
_announcement.let {
|
||||||
|
StateAnnouncement.instance.neverAnnouncement(it?.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_buttonExtra.setOnClickListener {
|
||||||
|
_announcement.let {
|
||||||
|
StateAnnouncement.instance.actionAnnouncement(it?.id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_buttonAction.setOnClickListener {
|
||||||
|
_announcement.let {
|
||||||
|
StateAnnouncement.instance.actionAnnouncement(it?.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
override fun bind(value: Announcement) {
|
||||||
|
val oldAnnouncement = _announcement;
|
||||||
|
_announcement = value;
|
||||||
|
|
||||||
|
if(oldAnnouncement is SessionAnnouncement)
|
||||||
|
oldAnnouncement.onProgressChanged.clear();
|
||||||
|
|
||||||
|
_textName.text = value.title;
|
||||||
|
_textMetadata.text = value.msg;
|
||||||
|
|
||||||
|
if(value is SessionAnnouncement) {
|
||||||
|
if(value.icon != null) {
|
||||||
|
value.icon.setImageView(_icon);
|
||||||
|
_icon.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_icon.visibility = View.GONE;
|
||||||
|
if(value.extraActionName != null && value.extraActionId != null) {
|
||||||
|
_buttonExtraText.text = value.extraActionName;
|
||||||
|
_buttonExtra.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_buttonExtra.visibility = View.GONE;
|
||||||
|
|
||||||
|
if(value.announceType == AnnouncementType.ONGOING) {
|
||||||
|
_buttonIgnore.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_buttonIgnore.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
if(value.progress != null && value.announceType == AnnouncementType.ONGOING) {
|
||||||
|
_progress.isVisible = true;
|
||||||
|
_progress.min = 0;
|
||||||
|
_progress.max = 100;
|
||||||
|
value.onProgressChanged.subscribe {
|
||||||
|
val prog = it.progress;
|
||||||
|
if(prog == 0.toDouble() || prog == 100.toDouble()) {
|
||||||
|
_progress.isIndeterminate = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_progress.isIndeterminate = false;
|
||||||
|
_progress.setProgress(it.progress?.times(100)?.toInt() ?: 0, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_progress.isVisible = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_buttonExtra.visibility = View.GONE;
|
||||||
|
_icon.visibility = View.GONE;
|
||||||
|
_buttonIgnore.visibility = View.VISIBLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(value.announceType == AnnouncementType.ONGOING) {
|
||||||
|
_loader.visibility = View.VISIBLE;
|
||||||
|
_loader.start();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_loader.visibility = View.GONE;
|
||||||
|
_loader.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buttonNever.visibility =
|
||||||
|
if (value.announceType == AnnouncementType.RECURRING || value.announceType == AnnouncementType.SESSION_RECURRING)
|
||||||
|
View.VISIBLE
|
||||||
|
else
|
||||||
|
View.GONE;
|
||||||
|
|
||||||
|
_buttonAction.visibility =
|
||||||
|
if(value.actionId != null && value.actionName != null)
|
||||||
|
View.VISIBLE;
|
||||||
|
else View.GONE;
|
||||||
|
|
||||||
|
if(value.actionId != null && value.actionName != null) {
|
||||||
|
_buttonActionText.text = value.actionName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Frag : MainFragment() {
|
||||||
|
override val isMainView : Boolean = true;
|
||||||
|
override val isTab: Boolean = true;
|
||||||
|
override val hasBottomBar: Boolean get() = true;
|
||||||
|
|
||||||
|
private var _view: NotificationOverlayView? = null;
|
||||||
|
|
||||||
|
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||||
|
super.onShownWithView(parameter, isBack);
|
||||||
|
_view?.onShown(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
val view = NotificationOverlayView(requireContext());
|
||||||
|
_view = view;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyMainView() {
|
||||||
|
super.onDestroyMainView();
|
||||||
|
_view = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
_view?.onResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
_view?.onPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-6
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.dp
|
||||||
|
|
||||||
class SlideUpMenuButtonList : LinearLayout {
|
class SlideUpMenuButtonList : LinearLayout {
|
||||||
private val _root: LinearLayout;
|
private val _root: LinearLayout;
|
||||||
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
var _activeText: String? = null;
|
var _activeText: String? = null;
|
||||||
val id: String?
|
val id: String?
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) {
|
val scrollable: Boolean;
|
||||||
this.id = id
|
|
||||||
|
|
||||||
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true);
|
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null, scrollable: Boolean = false): super(context, attrs) {
|
||||||
|
this.id = id
|
||||||
|
this.scrollable = scrollable ?: false;
|
||||||
|
|
||||||
|
LayoutInflater.from(context).inflate(
|
||||||
|
if(!scrollable)
|
||||||
|
R.layout.overlay_slide_up_menu_button_list
|
||||||
|
else R.layout.overlay_slide_up_menu_button_list_scrollable, this, true);
|
||||||
|
|
||||||
_root = findViewById(R.id.root);
|
_root = findViewById(R.id.root);
|
||||||
}
|
}
|
||||||
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
buttons.clear();
|
buttons.clear();
|
||||||
for (t in texts) {
|
for (t in texts) {
|
||||||
val button = LinearLayout(context);
|
val button = LinearLayout(context);
|
||||||
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
|
||||||
weight = 1.0f;
|
if(!scrollable)
|
||||||
|
weight = 1.0f;
|
||||||
marginStart = marginLeft;
|
marginStart = marginLeft;
|
||||||
marginEnd = marginRight;
|
marginEnd = marginRight;
|
||||||
};
|
};
|
||||||
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
onClick.emit(t);
|
onClick.emit(t);
|
||||||
};
|
};
|
||||||
|
|
||||||
button.setPadding(0, 0, 0, 0);
|
val dp8 = 8.dp(resources)
|
||||||
|
if(!scrollable)
|
||||||
|
button.setPadding(0, 0, 0, 0);
|
||||||
|
else
|
||||||
|
button.setPadding(dp8, 0, dp8, 0);
|
||||||
|
|
||||||
val text = TextView(context);
|
val text = TextView(context);
|
||||||
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||||
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
|
|||||||
fun setSelected(text: String) {
|
fun setSelected(text: String) {
|
||||||
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
|
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
|
||||||
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
|
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
|
||||||
|
|
||||||
|
|
||||||
|
val dp8 = 8.dp(resources)
|
||||||
|
if(!scrollable) {
|
||||||
|
buttons[text]?.setPadding(0, 0, 0, 0);
|
||||||
|
buttons[_activeText]?.setPadding(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
buttons[text]?.setPadding(dp8, 0, dp8, 0);
|
||||||
|
buttons[_activeText]?.setPadding(dp8, 0, dp8, 0);
|
||||||
|
}
|
||||||
|
|
||||||
_activeText = text;
|
_activeText = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-5
@@ -3,15 +3,10 @@ package com.futo.platformplayer.views.overlays.slideup
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import org.w3c.dom.Text
|
|
||||||
|
|
||||||
class SlideUpMenuTextInput : LinearLayout {
|
class SlideUpMenuTextInput : LinearLayout {
|
||||||
private lateinit var _root: LinearLayout;
|
private lateinit var _root: LinearLayout;
|
||||||
|
|||||||
+2
-2
@@ -15,9 +15,9 @@ class PluginMediaDrmCallback(
|
|||||||
) : MediaDrmCallback by delegate {
|
) : MediaDrmCallback by delegate {
|
||||||
|
|
||||||
@ExperimentalEncodingApi
|
@ExperimentalEncodingApi
|
||||||
override fun executeKeyRequest(uuid: UUID, request: ExoMediaDrm.KeyRequest): ByteArray {
|
override fun executeKeyRequest(uuid: UUID, request: ExoMediaDrm.KeyRequest): MediaDrmCallback.Response {
|
||||||
val pluginResponse = requestExecutor.executeRequest("POST", licenseUrl, request.data, mapOf())
|
val pluginResponse = requestExecutor.executeRequest("POST", licenseUrl, request.data, mapOf())
|
||||||
|
|
||||||
return pluginResponse
|
return MediaDrmCallback.Response(pluginResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
syntax = "proto2";
|
|
||||||
option optimize_for = LITE_RUNTIME;
|
|
||||||
package com.futo.platformplayer.protos;
|
|
||||||
|
|
||||||
message CastMessage {
|
|
||||||
enum ProtocolVersion { CASTV2_1_0 = 0; }
|
|
||||||
required ProtocolVersion protocol_version = 1;
|
|
||||||
required string source_id = 2;
|
|
||||||
required string destination_id = 3;
|
|
||||||
required string namespace = 4;
|
|
||||||
enum PayloadType {
|
|
||||||
STRING = 0;
|
|
||||||
BINARY = 1;
|
|
||||||
}
|
|
||||||
required PayloadType payload_type = 5;
|
|
||||||
optional string payload_utf8 = 6;
|
|
||||||
optional bytes payload_binary = 7;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#2D63ED" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||||
|
</shape>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="111.96dp"
|
|
||||||
android:height="114.46dp"
|
|
||||||
android:viewportWidth="111.96"
|
|
||||||
android:viewportHeight="114.46">
|
|
||||||
<path
|
|
||||||
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
|
|
||||||
android:strokeWidth="0"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="17dp"
|
android:width="111.96dp"
|
||||||
android:height="12dp"
|
android:height="114.46dp"
|
||||||
android:viewportWidth="17"
|
android:viewportWidth="111.96"
|
||||||
android:viewportHeight="12">
|
android:viewportHeight="114.46">
|
||||||
<path
|
<path
|
||||||
android:pathData="M0.672,11V0.818H6.563V1.653H1.601V5.487H6.101V6.322H1.601V11H0.672ZM16.849,4H15.915C15.845,3.652 15.719,3.33 15.537,3.036C15.358,2.737 15.132,2.477 14.861,2.255C14.589,2.033 14.281,1.861 13.936,1.738C13.591,1.615 13.218,1.554 12.817,1.554C12.174,1.554 11.588,1.721 11.057,2.056C10.53,2.391 10.108,2.883 9.79,3.533C9.475,4.179 9.317,4.971 9.317,5.909C9.317,6.854 9.475,7.649 9.79,8.295C10.108,8.942 10.53,9.432 11.057,9.767C11.588,10.099 12.174,10.264 12.817,10.264C13.218,10.264 13.591,10.203 13.936,10.08C14.281,9.958 14.589,9.787 14.861,9.568C15.132,9.346 15.358,9.086 15.537,8.788C15.719,8.489 15.845,8.166 15.915,7.818H16.849C16.766,8.286 16.611,8.721 16.382,9.126C16.156,9.527 15.868,9.878 15.517,10.18C15.169,10.481 14.768,10.717 14.314,10.886C13.86,11.055 13.361,11.139 12.817,11.139C11.962,11.139 11.203,10.925 10.54,10.498C9.877,10.067 9.357,9.46 8.979,8.678C8.605,7.896 8.417,6.973 8.417,5.909C8.417,4.845 8.605,3.922 8.979,3.14C9.357,2.358 9.877,1.753 10.54,1.325C11.203,0.894 11.962,0.679 12.817,0.679C13.361,0.679 13.86,0.763 14.314,0.933C14.768,1.098 15.169,1.334 15.517,1.638C15.868,1.94 16.156,2.291 16.382,2.692C16.611,3.094 16.766,3.529 16.849,4Z"
|
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
|
||||||
|
android:strokeWidth="0"
|
||||||
android:fillColor="#ffffff"/>
|
android:fillColor="#ffffff"/>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880Z"/>
|
||||||
|
</vector>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/set_a_password_for_your_daily_backup"
|
android:text="@string/enable_daily_backup"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:textColor="#AAAAAA"
|
android:textColor="#AAAAAA"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
android:text="@string/set_a_password_used_to_encrypt_your_daily_backup_that_is_written_to_external_storage"
|
android:text="@string/automatic_backup_unencrypted_explanation"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:layout_marginStart="30dp"
|
android:layout_marginStart="30dp"
|
||||||
android:layout_marginEnd="30dp"
|
android:layout_marginEnd="30dp"
|
||||||
@@ -62,26 +62,6 @@
|
|||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
</TextView>
|
</TextView>
|
||||||
<EditText
|
|
||||||
android:id="@+id/edit_password"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_marginLeft="30dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginRight="30dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:inputType="textPassword"
|
|
||||||
android:hint="@string/backup_password" />
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/edit_password2"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_marginLeft="30dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginRight="30dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:inputType="textPassword"
|
|
||||||
android:hint="@string/repeat_password" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -107,7 +87,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/stop"
|
android:text="@string/disable"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
@@ -128,7 +108,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/start"
|
android:text="@string/enable"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:background="@color/gray_1d">
|
android:background="@color/gray_1d">
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="40dp">
|
android:paddingTop="40dp"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
android:id="@+id/image_icon"
|
||||||
android:layout_width="80dp"
|
android:layout_width="80dp"
|
||||||
android:layout_height="80dp"
|
android:layout_height="80dp"
|
||||||
app:srcCompat="@drawable/ic_lock" />
|
app:srcCompat="@drawable/ic_lock" />
|
||||||
@@ -31,42 +33,57 @@
|
|||||||
android:layout_marginStart="30dp"
|
android:layout_marginStart="30dp"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:layout_marginEnd="30dp" />
|
android:layout_marginEnd="30dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_reason"
|
android:id="@+id/text_reason"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#AAAAAA"
|
android:textColor="#AAAAAA"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
|
android:text="@string/it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:layout_marginStart="30dp"
|
android:layout_marginStart="30dp"
|
||||||
android:layout_marginEnd="30dp"
|
android:layout_marginEnd="30dp"
|
||||||
android:textSize="10dp"
|
android:textSize="10dp" />
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
</TextView>
|
<LinearLayout
|
||||||
<EditText
|
android:id="@+id/password_container"
|
||||||
android:id="@+id/edit_password"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_marginLeft="30dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginRight="30dp"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textPassword"
|
android:orientation="vertical">
|
||||||
android:singleLine="true"
|
|
||||||
android:hint="@string/backup_password" />
|
<EditText
|
||||||
|
android:id="@+id/edit_password"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_marginLeft="30dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginRight="30dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:hint="@string/backup_password" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_restore"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
style="?android:attr/progressBarStyleLarge" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:layout_marginTop="28dp"
|
android:layout_marginTop="28dp">
|
||||||
android:layout_marginBottom="28dp">
|
|
||||||
|
|
||||||
<Space
|
<Space
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button_cancel"
|
android:id="@+id/button_cancel"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -77,6 +94,7 @@
|
|||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
android:textColor="@color/colorPrimary"
|
android:textColor="@color/colorPrimary"
|
||||||
android:background="@color/transparent" />
|
android:background="@color/transparent" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/button_start"
|
android:id="@+id/button_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -86,6 +104,7 @@
|
|||||||
android:clickable="true">
|
android:clickable="true">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/text_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/restore"
|
android:text="@string/restore"
|
||||||
@@ -99,4 +118,4 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/root"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -11,4 +12,4 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</LinearLayout>
|
||||||
@@ -38,11 +38,12 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!--
|
||||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||||
android:id="@+id/announcement_view"
|
android:id="@+id/announcement_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" /> -->
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/container_sort_by"
|
android:id="@+id/container_sort_by"
|
||||||
|
|||||||
@@ -46,6 +46,42 @@
|
|||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
app:srcCompat="@drawable/ic_cast_white_25dp" />
|
app:srcCompat="@drawable/ic_cast_white_25dp" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:id="@+id/button_notifs">
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/button_notifs_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:contentDescription="@string/cd_button_notifs"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="11dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:clickable="false"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:srcCompat="@drawable/ic_notifications" />
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/button_notifs_count"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:text="5"
|
||||||
|
android:textSize="12dp"
|
||||||
|
android:textAlignment="center"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
android:background="@drawable/background_primary_round_20dp"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:layout_marginRight="5dp"
|
||||||
|
android:clickable="false"
|
||||||
|
/>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
<!--Back Button-->
|
<!--Back Button-->
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/button_search"
|
android:id="@+id/button_search"
|
||||||
|
|||||||
@@ -30,11 +30,12 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!--
|
||||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||||
android:id="@+id/announcement_view"
|
android:id="@+id/announcement_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" /> -->
|
||||||
|
|
||||||
<com.futo.platformplayer.views.others.RadioGroupView
|
<com.futo.platformplayer.views.others.RadioGroupView
|
||||||
android:id="@+id/radio_group"
|
android:id="@+id/radio_group"
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginTop="0dp"
|
||||||
|
android:layout_marginBottom="0dp"
|
||||||
|
android:id="@+id/root"
|
||||||
|
android:clickable="true"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingLeft="10dp"
|
||||||
|
android:paddingRight="10dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/icon"
|
||||||
|
android:layout_width="30dp"
|
||||||
|
android:layout_height="30dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_name"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:textSize="13dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:fontFamily="@font/inter_regular"
|
||||||
|
tools:text="Example Artist"
|
||||||
|
android:maxLines="1"
|
||||||
|
app:layout_constraintLeft_toRightOf="@id/icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintRight_toLeftOf="@id/button_ignore"
|
||||||
|
android:layout_marginRight="20dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/text_metadata"
|
||||||
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_metadata"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:textSize="12dp"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:fontFamily="@font/inter_regular"
|
||||||
|
tools:text="3 videos"
|
||||||
|
android:maxLines="2"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||||
|
app:layout_constraintLeft_toRightOf="@id/icon"
|
||||||
|
app:layout_constraintRight_toLeftOf="@id/button_ignore"
|
||||||
|
android:layout_marginRight="20dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:layout_marginStart="10dp" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/button_ignore"
|
||||||
|
android:layout_width="30dp"
|
||||||
|
android:layout_height="30dp"
|
||||||
|
android:src="@drawable/ic_close"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.LoaderView
|
||||||
|
android:id="@+id/loader"
|
||||||
|
android:layout_width="30dp"
|
||||||
|
android:layout_height="30dp"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/container_buttons"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/text_metadata"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/separator"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/button_never"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/background_button_accent"
|
||||||
|
android:layout_marginLeft="5dp"
|
||||||
|
android:layout_marginRight="5dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingStart="28dp"
|
||||||
|
android:paddingEnd="28dp"
|
||||||
|
android:text="Never" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/button_extra"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/background_button_accent"
|
||||||
|
android:layout_marginLeft="5dp"
|
||||||
|
android:layout_marginRight="5dp">
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/button_extra_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingStart="28dp"
|
||||||
|
android:paddingEnd="28dp"
|
||||||
|
android:text="Extra" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/button_action"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="5dp"
|
||||||
|
android:layout_marginRight="5dp"
|
||||||
|
android:background="@drawable/background_button_primary"
|
||||||
|
android:clickable="true">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/button_action_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Action"
|
||||||
|
android:textSize="14dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:fontFamily="@font/inter_regular"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingStart="28dp"
|
||||||
|
android:paddingEnd="28dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress"
|
||||||
|
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
android:layout_marginBottom="1dp"
|
||||||
|
android:progressTint="@color/primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/separator"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/container_buttons"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1px"
|
||||||
|
android:background="#181818" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
<View
|
||||||
|
android:id="@+id/overlay_slide_up_menu_background"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#C9000000" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/separator"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1px"
|
||||||
|
android:background="#181818" />
|
||||||
|
<com.futo.platformplayer.views.NoResultsView
|
||||||
|
android:id="@+id/no_results"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/container_notifications"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/separator"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<HorizontalScrollView android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="35dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:id="@+id/root"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="0dp"
|
||||||
|
android:paddingEnd="0dp">
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</HorizontalScrollView>
|
||||||
@@ -1064,6 +1064,7 @@
|
|||||||
<item>Russo</item>
|
<item>Russo</item>
|
||||||
<item>Portoghese</item>
|
<item>Portoghese</item>
|
||||||
<item>Cinese</item>
|
<item>Cinese</item>
|
||||||
|
<item>Italiano</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="casting_device_type_array" translatable="false">
|
<string-array name="casting_device_type_array" translatable="false">
|
||||||
<item>FCast</item>
|
<item>FCast</item>
|
||||||
|
|||||||
@@ -1017,6 +1017,7 @@
|
|||||||
<item>Rusça</item>
|
<item>Rusça</item>
|
||||||
<item>Portekizce</item>
|
<item>Portekizce</item>
|
||||||
<item>Çince</item>
|
<item>Çince</item>
|
||||||
|
<item>İtalyanca</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="casting_device_type_array" translatable="false">
|
<string-array name="casting_device_type_array" translatable="false">
|
||||||
<item>FCast</item>
|
<item>FCast</item>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
<string name="install_failed_device_installer_broken">Failed to start system installer. Your device’s ROM is not compatible with automatic updates.</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="enable_daily_backup">Manage daily backup</string>
|
||||||
|
<string name="automatic_backup_unencrypted_explanation">Enable or disable your automatic backups here</string>
|
||||||
|
<string name="continue_anyway">Continue anyway</string>
|
||||||
|
<string name="automatic_backup_disabled">Automatic backup disabled</string>
|
||||||
|
<string name="automatic_backup_enabled">Automatic backup enabled</string>
|
||||||
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
<string name="failed_to_retrieve_data_are_you_connected">Failed to retrieve data, are you connected?</string>
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="history">History</string>
|
<string name="history">History</string>
|
||||||
@@ -338,6 +343,8 @@
|
|||||||
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
|
<string name="clear_the_external_storage_for_download_files">Clear the external storage for download files</string>
|
||||||
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
||||||
<string name="clear_cookies">Clear Cookies</string>
|
<string name="clear_cookies">Clear Cookies</string>
|
||||||
|
<string name="clear_cookies_after_login">Clear Cookies after Login</string>
|
||||||
|
<string name="clear_cookies_after_login_desc">Deletes all cookies on the webview after login, this may be required for certain plugins to function properly.</string>
|
||||||
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
||||||
<string name="test_background_worker">Test Background Worker</string>
|
<string name="test_background_worker">Test Background Worker</string>
|
||||||
<string name="test_background_worker_description"></string>
|
<string name="test_background_worker_description"></string>
|
||||||
@@ -535,7 +542,7 @@
|
|||||||
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
|
<string name="restart_playback_when_gaining_connectivity_after_a_loss">Restart playback when gaining connectivity after a loss</string>
|
||||||
<string name="chapter_update_fps_title">Chapter Update FPS</string>
|
<string name="chapter_update_fps_title">Chapter Update FPS</string>
|
||||||
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
|
<string name="chapter_update_fps_description">Change accuracy of chapter updating, higher might cost more performance</string>
|
||||||
<string name="set_automatic_backup">Set Automatic Backup</string>
|
<string name="set_automatic_backup">Configure Automatic Backup</string>
|
||||||
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
|
<string name="shortly_after_opening_the_app_start_fetching_subscriptions">Shortly after opening the app, start fetching subscriptions</string>
|
||||||
<string name="show_faq">Show FAQ</string>
|
<string name="show_faq">Show FAQ</string>
|
||||||
<string name="show_issues">Show Issues</string>
|
<string name="show_issues">Show Issues</string>
|
||||||
@@ -805,6 +812,10 @@
|
|||||||
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
|
<string name="not_yet_available_retrying_in_time_s">Not yet available, retrying in {time}s</string>
|
||||||
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
|
<string name="failed_to_retry_for_live_stream">Failed to retry for live stream</string>
|
||||||
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
|
<string name="this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete">This app is in development. Please submit bug reports and understand that many features are incomplete.</string>
|
||||||
|
<string name="automatic_backup_found_no_password">Automatic backup found. No password is required to restore.</string>
|
||||||
|
<string name="checking_backup">Checking backup...</string>
|
||||||
|
<string name="backup_password_length_error">Password must be 4–32 bytes.</string>
|
||||||
|
<string name="restoring">Restoring...</string>
|
||||||
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
|
<string name="please_use_at_least_1_character">Please use at least 1 character</string>
|
||||||
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
|
<string name="are_you_sure_you_want_to_delete_this_video">Are you sure you want to delete this video?</string>
|
||||||
<string name="tap_to_open">Tap to open</string>
|
<string name="tap_to_open">Tap to open</string>
|
||||||
@@ -896,6 +907,7 @@
|
|||||||
<string name="cd_creator_thumbnail">Creator thumbnail</string>
|
<string name="cd_creator_thumbnail">Creator thumbnail</string>
|
||||||
<string name="cd_button_clear_search">Clear search</string>
|
<string name="cd_button_clear_search">Clear search</string>
|
||||||
<string name="cd_button_search">Search</string>
|
<string name="cd_button_search">Search</string>
|
||||||
|
<string name="cd_button_notifs">Notifications</string>
|
||||||
<string name="cd_search_icon">Search icon</string>
|
<string name="cd_search_icon">Search icon</string>
|
||||||
<string name="cd_button_back">Back button</string>
|
<string name="cd_button_back">Back button</string>
|
||||||
<string name="cd_app_icon">App icon</string>
|
<string name="cd_app_icon">App icon</string>
|
||||||
@@ -1108,15 +1120,11 @@
|
|||||||
<item>Russian</item>
|
<item>Russian</item>
|
||||||
<item>Portuguese</item>
|
<item>Portuguese</item>
|
||||||
<item>Chinese</item>
|
<item>Chinese</item>
|
||||||
|
<item>Italian</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="casting_device_type_array" translatable="false">
|
<string-array name="casting_device_type_array" translatable="false">
|
||||||
<item>FCast</item>
|
<item>FCast</item>
|
||||||
<item>ChromeCast</item>
|
<item>ChromeCast</item>
|
||||||
<item>AirPlay</item>
|
|
||||||
</string-array>
|
|
||||||
<string-array name="exp_casting_device_type_array" translatable="false">
|
|
||||||
<item>FCast</item>
|
|
||||||
<item>ChromeCast</item>
|
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="log_levels">
|
<string-array name="log_levels">
|
||||||
<item>None</item>
|
<item>None</item>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="youtu.be" />
|
<data android:host="youtu.be" />
|
||||||
<data android:host="www.you.be" />
|
<data android:host="www.youtu.be" />
|
||||||
<data android:host="youtube.com" />
|
<data android:host="youtube.com" />
|
||||||
<data android:host="www.youtube.com" />
|
<data android:host="www.youtube.com" />
|
||||||
<data android:host="m.youtube.com" />
|
<data android:host="m.youtube.com" />
|
||||||
@@ -31,6 +31,8 @@
|
|||||||
<data android:host="patreon.com" />
|
<data android:host="patreon.com" />
|
||||||
<data android:host="soundcloud.com" />
|
<data android:host="soundcloud.com" />
|
||||||
<data android:host="twitch.tv" />
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
<data android:host="bilibili.com" />
|
<data android:host="bilibili.com" />
|
||||||
<data android:host="bilibili.tv" />
|
<data android:host="bilibili.tv" />
|
||||||
<data android:host="dailymotion.com" />
|
<data android:host="dailymotion.com" />
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
<data android:host="old.bitchute.com" />
|
<data android:host="old.bitchute.com" />
|
||||||
<data android:host="open.spotify.com" />
|
<data android:host="open.spotify.com" />
|
||||||
<data android:host="music.youtube.com" />
|
<data android:host="music.youtube.com" />
|
||||||
|
<data android:host="b23.tv" />
|
||||||
<data android:pathPrefix="/" />
|
<data android:pathPrefix="/" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
|
|
||||||
<data android:host="youtu.be" />
|
<data android:host="youtu.be" />
|
||||||
<data android:host="www.you.be" />
|
<data android:host="www.youtu.be" />
|
||||||
<data android:host="youtube.com" />
|
<data android:host="youtube.com" />
|
||||||
<data android:host="www.youtube.com" />
|
<data android:host="www.youtube.com" />
|
||||||
<data android:host="m.youtube.com" />
|
<data android:host="m.youtube.com" />
|
||||||
@@ -62,6 +65,8 @@
|
|||||||
<data android:host="patreon.com" />
|
<data android:host="patreon.com" />
|
||||||
<data android:host="soundcloud.com" />
|
<data android:host="soundcloud.com" />
|
||||||
<data android:host="twitch.tv" />
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
<data android:host="bilibili.com" />
|
<data android:host="bilibili.com" />
|
||||||
<data android:host="bilibili.tv" />
|
<data android:host="bilibili.tv" />
|
||||||
<data android:host="dailymotion.com" />
|
<data android:host="dailymotion.com" />
|
||||||
@@ -71,6 +76,7 @@
|
|||||||
<data android:host="old.bitchute.com" />
|
<data android:host="old.bitchute.com" />
|
||||||
<data android:host="open.spotify.com" />
|
<data android:host="open.spotify.com" />
|
||||||
<data android:host="music.youtube.com" />
|
<data android:host="music.youtube.com" />
|
||||||
|
<data android:host="b23.tv" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
Submodule app/src/stable/assets/sources/bilibili updated: b153339c93...9186672f0f
Submodule app/src/stable/assets/sources/dailymotion updated: d95df7dca2...70f625a3bd
Submodule app/src/stable/assets/sources/kick updated: 96503584d9...bea721edc3
Submodule app/src/stable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/stable/assets/sources/patreon updated: 6880b30b71...52154f36c2
Submodule app/src/stable/assets/sources/peertube updated: 21dcf4bef5...7b52405ad0
Submodule app/src/stable/assets/sources/rumble updated: 3b51471010...34e0a15016
Submodule app/src/stable/assets/sources/soundcloud updated: 49db9e3e15...e785c5d8c9
Submodule app/src/stable/assets/sources/youtube updated: 5e903fa569...fb90a44f83
@@ -29,7 +29,7 @@
|
|||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="youtu.be" />
|
<data android:host="youtu.be" />
|
||||||
<data android:host="www.you.be" />
|
<data android:host="www.youtu.be" />
|
||||||
<data android:host="youtube.com" />
|
<data android:host="youtube.com" />
|
||||||
<data android:host="www.youtube.com" />
|
<data android:host="www.youtube.com" />
|
||||||
<data android:host="m.youtube.com" />
|
<data android:host="m.youtube.com" />
|
||||||
@@ -41,6 +41,8 @@
|
|||||||
<data android:host="patreon.com" />
|
<data android:host="patreon.com" />
|
||||||
<data android:host="soundcloud.com" />
|
<data android:host="soundcloud.com" />
|
||||||
<data android:host="twitch.tv" />
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
<data android:host="bilibili.com" />
|
<data android:host="bilibili.com" />
|
||||||
<data android:host="bilibili.tv" />
|
<data android:host="bilibili.tv" />
|
||||||
<data android:host="dailymotion.com" />
|
<data android:host="dailymotion.com" />
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
<data android:host="old.bitchute.com" />
|
<data android:host="old.bitchute.com" />
|
||||||
<data android:host="open.spotify.com" />
|
<data android:host="open.spotify.com" />
|
||||||
<data android:host="music.youtube.com" />
|
<data android:host="music.youtube.com" />
|
||||||
|
<data android:host="b23.tv" />
|
||||||
<data android:pathPrefix="/" />
|
<data android:pathPrefix="/" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
|
|
||||||
<data android:host="youtu.be" />
|
<data android:host="youtu.be" />
|
||||||
<data android:host="www.you.be" />
|
<data android:host="www.youtu.be" />
|
||||||
<data android:host="youtube.com" />
|
<data android:host="youtube.com" />
|
||||||
<data android:host="www.youtube.com" />
|
<data android:host="www.youtube.com" />
|
||||||
<data android:host="m.youtube.com" />
|
<data android:host="m.youtube.com" />
|
||||||
@@ -72,6 +75,8 @@
|
|||||||
<data android:host="patreon.com" />
|
<data android:host="patreon.com" />
|
||||||
<data android:host="soundcloud.com" />
|
<data android:host="soundcloud.com" />
|
||||||
<data android:host="twitch.tv" />
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
<data android:host="bilibili.com" />
|
<data android:host="bilibili.com" />
|
||||||
<data android:host="bilibili.tv" />
|
<data android:host="bilibili.tv" />
|
||||||
<data android:host="dailymotion.com" />
|
<data android:host="dailymotion.com" />
|
||||||
@@ -81,6 +86,7 @@
|
|||||||
<data android:host="old.bitchute.com" />
|
<data android:host="old.bitchute.com" />
|
||||||
<data android:host="open.spotify.com" />
|
<data android:host="open.spotify.com" />
|
||||||
<data android:host="music.youtube.com" />
|
<data android:host="music.youtube.com" />
|
||||||
|
<data android:host="b23.tv" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
Submodule app/src/unstable/assets/sources/bilibili updated: b153339c93...9186672f0f
Submodule app/src/unstable/assets/sources/dailymotion updated: d95df7dca2...70f625a3bd
Submodule app/src/unstable/assets/sources/kick updated: 96503584d9...bea721edc3
Submodule app/src/unstable/assets/sources/mixcloud updated: 0bbe4c63f4...1b801553b3
Submodule app/src/unstable/assets/sources/patreon updated: 6880b30b71...52154f36c2
Submodule app/src/unstable/assets/sources/peertube updated: 21dcf4bef5...7b52405ad0
Submodule app/src/unstable/assets/sources/rumble updated: 3b51471010...34e0a15016
Submodule app/src/unstable/assets/sources/soundcloud updated: 49db9e3e15...e785c5d8c9
Submodule app/src/unstable/assets/sources/youtube updated: 5e903fa569...fb90a44f83
@@ -1,5 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
DOCUMENT_ROOT=/var/www/html
|
DOCUMENT_ROOT=/var/www/html
|
||||||
|
MAINT_FILE="$DOCUMENT_ROOT/maintenance.file"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$MAINT_FILE"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
# Sign sources
|
# Sign sources
|
||||||
echo "Signing all sources..."
|
echo "Signing all sources..."
|
||||||
@@ -11,12 +19,12 @@ echo "Building content..."
|
|||||||
|
|
||||||
# Take site offline
|
# Take site offline
|
||||||
echo "Taking site offline..."
|
echo "Taking site offline..."
|
||||||
touch $DOCUMENT_ROOT/maintenance.file
|
touch "$MAINT_FILE"
|
||||||
|
|
||||||
# Swap over the content
|
# Swap over the content
|
||||||
echo "Deploying content..."
|
echo "Deploying content..."
|
||||||
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab $DOCUMENT_ROOT/app-playstore-release.aab
|
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab \
|
||||||
aws s3 cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab s3://artifacts-grayjay-app/app-playstore-release.aab
|
"$DOCUMENT_ROOT/app-playstore-release.aab"
|
||||||
|
|
||||||
# Notify Cloudflare to wipe the CDN cache
|
# Notify Cloudflare to wipe the CDN cache
|
||||||
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
||||||
@@ -29,4 +37,5 @@ sleep 30
|
|||||||
|
|
||||||
# Take site back online
|
# Take site back online
|
||||||
echo "Bringing site back online..."
|
echo "Bringing site back online..."
|
||||||
rm $DOCUMENT_ROOT/maintenance.file
|
rm -f "$MAINT_FILE"
|
||||||
|
trap - EXIT INT TERM
|
||||||
+1
-1
Submodule dep/futopay updated: 6857c8f0bc...3996582852
+70
-44
@@ -1,55 +1,81 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DOCUMENT_ROOT=/var/www/html
|
set -eu
|
||||||
|
|
||||||
|
R2_ENDPOINT="https://$CF_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
|
||||||
|
|
||||||
|
r2_cp() {
|
||||||
|
src="$1"
|
||||||
|
key="$2"
|
||||||
|
cache_control="$3"
|
||||||
|
content_type="$4"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID="$CF_R2_ACCESS_KEY_ID" \
|
||||||
|
AWS_SECRET_ACCESS_KEY="$CF_R2_SECRET_ACCESS_KEY" \
|
||||||
|
AWS_DEFAULT_REGION=auto \
|
||||||
|
aws s3 cp "$src" "s3://$CF_R2_BUCKET/$key" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" \
|
||||||
|
--only-show-errors \
|
||||||
|
--cache-control "$cache_control" \
|
||||||
|
--content-type "$content_type"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_apk_latest_and_versioned() {
|
||||||
|
src="$1"
|
||||||
|
filename="$2"
|
||||||
|
|
||||||
|
r2_cp "$src" "$VERSION/$filename" \
|
||||||
|
"public, max-age=31536000, immutable" \
|
||||||
|
"application/vnd.android.package-archive"
|
||||||
|
|
||||||
|
r2_cp "$src" "$filename" \
|
||||||
|
"no-store" \
|
||||||
|
"application/vnd.android.package-archive"
|
||||||
|
}
|
||||||
|
|
||||||
# Sign sources
|
|
||||||
echo "Signing all sources..."
|
echo "Signing all sources..."
|
||||||
/usr/bin/bash ./sign-all-sources.sh
|
/usr/bin/bash ./sign-all-sources.sh
|
||||||
|
|
||||||
# Build content
|
|
||||||
echo "Building content..."
|
echo "Building content..."
|
||||||
./gradlew --stacktrace assembleStableRelease
|
./gradlew --stacktrace assembleStableRelease
|
||||||
|
|
||||||
# Take site offline
|
VERSION="$(git describe --tags)"
|
||||||
echo "Taking site offline..."
|
|
||||||
touch $DOCUMENT_ROOT/maintenance.file
|
|
||||||
|
|
||||||
# Swap over the content
|
echo "Deploying artifacts to Cloudflare R2..."
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk" "app-x86_64-release.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk" "app-arm64-v8a-release.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk" "app-armeabi-v7a-release.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-universal-release.apk" "app-universal-release.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-x86-release.apk" "app-x86-release.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk" "app-release.apk"
|
||||||
|
|
||||||
|
tmp_version="$(mktemp)"
|
||||||
|
printf '%s\n' "$VERSION" > "$tmp_version"
|
||||||
|
r2_cp "$tmp_version" "$VERSION/version.txt" \
|
||||||
|
"public, max-age=31536000, immutable" \
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
r2_cp "$tmp_version" "version.txt" \
|
||||||
|
"no-store" \
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
rm -f "$tmp_version"
|
||||||
|
|
||||||
|
tmp_changelog="$(mktemp)"
|
||||||
|
git tag -l --format='%(contents)' "$VERSION" > "$tmp_changelog"
|
||||||
|
r2_cp "$tmp_changelog" "changelogs/$VERSION" \
|
||||||
|
"public, max-age=31536000, immutable" \
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
rm -f "$tmp_changelog"
|
||||||
|
|
||||||
|
DOCUMENT_ROOT=/var/www/html
|
||||||
echo "Deploying content..."
|
echo "Deploying content..."
|
||||||
cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk $DOCUMENT_ROOT/app-x86_64-release.apk
|
cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk "$DOCUMENT_ROOT/app-x86_64-release.apk"
|
||||||
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-arm64-v8a-release.apk
|
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-arm64-v8a-release.apk"
|
||||||
cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk $DOCUMENT_ROOT/app-armeabi-v7a-release.apk
|
cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk "$DOCUMENT_ROOT/app-armeabi-v7a-release.apk"
|
||||||
cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk $DOCUMENT_ROOT/app-universal-release.apk
|
cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk "$DOCUMENT_ROOT/app-universal-release.apk"
|
||||||
cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk $DOCUMENT_ROOT/app-x86-release.apk
|
cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk "$DOCUMENT_ROOT/app-x86-release.apk"
|
||||||
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-release.apk
|
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-release.apk"
|
||||||
|
VERSION="$(git describe --tags)"
|
||||||
|
echo "$VERSION" > "$DOCUMENT_ROOT/version.txt"
|
||||||
|
mkdir -p "$DOCUMENT_ROOT/changelogs"
|
||||||
|
git tag -l --format='%(contents)' "$VERSION" > "$DOCUMENT_ROOT/changelogs/$VERSION"
|
||||||
|
|
||||||
VERSION=$(git describe --tags)
|
echo "Done."
|
||||||
echo $VERSION > $DOCUMENT_ROOT/version.txt
|
|
||||||
mkdir -p $DOCUMENT_ROOT/changelogs
|
|
||||||
git tag -l --format='%(contents)' $VERSION > $DOCUMENT_ROOT/changelogs/$VERSION
|
|
||||||
|
|
||||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk s3://artifacts-grayjay-app/app-x86_64-release.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-arm64-v8a-release.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk s3://artifacts-grayjay-app/app-armeabi-v7a-release.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk s3://artifacts-grayjay-app/app-universal-release.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk s3://artifacts-grayjay-app/app-x86-release.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-release.apk
|
|
||||||
|
|
||||||
VERSION=$(git describe --tags)
|
|
||||||
echo $VERSION > ./version.txt
|
|
||||||
git tag -l --format='%(contents)' $VERSION > ./changelog.txt
|
|
||||||
|
|
||||||
aws s3 cp ./version.txt s3://artifacts-grayjay-app/version.txt
|
|
||||||
aws s3 cp ./changelog.txt s3://artifacts-grayjay-app/changelogs/$VERSION
|
|
||||||
|
|
||||||
# Notify Cloudflare to wipe the CDN cache
|
|
||||||
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
|
||||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
|
|
||||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data '{"files":["https://releases.grayjay.app/app-x86_64-release.apk", "https://releases.grayjay.app/app-arm64-v8a-release.apk", "https://releases.grayjay.app/app-armeabi-v7a-release.apk", "https://releases.grayjay.app/app-universal-release.apk", "https://releases.grayjay.app/app-x86-release.apk", "https://releases.grayjay.app/app-release.apk", "https://releases.grayjay.app/version.txt"]}'
|
|
||||||
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
# Take site back online
|
|
||||||
echo "Bringing site back online..."
|
|
||||||
rm $DOCUMENT_ROOT/maintenance.file
|
|
||||||
+61
-36
@@ -1,47 +1,72 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DOCUMENT_ROOT=/var/www/html
|
set -eu
|
||||||
|
|
||||||
|
R2_ENDPOINT="https://$CF_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
|
||||||
|
|
||||||
|
r2_cp() {
|
||||||
|
src="$1"
|
||||||
|
key="$2"
|
||||||
|
cache_control="$3"
|
||||||
|
content_type="$4"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID="$CF_R2_ACCESS_KEY_ID" \
|
||||||
|
AWS_SECRET_ACCESS_KEY="$CF_R2_SECRET_ACCESS_KEY" \
|
||||||
|
AWS_DEFAULT_REGION=auto \
|
||||||
|
aws s3 cp "$src" "s3://$CF_R2_BUCKET/$key" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" \
|
||||||
|
--only-show-errors \
|
||||||
|
--cache-control "$cache_control" \
|
||||||
|
--content-type "$content_type"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_apk_latest_and_versioned() {
|
||||||
|
src="$1"
|
||||||
|
filename="$2"
|
||||||
|
|
||||||
|
r2_cp "$src" "$VERSION/$filename" \
|
||||||
|
"public, max-age=31536000, immutable" \
|
||||||
|
"application/vnd.android.package-archive"
|
||||||
|
|
||||||
|
r2_cp "$src" "$filename" \
|
||||||
|
"no-store" \
|
||||||
|
"application/vnd.android.package-archive"
|
||||||
|
}
|
||||||
|
|
||||||
# Sign sources
|
|
||||||
echo "Signing all sources..."
|
echo "Signing all sources..."
|
||||||
/usr/bin/bash ./sign-all-sources.sh
|
/usr/bin/bash ./sign-all-sources.sh
|
||||||
|
|
||||||
# Build content
|
|
||||||
echo "Building content..."
|
echo "Building content..."
|
||||||
./gradlew --stacktrace assembleUnstableRelease
|
./gradlew --stacktrace assembleUnstableRelease
|
||||||
|
|
||||||
# Take site offline
|
VERSION="$(git describe --tags)"
|
||||||
echo "Taking site offline..."
|
|
||||||
touch $DOCUMENT_ROOT/maintenance.file
|
|
||||||
|
|
||||||
# Swap over the content
|
echo "Deploying unstable artifacts to Cloudflare R2..."
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk" "app-x86_64-release-unstable.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk" "app-arm64-v8a-release-unstable.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk" "app-armeabi-v7a-release-unstable.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk" "app-universal-release-unstable.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk" "app-x86-release-unstable.apk"
|
||||||
|
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk" "app-release-unstable.apk"
|
||||||
|
|
||||||
|
tmp_version="$(mktemp)"
|
||||||
|
printf '%s\n' "$VERSION" > "$tmp_version"
|
||||||
|
r2_cp "$tmp_version" "$VERSION/version-unstable.txt" \
|
||||||
|
"public, max-age=31536000, immutable" \
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
r2_cp "$tmp_version" "version-unstable.txt" \
|
||||||
|
"no-store" \
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
rm -f "$tmp_version"
|
||||||
|
|
||||||
|
DOCUMENT_ROOT=/var/www/html
|
||||||
echo "Deploying content..."
|
echo "Deploying content..."
|
||||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk $DOCUMENT_ROOT/app-x86_64-release-unstable.apk
|
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk "$DOCUMENT_ROOT/app-x86_64-release-unstable.apk"
|
||||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-arm64-v8a-release-unstable.apk
|
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-arm64-v8a-release-unstable.apk"
|
||||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk $DOCUMENT_ROOT/app-armeabi-v7a-release-unstable.apk
|
cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk "$DOCUMENT_ROOT/app-armeabi-v7a-release-unstable.apk"
|
||||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk $DOCUMENT_ROOT/app-universal-release-unstable.apk
|
cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk "$DOCUMENT_ROOT/app-universal-release-unstable.apk"
|
||||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk $DOCUMENT_ROOT/app-x86-release-unstable.apk
|
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk "$DOCUMENT_ROOT/app-x86-release-unstable.apk"
|
||||||
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-release-unstable.apk
|
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-release-unstable.apk"
|
||||||
git describe --tags > $DOCUMENT_ROOT/version-unstable.txt
|
VERSION="$(git describe --tags)"
|
||||||
|
echo "$VERSION" > "$DOCUMENT_ROOT/version-unstable.txt"
|
||||||
|
|
||||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk s3://artifacts-grayjay-app/app-x86_64-release-unstable.apk
|
echo "Done."
|
||||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-arm64-v8a-release-unstable.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk s3://artifacts-grayjay-app/app-armeabi-v7a-release-unstable.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk s3://artifacts-grayjay-app/app-universal-release-unstable.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk s3://artifacts-grayjay-app/app-x86-release-unstable.apk
|
|
||||||
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-release-unstable.apk
|
|
||||||
|
|
||||||
git describe --tags > ./version-unstable.txt
|
|
||||||
aws s3 cp ./version-unstable.txt s3://artifacts-grayjay-app/version-unstable.txt
|
|
||||||
|
|
||||||
# Notify Cloudflare to wipe the CDN cache
|
|
||||||
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
|
|
||||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
|
|
||||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data '{"files":["https://releases.grayjay.app/app-x86_64-release-unstable.apk", "https://releases.grayjay.app/app-arm64-v8a-release-unstable.apk", "https://releases.grayjay.app/app-armeabi-v7a-release-unstable.apk", "https://releases.grayjay.app/app-universal-release-unstable.apk", "https://releases.grayjay.app/app-x86-release-unstable.apk", "https://releases.grayjay.app/app-release-unstable.apk", "https://releases.grayjay.app/version-unstable.txt"]}'
|
|
||||||
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
# Take site back online
|
|
||||||
echo "Bringing site back online..."
|
|
||||||
rm $DOCUMENT_ROOT/maintenance.file
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user