Compare commits

...

13 Commits

53 changed files with 841 additions and 95 deletions
@@ -16,7 +16,10 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformMultiClientPool
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod
@@ -51,6 +54,11 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
return ex;
}
fun warnIfMainThread(context: String) {
if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper())
Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace);
}
fun ensureNotMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) {
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
@@ -63,6 +71,8 @@ fun String.isHexColor(): Boolean {
return _regexHexColor.matches(this);
}
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
@@ -42,7 +42,8 @@ class AddSourceActivity : AppCompatActivity() {
private val _client = ManagedHttpClient();
private var _config : SourcePluginConfig? = null;
private var _config: SourcePluginConfig? = null;
private var _script: String? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -81,7 +82,7 @@ class AddSourceActivity : AppCompatActivity() {
}
_buttonInstall.setOnClickListener {
_config?.let {
install(_config!!);
install(_config!!, _script!!);
};
};
@@ -114,6 +115,7 @@ class AddSourceActivity : AppCompatActivity() {
setLoading(true);
lifecycleScope.launch(Dispatchers.IO) {
val config: SourcePluginConfig;
try {
val configResp = _client.get(url);
if(!configResp.isOk)
@@ -121,33 +123,51 @@ class AddSourceActivity : AppCompatActivity() {
val configJson = configResp.body?.string();
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
val config = SourcePluginConfig.fromJson(configJson, url);
withContext(Dispatchers.Main) {
loadConfig(config);
}
}
catch(ex: SerializationException) {
config = SourcePluginConfig.fromJson(configJson, url);
} catch(ex: SerializationException) {
Logger.e(TAG, "Failed decode config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error,
"Invalid Config Format", null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
};
}
catch(ex: Exception) {
return@launch;
} catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex);
};
return@launch;
}
val script: String?
try {
val scriptResp = _client.get(config.absoluteScriptUrl);
if (!scriptResp.isOk)
throw IllegalStateException("script not available [${scriptResp.code}]");
script = scriptResp.body?.string();
if (script.isNullOrEmpty())
throw IllegalStateException("script empty");
} catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch script", ex);
};
return@launch;
}
withContext(Dispatchers.Main) {
loadConfig(config, script);
}
};
}
fun loadConfig(config: SourcePluginConfig) {
private fun loadConfig(config: SourcePluginConfig, script: String) {
_config = config;
_script = script;
_sourceHeader.loadConfig(config);
_sourceHeader.loadConfig(config, script);
_sourcePermissions.removeAllViews();
_sourceWarnings.removeAllViews();
@@ -171,7 +191,7 @@ class AddSourceActivity : AppCompatActivity() {
val pastelRed = resources.getColor(R.color.pastel_red);
for(warning in config.getWarnings())
for(warning in config.getWarnings(script))
_sourceWarnings.addView(
SourceInfoView(this,
R.drawable.ic_security_pred,
@@ -182,8 +202,8 @@ class AddSourceActivity : AppCompatActivity() {
setLoading(false);
}
fun install(config: SourcePluginConfig) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config) {
fun install(config: SourcePluginConfig, script: String) {
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it)
backToSources();
}
@@ -0,0 +1,9 @@
package com.futo.platformplayer.activities
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
interface IWithResultLauncher {
fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit);
}
@@ -10,6 +10,9 @@ import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.FrameLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.WindowCompat
@@ -24,6 +27,7 @@ import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@@ -48,7 +52,7 @@ import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.*
class MainActivity : AppCompatActivity {
class MainActivity : AppCompatActivity, IWithResultLauncher {
//TODO: Move to dimensions
private val HEIGHT_MENU_DP = 48f;
@@ -364,6 +368,7 @@ class MainActivity : AppCompatActivity {
//startActivity(Intent(this, TestActivity::class.java));
}
/*
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
@@ -892,6 +897,28 @@ class MainActivity : AppCompatActivity {
_fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt());
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
companion object {
private val TAG = "MainActivity"
@@ -6,13 +6,16 @@ import android.os.Bundle
import android.view.View
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity() {
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
@@ -78,6 +81,28 @@ class SettingsActivity : AppCompatActivity() {
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
@@ -6,6 +6,7 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable
import com.futo.polycentric.core.combineHashCodes
import okhttp3.internal.platform.Platform
@kotlinx.serialization.Serializable
class PlatformID {
@@ -40,6 +41,8 @@ class PlatformID {
}
companion object {
val NONE = PlatformID("Unknown", null);
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
val contextName = "PlatformID";
return PlatformID(
@@ -49,5 +52,9 @@ class PlatformID {
value.getOrDefault(config, "claimType", contextName, 0) ?: 0,
value.getOrDefault(config, "claimFieldType", contextName, -1) ?: -1);
}
fun asUrlID(url: String): PlatformID {
return PlatformID("URL", url, null);
}
}
}
@@ -0,0 +1,38 @@
package com.futo.platformplayer.api.media
class PlatformMultiClientPool {
private val _maxCap: Int;
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
private var _isFake = false;
constructor(maxCap: Int = -1) {
_maxCap = if(maxCap > 0)
maxCap
else 99;
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
if(_isFake)
return parentClient;
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
this.onDead.subscribe { client, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
}
}
}
_clientPools[parentClient]!!;
};
return pool.getClient(capacity.coerceAtMost(_maxCap));
}
//Allows for testing disabling pooling without changing callers
fun asFake(): PlatformMultiClientPool {
_isFake = true;
return this;
}
}
@@ -4,6 +4,7 @@ import android.net.Uri
import com.futo.platformplayer.SignatureProvider
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.decodeFromString
import java.net.URL
import java.util.*
@@ -78,6 +79,15 @@ class SourcePluginConfig(
fun getWarnings(scriptToCheck: String? = null) : List<Pair<String,String>> {
val list = mutableListOf<Pair<String,String>>();
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
if (currentlyInstalledPlugin != null) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) {
list.add(Pair(
"Different Author",
"This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor."));
}
}
if(scriptPublicKey.isNullOrEmpty() || scriptSignature.isNullOrEmpty())
list.add(Pair(
"Missing Signature",
@@ -1,11 +1,14 @@
package com.futo.platformplayer.api.media.platforms.js.models
import android.os.Looper
import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.warnIfMainThread
abstract class JSPager<T> : IPager<T> {
protected val plugin: V8Plugin;
@@ -37,6 +40,8 @@ abstract class JSPager<T> : IPager<T> {
}
override fun nextPage() {
warnIfMainThread("JSPager.nextPage");
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
pager.invoke("nextPage", arrayOf<Any>());
};
@@ -53,6 +58,8 @@ abstract class JSPager<T> : IPager<T> {
}
override fun getResults(): List<T> {
warnIfMainThread("JSPager.getResults");
val previousResults = _lastResults?.let {
if(!_resultChanged)
return@let it;
@@ -6,6 +6,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.warnIfMainThread
class JSPlaybackTracker: IPlaybackTracker {
private val _config: IV8PluginConfig;
@@ -20,6 +21,7 @@ class JSPlaybackTracker: IPlaybackTracker {
private set;
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
warnIfMainThread("JSPlaybackTracker.constructor");
if(!obj.has("onProgress"))
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
if(!obj.has("nextRequest"))
@@ -31,6 +33,7 @@ class JSPlaybackTracker: IPlaybackTracker {
}
override fun onInit(seconds: Double) {
warnIfMainThread("JSPlaybackTracker.onInit");
synchronized(_obj) {
if(_hasCalledInit)
return;
@@ -44,6 +47,7 @@ class JSPlaybackTracker: IPlaybackTracker {
}
override fun onProgress(seconds: Double, isPlaying: Boolean) {
warnIfMainThread("JSPlaybackTracker.onProgress");
synchronized(_obj) {
if(!_hasCalledInit && _hasInit)
onInit(seconds);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.constructs
import android.provider.Settings.Global
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.*
@@ -39,8 +40,7 @@ class BatchedTaskHandler<TParameter, TResult> {
//Cached
if(result != null)
//TODO: Replace with some kind of constant Deferred<IPlatformStreamVideo>
return _scope.async { result as TResult }
return CompletableDeferred(result as TResult);
//Already requesting
if(taskResult != null)
return taskResult as Deferred<TResult>;
@@ -5,6 +5,16 @@ import com.google.android.exoplayer2.util.Log
class Stopwatch {
var startTime = System.nanoTime()
val elapsedMs: Double get() {
val now = System.nanoTime()
val diff = now - startTime
return diff / 1000000.0
}
fun reset() {
startTime = System.nanoTime()
}
fun logAndNext(tag: String, message: String): Long {
val now = System.nanoTime()
val diff = now - startTime
@@ -1,6 +1,7 @@
package com.futo.platformplayer.engine
import android.content.Context
import android.os.Looper
import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host
@@ -17,6 +18,7 @@ import com.futo.platformplayer.engine.exceptions.*
import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets
import kotlinx.coroutines.*
@@ -25,6 +27,7 @@ class V8Plugin {
private val _client: ManagedHttpClient;
private val _clientAuth: ManagedHttpClient;
val httpClient: ManagedHttpClient get() = _client;
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
@@ -137,6 +140,8 @@ class V8Plugin {
return executeTyped<V8Value>(js);
}
fun <T : V8Value> executeTyped(js: String) : T {
warnIfMainThread("V8Plugin.executeTyped");
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() };
}
@@ -84,8 +84,8 @@ class HomeFragment : MainFragment() {
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
_announcementsView = AnnouncementView(context).apply {
headerView.addView(AnnouncementView(context))
_announcementsView = AnnouncementView(context, null).apply {
headerView.addView(this);
};
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
@@ -32,7 +32,7 @@ class ImportSubscriptionsFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack);
super.onShownWithView(parameter, isBack);
_view?.onShown(parameter, isBack);
}
@@ -185,7 +185,7 @@ class SourceDetailFragment : MainFragment() {
val config = _config;
if (config != null) {
_sourceHeader.loadConfig(config);
_sourceHeader.loadConfig(config, StatePlugins.instance.getScript(config.id));
} else {
_sourceHeader.clear();
}
@@ -124,8 +124,8 @@ class SubscriptionsFeedFragment : MainFragment() {
if (announcementsView == null && !isHomeEnabled) {
val c = context;
if (c != null) {
_announcementsView = AnnouncementView(c).apply {
headerView?.addView(AnnouncementView(c))
_announcementsView = AnnouncementView(c, null).apply {
headerView?.addView(this)
};
}
}
@@ -94,6 +94,7 @@ import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.ui.PlayerControlView
import com.google.android.exoplayer2.ui.TimeBar
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
import com.google.common.base.Stopwatch
import com.google.protobuf.ByteString
import kotlinx.coroutines.*
import userpackage.Protocol
@@ -926,16 +927,26 @@ class VideoDetailView : ConstraintLayout {
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
this.video = video;
this._playbackTracker = null;
if(video is JSVideoDetails) {
val me = this;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val tracker = video.getPlaybackTracker() ?: StatePlatform.instance.getPlaybackTracker(video.url);
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
var tracker = video.getPlaybackTracker()
Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
if (tracker == null) {
stopwatch.reset()
tracker = StatePlatform.instance.getPlaybackTracker(video.url);
Logger.i(TAG, "StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms")
}
if(me.video == video)
me._playbackTracker = tracker;
}
catch(ex: Throwable) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to get Playback Tracker", ex);
};
}
@@ -991,6 +1002,7 @@ class VideoDetailView : ConstraintLayout {
_subTitle.text = subTitleSegments.joinToString("");
_rating.onLikeDislikeUpdated.remove(this);
if (ref != null) {
_rating.visibility = View.GONE;
@@ -1075,12 +1087,14 @@ class VideoDetailView : ConstraintLayout {
_layoutRating.visibility = View.GONE;
}
//Overlay
updateQualitySourcesOverlay(video);
setLoading(false);
//Set Mediasource
val toResume = _videoResumePositionMilliseconds;
_videoResumePositionMilliseconds = 0;
loadCurrentVideo(toResume);
@@ -1108,6 +1122,7 @@ class VideoDetailView : ConstraintLayout {
_textResume.text = "";
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
StatePlayer.instance.setCurrentlyPlaying(video);
@@ -14,6 +14,8 @@ class OrientationManager : OrientationEventListener {
constructor(context: Context) : super(context) { }
//TODO: Something weird is going on here
//TODO: Old implementation felt pretty good for me, but now with 0 deadzone still feels bad, even though code should be identical?
override fun onOrientationChanged(orientationAnglep: Int) {
if (orientationAnglep == -1) return
@@ -1,8 +1,13 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
@kotlinx.serialization.Serializable
class HistoryVideo {
@@ -18,4 +23,41 @@ class HistoryVideo {
this.position = position;
this.date = date;
}
fun toReconString(): String {
return "${video.url}|||${date.toEpochSecond()}|||${position}|||${video.name}";
}
companion object {
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo)? = null): HistoryVideo {
var index = str.indexOf("|||");
if(index < 0) throw IllegalArgumentException("Invalid history string: " + str);
val url = str.substring(0, index);
var indexNext = str.indexOf("|||", index + 3);
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
val dateSec = str.substring(index + 3, indexNext).toLong();
index = indexNext;
indexNext = str.indexOf("|||", index + 3);
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
val position = str.substring(index + 3, indexNext).toLong();
val name = str.substring(indexNext + 3);
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
id = PlatformID.asUrlID(url),
name = name,
thumbnails = Thumbnails(),
author = PlatformAuthorLink(PlatformID.NONE, "Unknown", ""),
datetime = null,
url = url,
shareUrl = url,
duration = 0,
viewCount = -1
);
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
}
}
}
@@ -2,7 +2,9 @@ package com.futo.platformplayer.states
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioManager
@@ -10,12 +12,20 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.util.DisplayMetrics
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.casting.StateCasting
@@ -43,6 +53,9 @@ class StateApp {
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay");
fun getExternalRootDirectory(): File? {
if(!externalRootDirectory.exists()) {
val result = externalRootDirectory.mkdirs();
@@ -158,6 +171,32 @@ class StateApp {
return state;
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, path: Uri?, handle: (Uri?)->Unit)
{
if(activity is Context)
{
UIDialogs.showDialog(activity, R.drawable.ic_security, "Missing Access", "Please grant access to ${name}", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
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
.and(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.and(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.and(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
}
}
//Lifecycle
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) {
_context = context;
@@ -1,12 +1,22 @@
package com.futo.platformplayer.states
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract.EXTRA_INITIAL_URI
import androidx.activity.ComponentActivity
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
@@ -22,6 +32,9 @@ import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.time.OffsetDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
@@ -34,6 +47,14 @@ class StateBackup {
private val _autoBackupLock = Object();
private fun getAutomaticBackupDocumentFiles(context: Context, root: Uri, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
val dir = DocumentFile.fromTreeUri(context, root);
if(dir == null)
throw IllegalStateException("Can't access external document files");
val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null;
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
return Pair(mainBackupFile, secondaryBackupFile);
}
private fun getAutomaticBackupFiles(): Pair<File, File> {
val dir = StateApp.instance.getExternalRootDirectory();
if(dir == null)
@@ -97,7 +118,13 @@ class StateBackup {
}
}
}
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
//TODO: This contains a temporary workaround to make it semi-compatible with > Android 11. By mixing "File" and "DocumentFile" usage.
//TODO: For now this is used to at least recover and gain temporary access to docs after losing access (due to permission lost after reinstall)
//TODO: Should be replaced with a more re-usable system that leverages OPEN_DOCUMENT_TREE once, and somehow persist this content after uninstall
//TODO: DocumentFiles are not compatible with normal files and require its own system.
//TODO: Investigate persistence of DOCUMENT_TREE files after uninstall...
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false, withStream: InputStream? = null) {
if(ifExists && !hasAutomaticBackup()) {
Logger.i(TAG, "No AutoBackup exists, not restoring");
return;
@@ -110,14 +137,33 @@ class StateBackup {
val backupFiles = getAutomaticBackupFiles();
try {
if (!backupFiles.first.exists())
if (!backupFiles.first.exists() && withStream == null)
throw IllegalStateException("Backup file does not exist");
val backupBytesEncrypted = backupFiles.first.readBytes();
val backupBytesEncrypted = if(withStream != null) withStream.readBytes() else backupFiles.first.readBytes();
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore");
} catch (ex: Throwable) {
}
catch (exSec: FileNotFoundException) {
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(SettingsActivity.getActivity() != null)
SettingsActivity.getActivity();
else if(StateApp.instance.isMainActive)
StateApp.instance.contextOrNull;
else null;
if(activity != null) {
if(activity is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", backupFiles.first.parent?.toUri()) {
if(it != null) {
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity, it);
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
restoreAutomaticBackup(context, scope, password, ifExists, activity.contentResolver.openInputStream(customFiles.first!!.uri));
}
};
}
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed main AutoBackup restore", ex)
if (!backupFiles.second.exists())
throw ex;
@@ -156,7 +202,8 @@ class StateBackup {
);
val storesToSave = getAllMigrationStores()
.associateBy { it.name }
.mapValues { it.value.getAllReconstructionStrings() };
.mapValues { it.value.getAllReconstructionStrings() }
.toMutableMap();
val settings = Settings.instance.encode();
val pluginSettings = StatePlugins.instance.getPlugins()
.associateBy { it.config.id }
@@ -166,7 +213,12 @@ class StateBackup {
.associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! };
return ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
//export.videoCache = StatePlaylists.instance.getHistory()
// .distinctBy { it.video.url }
// .map { it.video };
return export;
}
@@ -342,6 +394,7 @@ class StateBackup {
val plugins: Map<String, String>,
val pluginSettings: Map<String, Map<String, String?>>,
) {
var videoCache: List<SerializedPlatformVideo>? = null;
fun asZip(): ByteArray {
return ByteArrayOutputStream().use { byteStream ->
@@ -8,6 +8,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformClientPool
import com.futo.platformplayer.api.media.PlatformMultiClientPool
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.FilterGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -30,6 +31,7 @@ import com.futo.platformplayer.awaitFirstNotNullDeferred
import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fromPool
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
@@ -38,6 +40,7 @@ import com.futo.platformplayer.stores.*
import kotlinx.coroutines.*
import okhttp3.internal.concat
import java.time.OffsetDateTime
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
import kotlin.streams.toList
/***
@@ -45,7 +48,7 @@ import kotlin.streams.toList
*/
class StatePlatform {
private val TAG = "StatePlatform";
private val VIDEO_CACHE = 1024 * 1024 * 10;
private val VIDEO_CACHE = 100;
private val _scope = CoroutineScope(Dispatchers.IO);
@@ -61,7 +64,18 @@ class StatePlatform {
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
//ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video
//It also allows parallel usage of plugins that would otherwise be impossible.
//Pools always follow the behavior of the base client. So if user disables a plugin, it kills all pooled clients.
//Each pooled client adds additional memory usage.
//WARNING: Be careful with pooling some calls, as they might use the plugin subsequently afterwards. For example pagers might block plugins in future calls.
private val _mainClientPool = PlatformMultiClientPool(2); //Used for all main user events, generally user critical
private val _pagerClientPool = PlatformMultiClientPool(2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls.
private val _channelClientPool = PlatformMultiClientPool(15); //Used primarily for subscription/background channel fetches
private val _trackerClientPool = PlatformMultiClientPool(1); //Used exclusively for playback trackers
private val _liveEventClientPool = PlatformMultiClientPool(1); //Used exclusively for live events
private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient");
private var _primaryClientObj : IPlatformClient? = null;
@@ -83,14 +97,16 @@ class StatePlatform {
private val _batchTaskGetVideoDetails: BatchedTaskHandler<String, IPlatformContentDetails> = BatchedTaskHandler<String, IPlatformContentDetails>(_scope,
{ url ->
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
_enabledClients.find { it.isContentDetailsUrl(url) }?.getContentDetails(url)
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
_mainClientPool.getClientPooled(it).getContentDetails(url)
} ?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
},
{
if(!Settings.instance.browsing.videoCache)
return@BatchedTaskHandler null;
else {
val cached = synchronized(_cache) { _cache.get(it); } ?: return@BatchedTaskHandler null;
Logger.i(TAG, "Video Cache Hit [${cached.video.name}]");
if (cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
Logger.i(TAG, "Invalidated cache for [${it}]");
synchronized(_cache) {
@@ -231,21 +247,7 @@ class StatePlatform {
fun getClient(id: String): IPlatformClient {
return getClientOrNull(id) ?: throw IllegalArgumentException("Client with id $id does not exist");
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
this.onDead.subscribe { client, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
}
}
}
_clientPools[parentClient]!!;
};
return pool.getClient(capacity);
}
fun getClientsByClaimType(claimType: Int): List<IPlatformClient> {
return getEnabledClients().filter { it.isClaimTypeSupported(claimType) };
}
@@ -373,7 +375,7 @@ class StatePlatform {
synchronized(clientIdsOngoing) {
clientIdsOngoing.add(it.id);
}
val homeResult = it.getHome();
val homeResult = it.fromPool(_pagerClientPool).getHome();
synchronized(clientIdsOngoing) {
clientIdsOngoing.remove(it.id);
}
@@ -393,7 +395,7 @@ class StatePlatform {
val deferred: List<Pair<IPlatformClient, Deferred<IPager<IPlatformContent>?>>> = clients.map {
return@map Pair(it, scope.async(Dispatchers.IO) {
try {
val searchResult = it.getHome();
val searchResult = it.fromPool(_pagerClientPool).getHome();
return@async searchResult;
} catch(ex: Throwable) {
Logger.e(TAG, "getHomeRefresh", ex);
@@ -572,6 +574,7 @@ class StatePlatform {
pagers.put(it.searchChannels(query), 1f);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed search channels", ex)
UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})");
}
};
@@ -604,7 +607,12 @@ class StatePlatform {
}
fun getPlaybackTracker(url: String): IPlaybackTracker? {
return getContentClientOrNull(url)?.getPlaybackTracker(url);
val baseClient = getContentClientOrNull(url) ?: return null;
if (baseClient !is JSClient) {
return baseClient.getPlaybackTracker(url);
}
val client = _trackerClientPool.getClientPooled(baseClient, 1);
return client.getPlaybackTracker(url);
}
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
@@ -627,7 +635,7 @@ class StatePlatform {
val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1)
getClientPooled(baseClient, usePooledClients);
_channelClientPool.getClientPooled(baseClient, usePooledClients);
else baseClient;
var lastStream: OffsetDateTime? = null;
@@ -778,7 +786,7 @@ class StatePlatform {
if(!client.capabilities.hasGetComments)
return EmptyPager();
return client.getComments(url);
return client.fromPool(_mainClientPool).getComments(url);
}
fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
Logger.i(TAG, "Platform - getSubComments");
@@ -789,7 +797,7 @@ class StatePlatform {
fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? {
Logger.i(TAG, "Platform - getLiveChat");
var client = getContentClient(url);
return client.getLiveEvents(url);
return client.fromPool(_liveEventClientPool).getLiveEvents(url);
}
fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? {
Logger.i(TAG, "Platform - getLiveChat");
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -38,6 +39,11 @@ class StatePlaylists {
})
.load();
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
.withRestore(object: ReconstructStore<HistoryVideo>() {
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
= HistoryVideo.fromReconString(backup, null);
})
.load();
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup())
@@ -49,7 +55,7 @@ class StatePlaylists {
val onWatchLaterChanged = Event0();
fun toMigrateCheck(): List<ManagedStore<*>> {
return listOf(playlistStore, _watchlistStore);
return listOf(playlistStore, _watchlistStore, _historyStore);
}
fun getWatchLater() : List<SerializedPlatformVideo> {
@@ -122,6 +128,11 @@ class StatePlaylists {
}
if (shouldUpdate) {
//A unrecovered item
if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L)
historyVideo.video = SerializedPlatformVideo.fromVideo(video);
historyVideo.position = pos;
historyVideo.date = OffsetDateTime.now();
_historyStore.saveAsync(historyVideo);
@@ -177,18 +177,16 @@ class StatePlugins {
}
fun installPlugin(context: Context, scope: CoroutineScope, sourceUrl: String, handler: ((Boolean) -> Unit)? = null) {
scope.launch(Dispatchers.IO) {
val client = ManagedHttpClient();
val config: SourcePluginConfig;
try {
val configResp = ManagedHttpClient().get(sourceUrl);
val configResp = client.get(sourceUrl);
if(!configResp.isOk)
throw IllegalStateException("Failed request with ${configResp.code}");
val configJson = configResp.body?.string();
if(configJson.isNullOrEmpty())
throw IllegalStateException("No response");
val config = SourcePluginConfig.fromJson(configJson, sourceUrl);
withContext(Dispatchers.Main) {
installPlugin(context, scope, config, handler);
}
config = SourcePluginConfig.fromJson(configJson, sourceUrl);
}
catch(ex: SerializationException) {
Logger.e(TAG, "Failed decode config", ex);
@@ -199,8 +197,8 @@ class StatePlugins {
finish();
handler?.invoke(false);
}, UIDialogs.ActionStyle.PRIMARY));
};
return@launch;
}
catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex);
@@ -209,13 +207,36 @@ class StatePlugins {
handler?.invoke(false);
});
};
return@launch;
}
val script: String?
try {
val scriptResp = client.get(config.absoluteScriptUrl);
if (!scriptResp.isOk)
throw IllegalStateException("script not available [${scriptResp.code}]");
script = scriptResp.body?.string();
if (script.isNullOrEmpty())
throw IllegalStateException("script empty");
} catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to fetch script", ex);
};
return@launch;
}
withContext(Dispatchers.Main) {
installPlugin(context, scope, config, script, handler);
}
}
}
fun installPlugin(context: Context, scope: CoroutineScope, config: SourcePluginConfig, handler: ((Boolean)->Unit)? = null) {
fun installPlugin(context: Context, scope: CoroutineScope, config: SourcePluginConfig, script: String, handler: ((Boolean)->Unit)? = null) {
val client = ManagedHttpClient();
val warnings = config.getWarnings();
if (script.isEmpty())
throw IllegalStateException("script empty");
fun doInstall(reinstall: Boolean) {
UIDialogs.showDialogProgress(context) {
@@ -224,13 +245,6 @@ class StatePlugins {
scope.launch(Dispatchers.IO) {
try {
val scriptResp = client.get(config.absoluteScriptUrl);
if (!scriptResp.isOk)
throw IllegalStateException("script not available [${scriptResp.code}]");
val script = scriptResp.body?.string();
if (script.isNullOrEmpty())
throw IllegalStateException("script empty");
withContext(Dispatchers.Main) {
it.setText("Validating script...");
it.setProgress(0.25);
@@ -5,6 +5,8 @@ import android.util.AttributeSet
import android.view.View
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
@@ -14,6 +16,9 @@ import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanNowDiffString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AnnouncementView : LinearLayout {
private val _root: ConstraintLayout;
@@ -28,9 +33,13 @@ class AnnouncementView : LinearLayout {
private val _category: String?;
private var _currentAnnouncement: Announcement? = null;
private val _scope: CoroutineScope?;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_announcement, this);
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull; //TODO: Fetch correct scope
val dp10 = 10.dp(resources);
setPadding(dp10, dp10, dp10, dp10);
@@ -73,7 +82,9 @@ class AnnouncementView : LinearLayout {
super.onAttachedToWindow()
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
refresh();
_scope?.launch(Dispatchers.Main) {
refresh();
}
}
refresh();
@@ -23,6 +23,7 @@ class SourceHeaderView : LinearLayout {
private val _sourceVersion: TextView;
private val _sourceRepositoryUrl: TextView;
private val _sourceScriptUrl: TextView;
private val _sourceSignature: TextView;
private var _config : SourcePluginConfig? = null;
@@ -38,6 +39,7 @@ class SourceHeaderView : LinearLayout {
_sourceVersion = findViewById(R.id.source_version);
_sourceRepositoryUrl = findViewById(R.id.source_repo);
_sourceScriptUrl = findViewById(R.id.source_script);
_sourceSignature = findViewById(R.id.source_signature);
_sourceBy.setOnClickListener {
if(!_config?.authorUrl.isNullOrEmpty())
@@ -53,7 +55,7 @@ class SourceHeaderView : LinearLayout {
};
}
fun loadConfig(config: SourcePluginConfig) {
fun loadConfig(config: SourcePluginConfig, script: String?) {
_config = config;
val loadedIcon = StatePlugins.instance.getPluginIconOrNull(config.id);
@@ -76,6 +78,22 @@ class SourceHeaderView : LinearLayout {
_sourceBy.setTextColor(resources.getColor(R.color.colorPrimary));
else
_sourceBy.setTextColor(Color.WHITE);
if (!config.scriptPublicKey.isNullOrEmpty() && !config.scriptSignature.isNullOrEmpty()) {
if (script == null) {
_sourceSignature.setTextColor(Color.rgb(0xAC, 0xAC, 0xAC));
_sourceSignature.text = "Script is not available";
} else if (config.validate(script)) {
_sourceSignature.setTextColor(Color.rgb(0, 255, 0));
_sourceSignature.text = "Signature is valid";
} else {
_sourceSignature.setTextColor(Color.rgb(255, 0, 0));
_sourceSignature.text = "Signature is invalid";
}
} else {
_sourceSignature.setTextColor(Color.rgb(255, 0, 0));
_sourceSignature.text = "No signature available";
}
}
fun clear() {
@@ -144,4 +144,27 @@
android:fontFamily="@font/inter_extra_light"
android:text="https://some.repository.url/whatever/someScript.js" />
</LinearLayout>
<!--Script Url-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/white"
android:layout_marginTop="10dp"
android:fontFamily="@font/inter_light"
android:text="Signature" />
<TextView
android:id="@+id/source_signature"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/colorPrimary"
android:fontFamily="@font/inter_extra_light"
android:text="Valid" />
</LinearLayout>
</LinearLayout>
+4
View File
@@ -1,6 +1,10 @@
#!/bin/sh
DOCUMENT_ROOT=/var/www/html
# Sign sources
echo "Signing all sources..."
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
./gradlew --stacktrace bundlePlaystoreRelease
+4
View File
@@ -1,6 +1,10 @@
#!/bin/sh
DOCUMENT_ROOT=/var/www/html
# Sign sources
echo "Signing all sources..."
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
./gradlew --stacktrace assembleStableRelease
+4
View File
@@ -1,6 +1,10 @@
#!/bin/sh
DOCUMENT_ROOT=/var/www/html
# Sign sources
echo "Signing all sources..."
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
./gradlew --stacktrace assembleUnstableRelease
+279
View File
@@ -0,0 +1,279 @@
# Content Types
This page will cover the various types of content that are supported, and how to present them to Grayjay.
While Grayjay is primarily used for video, it supports various types of video, audio, but also text, images, and articles. In the future more types of content support might be added!
Content can be presented as a feed object, or a detail object. Feed objects are objects you see inside feeds and overviews such as the Home and Subscription tabs. Generally detail objects have an accompanying overview object.
Feed items are often returned in pagers, the following are some plugin methods that expect a pager of feed items:
```
source.getHome()
source.getChannelContents(...)
```
Content details are generally retrieved using
```
source.getContentDetails(url)
```
Note that all detail objects can be considered feed objects, but not the other way around. When you return a detail object in places where feed object is expected, and the user tries to open said item in a detail view, the ```GetContentDetail``` call is skipped, and the item is immediately shown without loading details.
# Feed Types
Feed types represent content in a feed or overview page. Most feed types have both a thumbnail and preview visualization, where they are displayed slightly differently. The plugin is not aware of these differences though.
## PlatformContent
All feed objects inherit PlatformContent, and always have the following properties:
```kotlin
class PlatformContent
{
id: PlatformID,
name: String,
thumbnails: ThumbNails,
author: PlatformAuthorLink,
datetime: Int, // (UnixTimeStamp)
url: String
}
```
## PlatformVideo
A feed object representing a video or audio.
*Usage:*
```javascript
new PlatformVideo({
id: new PlatformID("SomePlatformName", "SomeId", config.id),
name: "Some Video Name",
thumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
]),
author: new AuthorLink(
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
"SomeAuthorName",
"https://platform.com/your/channel/url",
"../url/to/thumbnail.png"),
uploadDate: 1696880568,
duration: 120,
viewCount: 1234567,
url: "https://platform.com/your/detail/url",
isLive: false
});
```
## PlatformPost
A feed object representing a community post with text, and optionally images.
*Usage:*
```javascript
new PlatformPost{
id: new PlatformID(config.name, item?.id, config.id),
name: item?.attributes?.title,
author: getPlatformAuthorLink(item, context),
datetime: (Date.parse(item?.attributes?.published_at) / 1000),
url: item?.attributes?.url,
description: "Description of Post",
images: ["../url/to/image1.png", "../url/to/image2.png"],
thumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
])
});
```
## PlatformNestedMediaContent
A feed object representing a link to a different item (often handled by a different plugin).
An example is a Patreon video, that links to an unlisted Youtube video. If no plugin exists to handle the content, it will be opened in an in-app browser.
A nested item consists of an detail url and optional metadata such as name, description, thumbnails, etc.
*Usage:*
```javascript
new PlatformNestedMediaContent({
id: new PlatformID("SomePlatformName", "SomeId", config.id),
name: "Name of content link",
author: new AuthorLink(
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
"SomeAuthorName",
"https://platform.com/your/channel/url",
"../url/to/thumbnail.png"),,
datetime: 1696880568,
url: item?.attributes?.url,
contentUrl: "https://someplatform.com/detail/url",
contentName: "OptionalName",
contentDescription: "OptionalDescription",
contentProvider: "OptionalPlatformName",
contentThumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
])
});
```
# Detail Types
Detail types represent content on a detail page.
## PlatformVideoDetails
A detail object representing a video or audio. It inherits PlatformVideo.
### Usage:
```javascript
new PlatformVideoDetails({
id: new PlatformID("SomePlatformName", "SomeId", config.id),
name: "Some Video Name",
thumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
]),
author: new AuthorLink(
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
"SomeAuthorName",
"https://platform.com/your/channel/url",
"../url/to/thumbnail.png"),
uploadDate: 1696880568,
duration: 120,
viewCount: 1234567,
url: "https://platform.com/your/detail/url",
isLive: false,
description: "Some description",
video: new VideoSourceDescriptor([]), //See sources
live: null,
rating: new RatingLikes(123),
subtitles: []
});
```
### Live Streams
If your video is live, the ```isLive``` property should be ```true```, and the ```live``` property should be set to a ```HLSSource```, ```DashSource```, or equivelant.
### UnMuxed and Audio-Only
If your content is either audio-only (eg. music), or has seperate video/audio tracks, you want to use ```UnMuxedVideoDescriptor``` instead of ```VideoSourceDescriptor```:
```javascript
new UnMuxedVideoDescriptor(
[videoSource1, videoSource2, ...],
[audioSource1, audioSource2, ...]
);
```
### Sources
Inside a VideoDescriptor you need to provide an array of sources.
Below you can find several source types that Grayjay supports:
**Standard Url Video/Audio**
These are videos available directly on a single url.
```javascript
new VideoUrlSource({
width: 1920,
height: 1080,
container: "video/mp4",
codec: "avc1.4d401e",
name: "1080p30 mp4",
bitrate: 188103,
duration: 250,
url: "https://platform.com/some/video/url.mp4"
});
//For audio:
new AudioUrlSource({
container: "audio/mp4",
codec: "mp4a.40.2",
name: "mp4a.40.2",
bitrate: 131294,
duration: 250,
url: "https://platform.com/some/video/url.mp4a",
language: "Unknown"
});
```
**Range Url Video/Audio**
These are more complex url sources that require very specific range headers to function. They require correct initialization and index positions.
These are converted to Dash manifests.
```javascript
new VideoUrlRangeSource({
width: 1920,
height: 1080,
container: "video/mp4",
codec: "avc1.4d401e",
name: "1080p30 mp4",
bitrate: 188103,
duration: 250,
url: "https://platform.com/some/video/url.mp4",
itagId: 1234, //Optional
initStart: 0,
initEnd: 219,
indexStart: 220,
indexEnd: 791
});
//For Audio
new AudioUrlRangeSource({
container: "audio/mp4",
codec: "mp4a.40.2",
name: "mp4a.40.2",
bitrate: 131294,
duration: 250,
url: "https://platform.com/some/video/url.mp4a",
language: "Unknown"
itagId: 1234, //Optional
initStart: 0,
initEnd: 219,
indexStart: 220,
indexEnd: 791,
audioChannels: 2
});
```
**HLSSource**
These are sources that are described in a HLS Manifest.
```javascript
new HLSSource({
name: "SomeName", //Optional
duration: 250, //Optional
url: "https://platform.com/some/hls/manifest.m3u8",
priority: false, //Optional
language: "Unknown" //Optional
});
```
Generally, HLS sources deprioritized in Grayjay. However if your platform requires HLS sources to be prioritized, you set ```priority``` to ```true```.
**DashSource**
These are sources that are described in a Dash Manifest.
```javascript
new DashSource({
name: "SomeName", //Optional
duration: 250, //Optional
url: "https://platform.com/some/dash/manifest.mpd"
});
```
## PlatformPostDetails
A detail object representing a text with optionally accompanying images. The text can be either raw text or html (and possibly in future markup).
### Usage:
```javascript
new PlatformPostDetails{
id: new PlatformID(config.name, item?.id, config.id),
name: item?.attributes?.title,
author: getPlatformAuthorLink(item, context),
datetime: (Date.parse(item?.attributes?.published_at) / 1000),
url: item?.attributes?.url,
description: "Description of Post",
images: ["../url/to/image1.png", "../url/to/image2.png"],
thumbnails: new Thumbnails([
new Thumbnail("https://.../thumbnail1.png", 720),
new Thumbnail("https://.../thumbnail2.png", 1080),
]),
rating: new RatingLikes(123),
textType: Type.Text.Html/Raw/Markup,
content: "Your post content in either raw, html, or in future markup."
});
```
+40
View File
@@ -0,0 +1,40 @@
# Pagers
Within Grayjay there are several situations where Pagers are used to communicate multiple pages of data back to the app. Some examples are home feed, channel contents, comments, live events, etc.
All these pagers have exact same layout and usage, with only some very specific cases where additional functionality is exposed.
Some example of base pagers that exist:
**ContentPager** for feed objects
**ChannelPager** for channels
**PlaylistPager** for playlists
**CommentPager** for comments
An example of a pager implementation is as follows:
```javascript
class MyPlatformContentPager extends ContentPager {
constructor(someInfo) {
super([], true); //Alternatively, pass first page results in []
this.someInfo = someInfo;
}
nextPage() {
const myNewResults = //Fetch your next page
this.results = myNewResults;
this.hasMore = true; //Or false if last page
}
}
```
You can also choose to return an entirely new pager object in nextPage, but this is **NOT RECOMMENDED** as it generates a new object for every page. But can be convenient in some recursive situations.
```
nextPage() {
return new MyPlatformContentPager(...);
}
```
In this case the new pager will replace the parent.
If you ever just want to return an empty pager without any results, you can choose to directly use the base pagers as follows:
```
return new ContentPager([], false);
```
Which effectively says *"First page is empty, and no next page"*.
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
PRIVATE_KEY=$(openssl genpkey -algorithm RSA -outform PEM)
PUBLIC_KEY=$(echo "$PRIVATE_KEY" | openssl rsa -pubout -outform PEM)
echo -en "\nPrivate key:\n$PRIVATE_KEY\n"
echo -en "\nPrivate key (base64):\n$(echo "$PRIVATE_KEY" | base64 -w 0)\n"
echo -en "\nPublic key:\n$PUBLIC_KEY\n"
echo -en "\nPublic key (base64):\n$(echo "$PUBLIC_KEY" | base64 -w 0)\n"