diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 00000000..eb9709af
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,36 @@
+variables:
+ GIT_SUBMODULE_STRATEGY: recursive
+
+stages:
+- buildAndDeployApkUnstable
+- buildAndDeployApkStable
+
+buildAndDeployApkUnstable:
+ stage: buildAndDeployApkUnstable
+ script:
+ - sh deploy-unstable.sh
+ only:
+ - tags
+ except:
+ - ^(dev)
+ when: manual
+
+buildAndDeployApkStable:
+ stage: buildAndDeployApkStable
+ script:
+ - sh deploy-stable.sh
+ only:
+ - tags
+ except:
+ - branches
+ when: manual
+
+buildAndDeployApkStable:
+ stage: buildAndDeployApkStable
+ script:
+ - sh deploy-playstore.sh
+ only:
+ - tags
+ except:
+ - branches
+ when: manual
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..3c5b4994
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,63 @@
+[submodule "dep/polycentricandroid"]
+ path = dep/polycentricandroid
+ url = ../polycentricandroid.git
+[submodule "app/src/playstore/assets/sources/peertube"]
+ path = app/src/playstore/assets/sources/peertube
+ url = ../plugins/peertube.git
+[submodule "app/src/stable/assets/sources/kick"]
+ path = app/src/stable/assets/sources/kick
+ url = ../plugins/kick.git
+[submodule "app/src/stable/assets/sources/odysee"]
+ path = app/src/stable/assets/sources/odysee
+ url = ../plugins/odysee.git
+[submodule "app/src/stable/assets/sources/nebula"]
+ path = app/src/stable/assets/sources/nebula
+ url = ../plugins/nebula.git
+[submodule "app/src/stable/assets/sources/patreon"]
+ path = app/src/stable/assets/sources/patreon
+ url = ../plugins/patreon.git
+[submodule "app/src/stable/assets/sources/peertube"]
+ path = app/src/stable/assets/sources/peertube
+ url = ../plugins/peertube.git
+[submodule "app/src/stable/assets/sources/rumble"]
+ path = app/src/stable/assets/sources/rumble
+ url = ../plugins/rumble.git
+[submodule "app/src/stable/assets/sources/soundcloud"]
+ path = app/src/stable/assets/sources/soundcloud
+ url = ../plugins/soundcloud.git
+[submodule "app/src/stable/assets/sources/twitch"]
+ path = app/src/stable/assets/sources/twitch
+ url = ../plugins/twitch.git
+[submodule "app/src/stable/assets/sources/youtube"]
+ path = app/src/stable/assets/sources/youtube
+ url = ../plugins/youtube.git
+[submodule "app/src/unstable/assets/sources/kick"]
+ path = app/src/unstable/assets/sources/kick
+ url = ../plugins/kick.git
+[submodule "app/src/unstable/assets/sources/nebula"]
+ path = app/src/unstable/assets/sources/nebula
+ url = ../plugins/nebula.git
+[submodule "app/src/unstable/assets/sources/odysee"]
+ path = app/src/unstable/assets/sources/odysee
+ url = ../plugins/odysee.git
+[submodule "app/src/unstable/assets/sources/patreon"]
+ path = app/src/unstable/assets/sources/patreon
+ url = ../plugins/patreon.git
+[submodule "app/src/unstable/assets/sources/peertube"]
+ path = app/src/unstable/assets/sources/peertube
+ url = ../plugins/peertube.git
+[submodule "app/src/unstable/assets/sources/rumble"]
+ path = app/src/unstable/assets/sources/rumble
+ url = ../plugins/rumble.git
+[submodule "app/src/unstable/assets/sources/soundcloud"]
+ path = app/src/unstable/assets/sources/soundcloud
+ url = ../plugins/soundcloud.git
+[submodule "app/src/unstable/assets/sources/twitch"]
+ path = app/src/unstable/assets/sources/twitch
+ url = ../plugins/twitch.git
+[submodule "app/src/unstable/assets/sources/youtube"]
+ path = app/src/unstable/assets/sources/youtube
+ url = ../plugins/youtube.git
+[submodule "dep/futopay"]
+ path = dep/futopay
+ url = ../futopayclientlibraries.git
diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md
new file mode 100644
index 00000000..dd7891bc
--- /dev/null
+++ b/CONTRIBUTION.md
@@ -0,0 +1,60 @@
+# Contribution Guidelines
+
+## Table of Contents
+
+1. [Introduction](#introduction)
+2. [Contributing to Official Plugins](#contributing-to-official-plugins)
+3. [Creating Your Own Plugins](#creating-your-own-plugins)
+4. [Contributing to Core](#contributing-to-core)
+
+---
+
+## Introduction
+
+Thank you for your interest in contributing! This document outlines how you can contribute to the official plugins and also encourages you to write your own plugins. Please read this guide carefully to understand how you can collaborate with us.
+
+---
+
+## Contributing to Official Plugins
+
+### License
+
+The official plugins for this project are licensed under GPLv3. Any contributions you make will also fall under the GPLv3 license.
+
+### How to Contribute
+
+1. Fork the repository containing the plugin.
+2. Clone your fork.
+3. Make your changes.
+4. Commit and push your changes.
+5. Open a pull request.
+
+### Guidelines
+
+- Ensure your code adheres to the existing style.
+- Include documentation and unit tests (where applicable).
+
+---
+
+## Creating Your Own Plugins
+
+We encourage developers to write their own plugins. Please refer to the "Getting Started" documentation to learn how to create a plugin for the app.
+
+### Guidelines
+
+- Your plugin's license must be compatible with the core application's license.
+- We encourage you to make your plugin open-source, although it's not mandatory.
+
+---
+
+## Contributing to Core
+
+**We are currently not accepting contributions to the core.**
+
+The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity.
+
+---
+
+Thank you for reading the contribution guidelines. Happy contributing!
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..263689a2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,205 @@
+# PlatformPlayer
+
+The FUTO media app endeavours creating infrastructure for creators to have their content hosted by someone else but at the same time having creators retain full ownership of their content. We want creators to feel like they are publishing to the world, and we want multiple indexers competing with each other to do a good job connecting consumers to creators and their content.
+
+One part of the solution is to create an application that allows users to search through all available media websites and giving creators the tools for direct monetization of their content by allowing users to directly donate to the content creator.
+
+FUTO is an organization dedicated to developing, both through in-house engineering and investment,
+technologies that frustrate centralization and industry consolidation.
+
+
+
+
+
+
+
+
Video
+
Video (details)
+
+
+
+## What does the app do?
+
+The FUTO media app is a player that exposes multiple video websites as sources in the app. These sources can be easily configured and third-party sources can also manually be added. This is done through the sources UI.
+
+
+
+
+
+
+
+
Sources (all enabled)
+
Sources (one disabled)
+
+
+
+Additional sources can also be installed. These sources are JavaScript sources, created and maintained by the community.
+
+
+
+
+
+
+
+
Install a new source
+
Configure a source
+
+
+
+Once the sources are configured, the combined results will be shown throughout the app. The core features of the app will be highlighted below.
+
+### Searching
+
+When a user enters a search term into the search bar, the query is posted to the underlying platforms and a list of results that are ranked by relevance is returned. The search functionality of the app allows users to search multiple sources at once, allowing users to discover a wider range of content that is relevant to their interests.
+
+
+
+
+
+
+
+
Search (list)
+
Search (preview)
+
+
+
+### Channels
+
+Channels allow users to view the creators content, read more about them or support them by donating, purchasing from their store or buying a membership. The FUTO media app only links to other stores and the app does not play an intermediate role in the actual purchase process. This way, creators can directly monetize their own content in the way they like.
+
+Creators are able to configure their profile using NeoPass.
+
+
+
+
+
+
+
Channel
+
+
+
+### Feed
+
+Subscriptions are a way for users to keep up with the latest videos and content from their favorite creators. The creators you are subscribed to are shown in the creators tab. In the future we will add both creator search and suggested creators.
+
+
+
+
+
+
+
Creators
+
+
+
+When you subscribe to a creator, you'll be able to find new videos uploaded by them in the subscriptions tab.
+
+
+
+
+
+
+
+
Subscriptions (list)
+
Subscriptions (preview)
+
+
+
+Additionally there is also the "Home" feed which is based purely on recommendations by the underlying platforms. Also here we hope to offer user-picked recommendation engines in the future.
+
+## Settings
+
+The app offers a lot of settings customizing how the app looks and feels. An example of this is the background behaviour, do you wish to have it use picture in picture, background play or shut off entirely. Another example configuration option is choosing between list views or video previews.
+
+
+
+
+
+
+
Settings
+
+
+
+### Playlists
+
+Playlists allow you to make a collection of videos that you can create and customize to your liking. When you add videos to a playlist, they're grouped together in a single location, making it easy for you to find and watch all of the videos in the playlist in sequence.
+
+
+
+
+
+
+
+
Playlists
+
Playlist
+
+
+
+Playlists can also be downloaded in their entirety.
+
+### Downloads
+
+Both individual videos and playlists can be downloaded for local, offline playback. You can watch downloaded videos any time, even if you do not have an active internet connection.
+
+
+
+
+
+
+
Downloads
+
+
+
+### Casting
+
+The app can also cast to a big screen using any of the supported protocols (FastCast, ChromeCast, AirPlay). Not all casting protocols support all features. As a rule of thumb feature-wise FastCast > ChromeCast > AirPlay.
+
+For more information about casting please click [here](./docs/casting.md).
+
+
+
+
+
+
+
Casting
+
+
+
+### Commenting and rating
+
+The app can also cast to comment and rate. For more information about this please click [here](./docs/polycentric.md).
+
+### Creator Linking
+
+The app can also cast to link channels together. For more information about this please click [here](./docs/linking.md).
+
+### Migration and recommendations
+
+Sources have the ability to login, allowing you to use features that require credentials like importing your playlists, importing your subscriptions or have personalized recommendations. Some platforms may require a membership to work at all.
+
+In the future we hope to offer users the choice of their desired recommendation engine and have multiple competing recommendation engines for different audiences.
+
+## Building
+
+1. Download a copy of the repository.
+2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
+3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
+4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
+
+## Contributing
+
+Please see [CONTRIBUTION.md](./CONTRIBUTION.md).
+
+## CI/CD
+
+Tests will always run and are required to pass before a merge request is allowed to be merged. The build/deploy CI/CD steps will only be triggered by a tag on the master branch.
+
+### Making a new build
+
+Create a tag on the master branch, incrementing the last version number by 1 (for example `25` to `26`).
+
+Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
+
+
+## Documentation
+
+The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000..40ce21c2
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,207 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
+ id 'org.ajoberstar.grgit' version '1.7.2'
+ id 'com.google.protobuf'
+ id 'kotlin-parcelize'
+}
+
+ext {
+ gitVersionName = grgit.describe()
+ gitVersionCode = gitVersionName.isInteger() ? gitVersionName.toInteger() : 1
+}
+
+println("Version Name: $gitVersionName")
+println("Version Code: $gitVersionCode")
+
+def keystoreProperties = new Properties()
+def keystorePropertiesFile = rootProject.file('/opt/key.properties')
+if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
+}
+
+protobuf {
+ protoc {
+ artifact = 'com.google.protobuf:protoc:3.22.3'
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ java {
+ option "lite"
+ }
+ }
+ }
+ }
+}
+
+android {
+ namespace 'com.futo.platformplayer'
+ compileSdk 33
+ flavorDimensions "buildType"
+ productFlavors {
+ stable {
+ dimension "buildType"
+ applicationId "com.futo.platformplayer"
+ buildConfigField "boolean", "IS_UNSTABLE_BUILD", "false"
+ buildConfigField "boolean", "IS_PLAYSTORE_BUILD", "false"
+ resValue "string", "app_name", "Grayjay"
+ resValue "string", "authority", "com.futo.platformplayer"
+ }
+ unstable {
+ dimension "buildType"
+ applicationId "com.futo.platformplayer.d"
+ buildConfigField "boolean", "IS_UNSTABLE_BUILD", "true"
+ buildConfigField "boolean", "IS_PLAYSTORE_BUILD", "false"
+ resValue "string", "app_name", "Grayjay Unstable"
+ resValue "string", "authority", "com.futo.platformplayer.d"
+ getIsDefault().set(true)
+ }
+ playstore {
+ dimension "buildType"
+ applicationId "com.futo.platformplayer.playstore"
+ buildConfigField "boolean", "IS_UNSTABLE_BUILD", "false"
+ buildConfigField "boolean", "IS_PLAYSTORE_BUILD", "true"
+ resValue "string", "app_name", "Grayjay"
+ resValue "string", "authority", "com.futo.platformplayer.playstore"
+ }
+ }
+
+ android.applicationVariants.all { variant ->
+ if (variant.flavorName == "unstable") {
+ variant.preBuildProvider.configure {
+ doFirst {
+ println("UNSTABLE BUILD")
+ }
+ }
+ }
+
+ if (variant.flavorName == "stable") {
+ variant.preBuildProvider.configure {
+ doFirst {
+ println("STABLE BUILD")
+ }
+ }
+ }
+
+ if (variant.flavorName == "playstore") {
+ variant.preBuildProvider.configure {
+ doFirst {
+ println("PLAYSTORE BUILD")
+ }
+ }
+ }
+ }
+
+ defaultConfig {
+ minSdk 29
+ targetSdk 33
+ versionCode gitVersionCode
+ versionName gitVersionName
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ signingConfigs {
+ release {
+ keyAlias keystoreProperties['keyAlias']
+ keyPassword keystoreProperties['keyPassword']
+ storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
+ storePassword keystoreProperties['storePassword']
+ }
+ }
+
+ buildTypes {
+ release {
+ signingConfig signingConfigs.release
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ splits {
+ abi {
+ enable true
+ reset()
+
+ include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
+ universalApk true
+ }
+ }
+}
+
+dependencies {
+
+ //Core
+ implementation 'androidx.core:core-ktx:1.7.0'
+ implementation 'androidx.appcompat:appcompat:1.4.1'
+ implementation 'com.google.android.material:material:1.5.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
+
+ //Images
+ annotationProcessor 'com.github.bumptech.glide:compiler:4.15.1'
+ implementation 'com.github.bumptech.glide:glide:4.15.1'
+
+ //Async
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2"
+
+ //HTTP
+ implementation "com.squareup.okhttp3:okhttp:4.10.0"
+
+ //JSON
+ implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" //Used for structured json
+ implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
+
+ //JS
+ implementation("com.caoccao.javet:javet-android:2.2.1")
+
+ //Exoplayer
+ implementation 'com.google.android.exoplayer:exoplayer-core:2.18.7'
+ implementation 'com.google.android.exoplayer:exoplayer-dash:2.18.7'
+ implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.7'
+ implementation 'com.google.android.exoplayer:exoplayer-hls:2.18.7'
+ implementation 'com.google.android.exoplayer:exoplayer-rtsp:2.18.7'
+ implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.18.7'
+ implementation 'com.google.android.exoplayer:exoplayer-transformer:2.18.7'
+ implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
+ implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
+
+ //Other
+ implementation 'org.jmdns:jmdns:3.5.1'
+ implementation 'org.jsoup:jsoup:1.15.3'
+ implementation 'com.google.android.flexbox:flexbox:3.0.0'
+ implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+ implementation 'com.arthenica:ffmpeg-kit-full:5.1'
+ implementation 'org.jetbrains.kotlin:kotlin-reflect:1.7.20'
+ implementation 'com.github.dhaval2404:imagepicker:2.1'
+ implementation 'com.google.zxing:core:3.4.1'
+ implementation 'com.journeyapps:zxing-android-embedded:4.2.0'
+ implementation 'com.caverock:androidsvg-aar:1.4'
+
+ //Protobuf
+ implementation 'com.google.protobuf:protobuf-javalite:3.22.3'
+
+ implementation 'com.polycentric.core:app:1.0'
+ implementation 'com.futo.futopay:app:1.0'
+ implementation 'androidx.work:work-runtime-ktx:2.8.1'
+ implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
+
+ //Payment
+ implementation 'com.stripe:stripe-android:20.28.3'
+
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
+ testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.20"
+ testImplementation "org.xmlunit:xmlunit-core:2.9.1"
+ testImplementation "org.mockito:mockito-core:5.4.0"
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/futo/platformplayer/EncryptionProviderTests.kt b/app/src/androidTest/java/com/futo/platformplayer/EncryptionProviderTests.kt
new file mode 100644
index 00000000..f4605efd
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/EncryptionProviderTests.kt
@@ -0,0 +1,62 @@
+package com.futo.platformplayer
+
+import com.futo.platformplayer.encryption.EncryptionProvider
+import junit.framework.TestCase.assertEquals
+import org.junit.Test
+
+class EncryptionProviderTests {
+ @Test
+ fun testEncryptDecrypt() {
+ val encryptionProvider = EncryptionProvider.instance
+ val plaintext = "This is a test string."
+
+ // Encrypt the plaintext
+ val ciphertext = encryptionProvider.encrypt(plaintext)
+
+ // Decrypt the ciphertext
+ val decrypted = encryptionProvider.decrypt(ciphertext)
+
+ // The decrypted string should be equal to the original plaintext
+ assertEquals(plaintext, decrypted)
+ }
+
+
+ @Test
+ fun testEncryptDecryptBytes() {
+ val encryptionProvider = EncryptionProvider.instance
+ val bytes = "This is a test string.".toByteArray();
+
+ // Encrypt the plaintext
+ val ciphertext = encryptionProvider.encrypt(bytes)
+
+ // Decrypt the ciphertext
+ val decrypted = encryptionProvider.decrypt(ciphertext)
+
+ // The decrypted string should be equal to the original plaintext
+ assertArrayEquals(bytes, decrypted);
+ }
+
+ @Test
+ fun testEncryptDecryptBytesPassword() {
+ val encryptionProvider = EncryptionProvider.instance
+ val bytes = "This is a test string.".toByteArray();
+ val password = "1234".padStart(32, '9');
+
+ // Encrypt the plaintext
+ val ciphertext = encryptionProvider.encrypt(bytes, password)
+
+ // Decrypt the ciphertext
+ val decrypted = encryptionProvider.decrypt(ciphertext, password)
+
+ // The decrypted string should be equal to the original plaintext
+ assertArrayEquals(bytes, decrypted);
+
+ }
+
+ private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
+ assertEquals(a.size, b.size);
+ for(i in 0 until a.size) {
+ assertEquals(a[i], b[i]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/futo/platformplayer/RequireMigrationTests.kt b/app/src/androidTest/java/com/futo/platformplayer/RequireMigrationTests.kt
new file mode 100644
index 00000000..16c6da2f
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/RequireMigrationTests.kt
@@ -0,0 +1,35 @@
+package com.futo.platformplayer
+
+import android.util.Log
+import androidx.test.platform.app.InstrumentationRegistry
+import com.futo.platformplayer.api.media.PlatformID
+import com.futo.platformplayer.api.media.Serializer
+import com.futo.platformplayer.api.media.models.PlatformAuthorLink
+import com.futo.platformplayer.api.media.models.Thumbnail
+import com.futo.platformplayer.api.media.models.Thumbnails
+import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
+import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
+import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
+import com.futo.platformplayer.states.StateApp
+import com.futo.platformplayer.stores.FragmentedStorage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import org.junit.Assert
+import org.junit.Test
+import java.time.OffsetDateTime
+
+class RequireMigrationTests {
+ /* THESE TESTS SIMPLY EXIST TO WARN THE DEVELOPER THAT THEIR CHANGE WILL CAUSE A MIGRATION FOR ALL USERS */
+ private val serializedSettingsString = "{\"home\":{},\"search\":{},\"subscriptions\":{\"subscriptionsFeedStyle\":0,\"subscriptionsBackgroundUpdateInterval\":3},\"playback\":{\"autoRotate\":0},\"downloads\":{},\"browsing\":{},\"casting\":{},\"logging\":{},\"autoUpdate\":{\"check\":1},\"announcementSettings\":{},\"backup\":{},\"payment\":{\"paymentStatus\": \"Paid\"},\"info\":{}}";
+
+ @Test
+ fun testSettingsDeserializing() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext;
+ StateApp.instance.setGlobalContext(context, CoroutineScope(Dispatchers.Main));
+
+ Assert.assertNotNull(Json { ignoreUnknownKeys = true; this.isLenient = true }.decodeFromString(serializedSettingsString));
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/futo/platformplayer/SignatureTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SignatureTests.kt
new file mode 100644
index 00000000..69150715
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/SignatureTests.kt
@@ -0,0 +1,68 @@
+package com.futo.platformplayer
+
+import android.util.Log
+import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
+import org.junit.Test
+
+import org.junit.Assert.*
+
+class SignatureTests {
+ @Test
+ fun roundtripTest() {
+ val keys = SignatureProvider.generateKeyPair();
+ Log.i(TAG, "public key: ${keys.publicKey}\nprivate key: ${keys.privateKey}");
+
+ val signature = SignatureProvider.sign("test", keys.privateKey);
+ assertTrue(SignatureProvider.verify("test", signature, keys.publicKey));
+ }
+
+ @Test
+ fun decodeTest() {
+ assertTrue(SignatureProvider.verify(
+ "//this is just an empty script",
+ "eLdlDIcmpTQmfpCumB5NQwFa0ZDNU8hkRB12/Lg+CdTwPrfTIylGeN6jpTmJrEivyLjj" +
+ "5qHWZeNmrHP++9XFwfwzcaXNspKU9YrL3+Bsy2WNnXfQDeB2t4AkzWYAEfm8/kEcK0Ov8dzy0KW" +
+ "lJsxmW+Oj3mFNVP6PV5ZQY1Gju6W8Jw0sGCxnbuhswtRDPwBKnZQUhlZEXPvbrcblW1q5fCESnf" +
+ "oiJ2MHR5epgHfAuMsoY9EAHVXuyrLvmbWADeVwC5jvWLAkJKw68rQmARqV5BBWkpqFEBQcg50CR" +
+ "vTXtPr8IDjW7yiJ6x9nTG3nokTJn3fj2D3hBEHttEG+KhTMlQ==",
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucY14D6cl0fK5fHOTUfKMz1iQmfJMg" +
+ "Q+c4MfqlArGCv7YDTvazeQL9dsrCqlYx+o+AlYzbohGXYsYsJO474+Ia5VEcpCnMm6YPRhV0H+8bke" +
+ "lgE2vMB2MB54zIxVRVEA1CBPBrWle8qlBMmqXI8ndjQjIYZNZD0CN0ckOgLO3OX8+P6f+zYHbRINCXi" +
+ "T1L7DstJ4FacqE7b2+aNKiMogUoaq7H3dXxJXj32HMFZevrs8ZFxTvbIP4KkazRrdfnZPdWKXk9pv" +
+ "P8EI21RNKKr2NtVNJyRPxI1uWlvYtGeSLcNUioHshNRQ4SxRSG8p1VTBmUpS0cJoZSCmO/0W9doyzwI" +
+ "DAQAB"));
+ }
+
+ @Test
+ fun testSignature() {
+ val somePlugin = SourcePluginConfig(
+ "Script",
+ "A plugin that adds Script as a source",
+ "FUTO",
+ "https://futo.org",
+ "https://futo.org",
+ "./Script.js",
+ 1,
+ "./script.png",
+ "394dba51-fa0c-450c-8f17-6a00df362218",
+ "eLdlDIcmpTQmfpCumB5NQwFa0ZDNU8hkRB12/Lg+CdTwPrfTIylGeN6jpTmJrEivyLjj" +
+ "5qHWZeNmrHP++9XFwfwzcaXNspKU9YrL3+Bsy2WNnXfQDeB2t4AkzWYAEfm8/kEcK0Ov8dzy0KW" +
+ "lJsxmW+Oj3mFNVP6PV5ZQY1Gju6W8Jw0sGCxnbuhswtRDPwBKnZQUhlZEXPvbrcblW1q5fCESnf" +
+ "oiJ2MHR5epgHfAuMsoY9EAHVXuyrLvmbWADeVwC5jvWLAkJKw68rQmARqV5BBWkpqFEBQcg50CR" +
+ "vTXtPr8IDjW7yiJ6x9nTG3nokTJn3fj2D3hBEHttEG+KhTMlQ==",
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucY14D6cl0fK5fHOTUfKM" +
+ "z1iQmfJMgQ+c4MfqlArGCv7YDTvazeQL9dsrCqlYx+o+AlYzbohGXYsYsJO474+Ia5VEcpC" +
+ "nMm6YPRhV0H+8bkelgE2vMB2MB54zIxVRVEA1CBPBrWle8qlBMmqXI8ndjQjIYZNZD0CN0c" +
+ "kOgLO3OX8+P6f+zYHbRINCXiT1L7DstJ4FacqE7b2+aNKiMogUoaq7H3dXxJXj32HMFZevrs8ZF" +
+ "xTvbIP4KkazRrdfnZPdWKXk9pvP8EI21RNKKr2NtVNJyRPxI1uWlvYtGeSLcNUioHshNRQ4SxRSG8p1VTBmUpS0cJoZSCmO/0W9doyzwIDAQAB"
+ );
+ val script = "//this is just an empty script";
+
+ assert(somePlugin.validate(script), { "Invalid signature" });
+
+ }
+
+ companion object {
+ private const val TAG = "SignatureTests";
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/futo/platformplayer/StatePlatformTests.kt b/app/src/androidTest/java/com/futo/platformplayer/StatePlatformTests.kt
new file mode 100644
index 00000000..a006581a
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/StatePlatformTests.kt
@@ -0,0 +1,79 @@
+package com.futo.platformplayer
+
+//import androidx.test.platform.app.InstrumentationRegistry
+import com.futo.platformplayer.states.StatePlatform
+import com.futo.platformplayer.states.StateSubscriptions
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import kotlin.system.measureTimeMillis
+
+class StatePlatformTests {
+
+ /*
+ @Test
+ fun testPlatformStateGetVideo(){
+ runBlocking {
+ StatePlatform.instance.updateAvailableClients(InstrumentationRegistry.getInstrumentation().targetContext);
+ //StatePlatform.instance.selectClients(YoutubeClient.ID, OdyseeClient.ID);
+ var youtubeStreamVideoTask = StatePlatform.instance.getContentDetails("https://www.youtube.com/watch?v=bDgolMkLREA");
+ val odyseeStreamVideoTask = StatePlatform.instance.getContentDetails("lbry://ads-and-tracking-is-getting-worse-on#e2d1a7334869dfb531c80823064debbb2e75dac5");
+ val odyseeStreamVideoTask2 = StatePlatform.instance.getContentDetails("lbry://ads-and-tracking-is-getting-worse-on#e2d1a7334869dfb531c80823064debbb2e75dac5");
+
+ //Assert batching
+ assert(odyseeStreamVideoTask == odyseeStreamVideoTask2);
+
+ val youtubeStreamVideo = youtubeStreamVideoTask.await();
+ val odyseeStreamVideo = odyseeStreamVideoTask.await();
+
+ assert(youtubeStreamVideo.id.value == "bDgolMkLREA")
+ assert(odyseeStreamVideo.id.value == "bMPjhiYTR1GCPKaSGQkFyiVv1juJaY4PaG")
+ }
+ }*/
+
+ //TODO: Re-enable once getChannel requests are batched for non-subscribed channels.
+ /*
+ @Test
+ fun testPlatformStateGetChannelVideos(){
+ val expectedChannelUrl = "https://www.youtube.com/channel/UCL81YHgzH8tcrFfOJJwlSQw";
+ val expectedVideoId = "up2TjMuan6o"
+ val expectedVideoName= "bag cat";
+
+ runBlocking {
+ var youtubeChannelVideosTask = PlatformState.instance.getChannel(expectedChannelUrl);
+ var youtubeChannelVideosTask2 = PlatformState.instance.getChannel(expectedChannelUrl);
+
+ //Assert batching
+ assert(youtubeChannelVideosTask == youtubeChannelVideosTask2);
+
+ val youtubeStreamVideo = youtubeChannelVideosTask.await();
+
+ val page1Results = youtubeStreamVideo.videos.getResults();
+ assert(page1Results.size > 0);
+ assert(page1Results.any { it.id.value == expectedVideoId });
+ }
+ }
+
+ @Test
+ fun testPlatformStateSubscription(){
+ runBlocking {
+ StatePlatform.instance.updateAvailableClients(InstrumentationRegistry.getInstrumentation().targetContext);
+ //StatePlatform.instance.selectClients(YoutubeClient.ID, OdyseeClient.ID);
+ }
+ val expectedChannelUrl = "https://www.youtube.com/channel/UCL81YHgzH8tcrFfOJJwlSQw";
+ val expectedVideoId = "up2TjMuan6o"
+ val expectedVideoName= "bag cat";
+
+ val channel = runBlocking { StatePlatform.instance.getChannel(expectedChannelUrl).await() };
+
+ val timeExplicit = measureTimeMillis {
+ runBlocking {
+ val stateExplicit = StateSubscriptions();
+ stateExplicit.addSubscription(channel);
+ val channel = stateExplicit.getSubscription(expectedChannelUrl);
+
+ assert(channel != null);
+ }
+ };
+ System.out.println("Explicit Subscription update $timeExplicit ms");
+ }*/
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..2abba88c
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/devportal/dependencies/FutoMainLogo.svg b/app/src/main/assets/devportal/dependencies/FutoMainLogo.svg
new file mode 100644
index 00000000..f7685471
--- /dev/null
+++ b/app/src/main/assets/devportal/dependencies/FutoMainLogo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/main/assets/devportal/dev_bridge.js b/app/src/main/assets/devportal/dev_bridge.js
new file mode 100644
index 00000000..57f0f276
--- /dev/null
+++ b/app/src/main/assets/devportal/dev_bridge.js
@@ -0,0 +1,337 @@
+
+//These calls are purposely synchronized to emulate behavior within V8
+function syncGET(url, headers) {
+ if(!headers) headers = {};
+ const req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ for (const [key, value] of Object.entries(headers))
+ req.setRequestHeader(key, value);
+ req.send(null);
+
+ if(req.status >= 200 && req.status < 300)
+ return req.response;
+ else
+ throw "Request [" + req.status + "]\n" + req.response;
+}
+function syncPOST(url, headers, body) {
+ if(!headers) headers = {};
+ const req = new XMLHttpRequest();
+ req.open("POST", url, false);
+ for (const [key, value] of Object.entries(headers))
+ req.setRequestHeader(key, value);
+ req.send(body);
+
+ if(req.status >= 200 && req.status < 300)
+ return req.response;
+ else
+ throw "Request [" + req.status + "]\n" + req.response;
+}
+
+
+class RemoteObject {
+ constructor(remoteObj) {
+ Object.assign(this, remoteObj);
+
+ if(this.__methods) {
+ const me = this;
+ for(let i = 0; i < this.__methods.length; i++) {
+ const methodName = this.__methods[i];
+
+ this[methodName] = function() {
+ try{
+ return remoteCall(me.__id, methodName, Array.from(arguments));
+ }
+ catch(ex) {
+ if(ex.indexOf("[400]") > 0 && ex.indexOf("does not exist") > 0 && ex.indexOf(me.__id) > 0) {
+ deletePackage(me.__id);
+ }
+ else throw ex;
+ }
+ };
+ }
+ }
+ if(this.__props) {
+ const me = this;
+ for(let i = 0; i < this.__props.length; i++) {
+ const propName = this.__props[i];
+
+ Object.defineProperty(this, propName, {
+ get() {
+ try{
+ return remoteProp(me.__id, propName);
+ }
+ catch(ex) {
+ if(ex.indexOf("[400]") > 0 && ex.indexOf("does not exist") > 0 && ex.indexOf(me.__id) > 0) {
+ deletePackage(me.__id);
+ }
+ else throw ex;
+ }
+ }
+ });
+ }
+ }
+ }
+}
+const excludedCallsFromLogs = ["isLoggedIn"];
+function remoteCall(objID, methodName, args) {
+ for(let i = 0; i < args.length; i++) {
+ let arg = args[i];
+ if(typeof(arg) == "object") {
+ switch(arg.constructor.name) {
+ case "Uint8Array":
+ args[i] = [...arg]
+ break;
+ }
+ }
+ }
+
+
+ if(excludedCallsFromLogs.indexOf(methodName) < 0)
+ console.log("Remote Call on [" + objID + "]." + methodName + "(...)", args);
+ const result = pluginRemoteCall(objID, methodName, args);
+ return wrapRemoteObject(result);
+}
+function remoteProp(objID, propName) {
+ console.log("Remote Prop on [" + objID + "]." + propName);
+ const result = pluginRemoteProp(objID, propName);
+ return wrapRemoteObject(result);
+}
+function wrapRemoteObject(result) {
+ if(Array.isArray(result)) {
+ if(result.length == 0)
+ return [];
+ const firstItem = result[0];
+ if(typeof firstItem === "object")
+ return result.map(x=>new RemoteObject(x));
+ else
+ return result;
+ }
+ else if(typeof result === "object")
+ return new RemoteObject(result);
+ return result;
+}
+
+//These override implementations by packages if enabled
+var packageOverrides = {
+ domParser() {
+ return {
+ parseFromString(str) {
+ return new DOMParser().parseFromString(str, "text/html");
+ }
+ }
+ }
+};
+var packageOverridesEnabled = {};
+for(override in packageOverrides)
+ packageOverridesEnabled[override] = false;
+
+
+var _loadedPackages = {
+
+};
+function clearPackages() {
+ _loadedPackages = {};
+}
+function deletePackage(id) {
+ for(let key in _loadedPackages) {
+ if(_loadedPackages[key]?.__id == id)
+ _loadedPackages[key] = undefined;
+ }
+}
+function applyPackages(packages) {
+ _loadedPackages = {};
+ for(let i = 0; i < packages.length; i++) {
+ const package = packages[i];
+ delete window[package];
+ Object.defineProperty(window, package, {
+ configurable: true,
+ get() {
+ if(!_loadedPackages[package]) {
+ if(packageOverridesEnabled[package]) {
+ _loadedPackages[package] = packageOverrides[package]();
+ console.log("LOADED EMULATED PACKAGE [" + package + "]", _loadedPackages[package]);
+ }
+ else {
+ _loadedPackages[package] = new RemoteObject(pluginGetPackage(package));
+ console.log("LOADED REMOTE PACKAGE [" + package + "]", _loadedPackages[package]);
+ applyAdditionalOverrides(package, _loadedPackages[package]);
+ }
+ }
+ return _loadedPackages[package];
+ }
+ });
+ }
+}
+function applyAdditionalOverrides(packageName, package) {
+ switch(packageName) {
+ case "http":
+ console.log("Http override for socket");
+ package.socket = (url, headers, auth) => {
+ console.warn("This uses an emulated socket connection directly from browser. Remoting websocket is not yet supported.");
+ if(auth)
+ throw "Socket override does not support auth yet (should work in-app)";
+
+ const obj = {};
+
+ obj.connect = function(listeners) {
+ obj.socket = new WebSocket(url);
+ obj.socket.addEventListener("open", (event) => {
+ obj.isOpen = true;
+ listeners.open && listeners.open();
+ });
+ obj.socket.addEventListener("message", (event) => listeners.message && listeners.message(event.data));
+ obj.socket.addEventListener("error", (event) => listeners.failure && listeners.failure());
+ obj.socket.addEventListener("closed", (event) => {
+ obj.isOpen = false;
+ listeners.closed && listeners.closed(event.code, event.reason);
+ });
+ };
+ obj.send = function(msg) {
+ if(obj.socket != null)
+ obj.socket.send(msg);
+ }
+ obj.close = function(code, reason) {
+ if(obj.socket != null)
+ obj.socket.close(code, reason);
+ }
+ return obj;
+ };
+ break;
+
+ }
+}
+
+function reloadPackages() {
+ const packages = Object.keys(_loadedPackages);
+ applyPackages(packages);
+}
+
+function httpGETBypass(url, headers, ct) {
+ return JSON.parse(syncPOST("/get?CT=" + ct, {}, JSON.stringify({
+ url: url,
+ headers: headers
+ })));
+}
+function pluginUpdateTestPlugin(config) {
+ return JSON.parse(syncPOST("/plugin/updateTestPlugin", {}, JSON.stringify(config)));
+}
+function pluginLoginTestPlugin() {
+ return syncGET("/plugin/loginTestPlugin", {});
+}
+function pluginLogoutTestPlugin() {
+ return syncGET("/plugin/logoutTestPlugin", {});
+}
+function pluginGetPackage(packageName) {
+ return JSON.parse(syncGET("/plugin/packageGet?variable=" + packageName, {}));
+}
+function pluginRemoteProp(objID, propName) {
+ return JSON.parse(syncGET("/plugin/remoteProp?id=" + objID + "&prop=" + propName, {}));
+}
+function pluginRemoteCall(objID, methodName, args) {
+ return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args)));
+}
+
+function pluginIsLoggedIn(cb, err) {
+ fetch("/plugin/isLoggedIn", {
+ timeout: 1000
+ })
+ .then(x => x.json())
+ .then(x => cb(x))
+ .catch(y => err && err(y));
+}
+
+function pluginGetWarnings(config) {
+ return JSON.parse(syncPOST("/plugin/getWarnings", {}, JSON.stringify(config)));
+}
+
+function uploadDevPlugin(config) {
+ return JSON.parse(syncPOST("/plugin/loadDevPlugin", {}, JSON.stringify(config)));
+}
+function getDevLogs(lastIndex, cb) {
+ if(!lastIndex)
+ lastIndex = 0;
+ fetch("/plugin/getDevLogs?index=" + lastIndex, {
+ timeout: 1000
+ })
+ .then(x=>x.json())
+ .then(y=> cb && cb(y));
+}
+function sendFakeDevLog(devId, msg) {
+ return syncGET("/plugin/fakeDevLog?devId=" + devId + "&msg=" + msg, {});
+}
+
+var __DEV_SETTINGS = {};
+function setDevSettings(obj) {
+ __DEV_SETTINGS = obj;
+}
+
+var liveChatIntervalId = null;
+function testLiveChat(url, interval, verbose) {
+ if(!interval)
+ interval = 4000;
+ if(liveChatIntervalId)
+ clearInterval(liveChatIntervalId);
+
+ let live = source.getLiveEvents(url);
+ liveChatIntervalId = setInterval(()=>{
+ if(!live.hasMorePagers()) {
+ clearInterval(liveChatIntervalId);
+ console.log("END OF CHAT");
+ }
+ live.nextPage();
+ for(let event of live.results) {
+ if(verbose) {
+ if(event.type == 1)
+ console.log("Live Chat: [" + event.name + "]:" + event.message, event);
+ else if(event.type == 5)
+ console.log("Live Chat: DONATION (" + event.amount + ") [" + event.name + "]: " + event.message, event);
+ else if(event.type == 6)
+ console.log("Live Chat: MEMBER (" + event.amount + ") [" + event.name + "]: " + event.message, event);
+ else console.log("Live Chat: Ev", event);
+ }
+ else {
+ if(event.type == 1)
+ console.log("Live Chat: [" + event.name + "]:" + event.message);
+ else if(event.type == 5)
+ console.log("Live Chat: DONATION (" + event.amount + ") [" + event.name + "]: " + event.message);
+ else if(event.type == 6)
+ console.log("Live Chat: MEMBER (" + event.amount + ") [" + event.name + "]: " + event.message);
+ else console.log("Live Chat: Ev", event);
+ }
+ }
+ }, interval);
+}
+
+function testPlaybackTracker(url, seconds, iterations, pauseAfter) {
+ let lastTime = (new Date()).getTime();
+ const tracker = source.getPlaybackTracker(url);
+ if(!tracker) {
+ console.warn("No tracker available (null)");
+ return;
+ }
+
+ if(tracker.onInit)
+ tracker.onInit(seconds);
+
+ let iteration = undefined;
+ iteration = function(itt) {
+ const diff = (new Date()).getTime() - lastTime;
+ const secCurrent = seconds + (diff / 1000);
+
+ tracker.onProgress(secCurrent, true);
+
+ if(itt > 0)
+ setTimeout(()=>{
+ iteration(itt - 1);
+ }, tracker.nextRequest);
+ else
+ setTimeout(()=> {
+ const diff = (new Date()).getTime() - lastTime;
+ const secCurrent = seconds + (diff / 1000);
+ tracker.onProgress(secCurrent, false);
+ }, 850);
+ }
+ setTimeout(()=> {
+ iteration(iterations - 1);
+ }, tracker.nextRequest);
+}
\ No newline at end of file
diff --git a/app/src/main/assets/devportal/dev_test.html b/app/src/main/assets/devportal/dev_test.html
new file mode 100644
index 00000000..7105ad65
--- /dev/null
+++ b/app/src/main/assets/devportal/dev_test.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/devportal/index.html b/app/src/main/assets/devportal/index.html
new file mode 100644
index 00000000..17bce7d5
--- /dev/null
+++ b/app/src/main/assets/devportal/index.html
@@ -0,0 +1,897 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview
+
+
+ Testing
+
+
+ Integration
+
+
+ Settings
+
+
+
+ Login
+
+
+ Logout
+
+
+
+
+
+ {{Plugin.currentPlugin.name}}
+
+
+ Last updated: {{Plugin.lastLoadTime}}
+
+
+
+ mdi-refresh
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading via script tag might give issues reloading script, as it makes the script part of DOM, but does allow debugging via dev console.
+