diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..173a6f10
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+aar/* filter=lfs diff=lfs merge=lfs -text
+app/aar/* filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.yml b/.github/ISSUE_TEMPLATE/1-bug_report.yml
new file mode 100644
index 00000000..e2108e3e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1-bug_report.yml
@@ -0,0 +1,166 @@
+name: Bug Report
+description: Let us know about an unexpected error, a crash, or an incorrect behavior.
+labels: ["Bug", "Android"]
+title: "Bug: "
+type: bug
+projects: ["futo-org/19"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # Thank you for taking the time to fill out this bug report.
+
+ The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
+
+ For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
+
+ ## Filing a bug report
+
+ To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
+ * Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
+ * if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
+
+ - type: textarea
+ id: reproduction-steps
+ attributes:
+ label: Reproduction steps
+ description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
+ placeholder: |
+ 0. Play a Youtube video
+ 1. Press on Download button
+ 2. Select quality 1440p
+ 3. Grayjay crashes when attempting to download
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual-result
+ attributes:
+ label: Actual result
+ description: What happend?
+ placeholder: Tell us what you saw!
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected-result
+ attributes:
+ label: Expected result
+ description: What was suppose to happen?
+ placeholder: Tell us what you expected to happen!
+ validations:
+ required: true
+
+ - type: input
+ id: grayjay-version
+ attributes:
+ label: Grayjay Version
+ description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
+ placeholder: "311"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: plugin
+ attributes:
+ label: What plugins are you seeing the problem on?
+ multiple: true
+ options:
+ - "All"
+ - "Apple Podcasts"
+ - "BiliBili (CN)"
+ - "Bitchute"
+ - "Crunchyroll"
+ - "CuriosityStream"
+ - "Dailymotion"
+ - "Kick"
+ - "Nebula"
+ - "Odysee"
+ - "Patreon"
+ - "PeerTube"
+ - "Rumble"
+ - "SoundCloud"
+ - "Spotify"
+ - "TedTalks"
+ - "Twitch"
+ - "Youtube"
+ - "Other"
+ validations:
+ required: true
+
+ - type: input
+ id: plugin-version
+ attributes:
+ label: Plugin Version
+ description: In the application, select Sources > [the broken plugin], write down the value under "Version".
+ placeholder: "12"
+
+ - type: input
+ id: android-version
+ attributes:
+ label: Which android version are you using?
+ placeholder: "Android 15"
+ validations:
+ required: true
+
+ - type: input
+ id: phone-model
+ attributes:
+ label: Which device are you using?
+ placeholder: "Google Pixel 9"
+ validations:
+ required: true
+
+ - type: input
+ id: os-version
+ attributes:
+ label: Which operating system are you using?
+ placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..."
+ validations:
+ required: true
+
+ - type: checkboxes
+ id: login
+ attributes:
+ label: When do you experience the issue?
+ options:
+ - label: While logged in
+ - label: While logged out
+ - label: N/A
+
+ - type: dropdown
+ id: vpn
+ attributes:
+ label: Are you using a VPN?
+ multiple: false
+ options:
+ - "No"
+ - "Yes"
+ validations:
+ required: true
+
+ - type: textarea
+ id: grayjay-references
+ attributes:
+ label: References
+ description: |
+ Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example:
+ ```
+ - #10
+ ```
+ placeholder:
+ value:
+ validations:
+ required: false
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
+ render: shell
+
+ - type: markdown
+ attributes:
+ value: |
+ **Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.yml b/.github/ISSUE_TEMPLATE/2-feature_request.yml
new file mode 100644
index 00000000..2058150f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/2-feature_request.yml
@@ -0,0 +1,59 @@
+name: Feature Request
+description: Suggest a new feature or other enhancement.
+labels: ["Enhancement", "Android"]
+title: "Feature request: "
+type: feature
+projects: ["futo-org/19"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # Thank you for opening a feature request.
+
+ The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application
+
+ For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
+
+ - type: textarea
+ id: grayjay-use-case
+ attributes:
+ label: Use Cases
+ description: |
+ In order to properly evaluate a feature request, it is necessary to understand the use cases for it. Please describe below the _end goal_ you are trying to achieve that has led you to request this feature. Please keep this section focused on the problem and not on the suggested solution.
+ placeholder:
+ value:
+ validations:
+ required: true
+
+ - type: textarea
+ id: grayjay-proposal
+ attributes:
+ label: Proposal
+ description: |
+ If you have an idea for a way to address the problem via a change to Grayjay features, please describe it below.
+
+ In this section, it's helpful to include specific examples of how what you are suggesting might look in the application, this allows us to understand the full picture of what you are proposing. If you're not sure of some details, don't worry! When we evaluate the feature request we may suggest modifications as necessary to work within the design constraints of the Grayjay Core Application.
+ placeholder:
+ value:
+ validations:
+ required: false
+
+ - type: textarea
+ id: grayjay-references
+ attributes:
+ label: References
+ description: |
+ Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above or to the suggested solution? If so, please create a list below that mentions each of them. For example:
+ ```
+ - #10
+ ```
+ placeholder:
+ value:
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ **Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
+
diff --git a/.github/ISSUE_TEMPLATE/3-documentation_issue.yml b/.github/ISSUE_TEMPLATE/3-documentation_issue.yml
new file mode 100644
index 00000000..40d245dc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/3-documentation_issue.yml
@@ -0,0 +1,66 @@
+name: Documentation Issue
+description: Report an issue or suggest a change in the documentation.
+labels: ["Documentation"]
+title: "Documentation: "
+type: task
+projects: ["futo-org/19"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # Thank you for opening a documentation change request.
+
+ The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
+ Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
+
+ For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
+
+ - type: textarea
+ id: grayjay-affected-pages
+ attributes:
+ label: Affected Pages
+ description: |
+ Link to or describe the pages relevant to your documentation change request.
+ placeholder:
+ value:
+ validations:
+ required: false
+
+ - type: textarea
+ id: grayjay-problem
+ attributes:
+ label: What is the docs issue?
+ description: What problems or suggestions do you have about the documentation?
+ placeholder:
+ value:
+ validations:
+ required: true
+
+ - type: textarea
+ id: grayjay-proposal
+ attributes:
+ label: Proposal
+ description: What documentation changes would fix this issue and where would you expect to find them? Are one or more page headings unclear? Do one or more pages need additional context, examples, or warnings? Do we need a new page or section dedicated to a specific topic? Your ideas help us understand what you and other users need from our documentation and how we can improve the content.
+ placeholder:
+ value:
+ validations:
+ required: false
+
+ - type: textarea
+ id: grayjay-references
+ attributes:
+ label: References
+ description: |
+ Are there any other open or closed GitLab/GitHub issues related to the problem or solution you described? If so, list them below. For example:
+ ```
+ - #6017
+ ```
+ placeholder:
+ value:
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ **Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..1f51bd8f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Need a Grayjay License?
+ url: https://pay.futo.org/api/PaymentPortal
+ about: Purchase a Grayjay license with FutoPay
+ - name: Plugin Building, Usage, or other Questions
+ url: https://chat.futo.org/#narrow/stream/46-Grayjay
+ about: Grayjays Community Chat
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
index 3c5b4994..00037939 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,9 +1,6 @@
[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
@@ -61,3 +58,51 @@
[submodule "dep/futopay"]
path = dep/futopay
url = ../futopayclientlibraries.git
+[submodule "app/src/unstable/assets/sources/bilibili"]
+ path = app/src/unstable/assets/sources/bilibili
+ url = ../plugins/bilibili.git
+[submodule "app/src/stable/assets/sources/bilibili"]
+ path = app/src/stable/assets/sources/bilibili
+ url = ../plugins/bilibili.git
+[submodule "app/src/stable/assets/sources/spotify"]
+ path = app/src/stable/assets/sources/spotify
+ url = ../plugins/spotify.git
+[submodule "app/src/unstable/assets/sources/spotify"]
+ path = app/src/unstable/assets/sources/spotify
+ url = ../plugins/spotify.git
+[submodule "app/src/stable/assets/sources/bitchute"]
+ path = app/src/stable/assets/sources/bitchute
+ url = ../plugins/bitchute.git
+[submodule "app/src/unstable/assets/sources/bitchute"]
+ path = app/src/unstable/assets/sources/bitchute
+ url = ../plugins/bitchute.git
+[submodule "app/src/unstable/assets/sources/dailymotion"]
+ path = app/src/unstable/assets/sources/dailymotion
+ url = ../plugins/dailymotion.git
+[submodule "app/src/stable/assets/sources/dailymotion"]
+ path = app/src/stable/assets/sources/dailymotion
+ url = ../plugins/dailymotion.git
+[submodule "app/src/stable/assets/sources/apple-podcast"]
+ path = app/src/stable/assets/sources/apple-podcasts
+ url = ../plugins/apple-podcasts.git
+[submodule "app/src/unstable/assets/sources/apple-podcasts"]
+ path = app/src/unstable/assets/sources/apple-podcasts
+ url = ../plugins/apple-podcasts.git
+[submodule "app/src/stable/assets/sources/tedtalks"]
+ path = app/src/stable/assets/sources/tedtalks
+ url = ../plugins/tedtalks.git
+[submodule "app/src/unstable/assets/sources/tedtalks"]
+ path = app/src/unstable/assets/sources/tedtalks
+ url = ../plugins/tedtalks.git
+[submodule "app/src/stable/assets/sources/curiositystream"]
+ path = app/src/stable/assets/sources/curiositystream
+ url = ../plugins/curiositystream.git
+[submodule "app/src/unstable/assets/sources/curiositystream"]
+ path = app/src/unstable/assets/sources/curiositystream
+ url = ../plugins/curiositystream.git
+[submodule "app/src/unstable/assets/sources/crunchyroll"]
+ path = app/src/unstable/assets/sources/crunchyroll
+ url = ../plugins/crunchyroll.git
+[submodule "app/src/stable/assets/sources/crunchyroll"]
+ path = app/src/stable/assets/sources/crunchyroll
+ url = ../plugins/crunchyroll.git
diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md
index 23cc959e..8af2e2f5 100644
--- a/CONTRIBUTION.md
+++ b/CONTRIBUTION.md
@@ -49,9 +49,23 @@ We encourage developers to write their own plugins. Please refer to the "Getting
## 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.
+### License
+
+The core is currently licensed under the [Source First License 1.1](./LICENSE.md). All contributors have to sign FUTO Individual Contributor License Agreement before contributions can be accepted. You can read more about it at [https://cla.futo.org/](https://cla.futo.org/).
+
+### How to Contribute
+
+1. Fork the core repository.
+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).
---
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index a3d0f019..00000000
--- a/LICENSE
+++ /dev/null
@@ -1,32 +0,0 @@
-# FUTO TEMPORARY LICENSE
-This license grants you the rights, and only the rights, set out below in respect of the source code provided. If you take advantage of these rights, you accept this license. If you do not accept the license, do not access the code.
-
-Words used in the Terms of Service have the same meaning in this license. Where there is any inconsistency between this license and those Terms of Service, these terms prevail.
-
-## Section 1: Definitions
-- "code" means the source code made available from time, in our sole discretion, for access under this license. Reference to code in this license means the code and any part of it and any derivative of it.
-- “compilation” means to compile the code from ‘source code’ to ‘machine code’.
-- "defect" means a defect, bug, backdoor, security issue or other deficiency in the code.
-- “non-commercial distribution” means distribution of the code or any compilation of the code, or of any other application or program containing the code or any compilation of the code, where such distribution is not intended for or directed towards commercial advantage or monetary compensation.
-- "review" means to access, analyse, test and otherwise review the code as a reference, for the sole purpose of analysing it for defects.
-- "you" means the licensee of rights set out in this license.
-
-## Section 2: Grant of Rights
-1. Subject to the terms of this license, we grant you a non-transferable, non-exclusive, worldwide, royalty-free license to access and use the code solely for the purposes of review, compilation and non-commercial distribution.
-2. You may provide the code to anyone else and publish excerpts of it for the purposes of review, compilation and non-commercial distribution, provided that when you do so you make any recipient of the code aware of the terms of this license, they must agree to be bound by the terms of this license and you must attribute the code to the provider.
-3. Other than in respect of those parts of the code that were developed by other parties and as specified strictly in accordance with the open source and other licenses under which those parts of the code have been made available, as set out on our website or in those items of code, you are not entitled to use or do anything with the code for any commercial or other purpose, other than review, compilation and non-commercial distribution in accordance with the terms of this license.
-4. Subject to the terms of this license, you must at all times comply with and shall be bound by our Terms of Use, Privacy and Data Policy.
-
-## Section 3: Limitations
-1. This license does not grant you any rights to use the provider's name, logo, or trademarks and you must not in any way indicate you are authorised to speak on behalf of the provider.
-2. If you issue proceedings in any jurisdiction against the provider because you consider the provider has infringed copyright or any patent right in respect of the code (including any joinder or counterclaim), your license to the code is automatically terminated.
-3. THE CODE IS MADE AVAILABLE "AS-IS" AND WITHOUT ANY EXPRESS OR IMPLIED GUARANTEES AS TO FITNESS, MERCHANTABILITY, NON-INFRINGEMENT OR OTHERWISE. IT IS NOT BEING PROVIDED IN TRADE BUT ON A VOLUNTARY BASIS ON OUR PART AND IS NOT MADE AVAILABLE FOR ANY USE OUTSIDE THE TERMS OF THIS LICENSE. ANYONE ACCESSING THE CODE MUST ENSURE THEY HAVE THE REQUISITE EXPERTISE TO SECURE THEIR OWN SYSTEM AND DEVICES AND TO ACCESS AND USE THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE. YOU BEAR THE RISK OF ACCESSING AND USING THE CODE. IN PARTICULAR, THE PROVIDER BEARS NO LIABILITY FOR ANY INTERFERENCE WITH OR ADVERSE EFFECT ON YOUR SYSTEM OR DEVICES AS A RESULT OF YOUR ACCESSING AND USING THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE OR OTHERWISE.
-
-## Section 4: Termination, suspension and variation
-1. We may suspend, terminate or vary the terms of this license and any access to the code at any time, without notice, for any reason or no reason, in respect of any licensee, group of licensees or all licensees including as may be applicable any sub-licensees.
-
-## Section 5: General
-1. This license and its interpretation and operation are governed solely by the local law. You agree to submit to the exclusive jurisdiction of the local arbitral tribunals as further described in our Terms of Service and you agree not to raise any jurisdictional issue if we need to enforce an arbitral award or judgment in our jurisdiction or another country.
-2. Questions and comments regarding this license are welcomed and should be addressed at https://chat.futo.org/login/.
-
-Last updated 7 June 2023.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 00000000..2cc664ad
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,43 @@
+# Source First License 1.1
+
+## Acceptance
+By using the software, you agree to all of the terms and conditions below.
+
+## Copyright License
+FUTO Holdings, Inc. (the “Licensor”) grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations below.
+
+## Limitations
+You may use or modify the software only for non-commercial purposes such as personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, all without any anticipated commercial application.
+
+You may distribute the software or provide it to others only if you do so free of charge for non-commercial purposes.
+
+Notwithstanding the above, you may not remove or obscure any functionality in the software related to payment to the Licensor in any copy you distribute to others.
+
+You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law.
+
+## Patents
+If you make any written claim that the software infringes or contributes to infringement of any patent, your license for the software granted under these terms ends immediately. If your company makes such a claim, your license ends immediately for work on behalf of your company.
+
+## Notices
+You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.
+
+## Fair Use
+You may have "fair use" rights for the software under the law. These terms do not limit them.
+
+## No Other Rights
+These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the Licensor from granting licenses to anyone else. These terms do not imply any other licenses.
+
+## Termination
+If you use the software in violation of these terms, such use is not licensed, and your license will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your license will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your license to terminate automatically and permanently.
+
+## No Liability
+As far as the law allows, the software comes as is, without any warranty or condition, and the Licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.
+
+## Definitions
+- The “Licensor” is the entity offering these terms, FUTO Holdings, Inc.
+- The “software” is the software the licensor makes available under these terms, including any portion of it.
+- “You” refers to the individual or entity agreeing to these terms.
+- “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. Control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
+- “Your license” is the license granted to you for the software under these terms.
+- “Use” means anything you do with the software requiring your license.
+- “Trademark” means trademarks, service marks, and similar rights.
diff --git a/README.md b/README.md
index 263689a2..b05b5b82 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
-
-
+
+
Video
@@ -24,12 +24,10 @@ The FUTO media app is a player that exposes multiple video websites as sources i
-
-
+
-
Sources (all enabled)
-
Sources (one disabled)
+
Sources
@@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
-
+
Install a new source
@@ -54,8 +52,8 @@ When a user enters a search term into the search bar, the query is posted to th
-
-
+
+
Search (list)
@@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
-
+
Channel
@@ -112,7 +110,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
-
+
Settings
@@ -125,8 +123,8 @@ Playlists allow you to make a collection of videos that you can create and custo
-
-
+
+
Playlists
@@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
-
+
Downloads
@@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
-
+
Casting
@@ -182,6 +180,12 @@ In the future we hope to offer users the choice of their desired recommendation
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. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command:
+
+```sh
+git submodule update --init --recursive
+```
+
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.
@@ -199,7 +203,6 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo
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/aar/ffmpeg-kit-full-6.0-2.LTS.aar b/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
new file mode 100644
index 00000000..27b62b35
--- /dev/null
+++ b/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
+size 65512557
diff --git a/app/build.gradle b/app/build.gradle
index e8ed5131..25d458d4 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -2,7 +2,7 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
- id 'org.ajoberstar.grgit' version '1.7.2'
+ id 'org.ajoberstar.grgit' version '5.2.2'
id 'com.google.protobuf'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
@@ -144,14 +144,25 @@ android {
buildFeatures {
buildConfig true
}
+ sourceSets {
+ main {
+ assets {
+ srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
+ }
+ }
+ }
}
dependencies {
+ implementation 'com.google.dagger:dagger:2.48'
+ implementation 'androidx.test:monitor:1.7.2'
+ implementation 'com.google.android.material:material:1.12.0'
+ annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'com.google.android.material:material:1.10.0'
+ implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
//Images
@@ -170,25 +181,26 @@ dependencies {
//JS
implementation("com.caoccao.javet:javet-android:3.0.2")
+ //implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
//Exoplayer
- implementation 'androidx.media3:media3-exoplayer:1.2.0'
- implementation 'androidx.media3:media3-exoplayer-dash:1.2.0'
- implementation 'androidx.media3:media3-ui:1.2.0'
- implementation 'androidx.media3:media3-exoplayer-hls:1.2.0'
- implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.0'
- implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.0'
- implementation 'androidx.media3:media3-transformer:1.2.0'
- implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5'
- implementation 'androidx.navigation:navigation-ui-ktx:2.7.5'
+ implementation 'androidx.media3:media3-exoplayer:1.2.1'
+ implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
+ implementation 'androidx.media3:media3-ui:1.2.1'
+ implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
+ implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
+ implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
+ implementation 'androidx.media3:media3-transformer:1.2.1'
+ implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
+ implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
implementation 'androidx.media:media:1.7.0'
//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 fileTree(dir: 'aar', include: ['*.aar'])
+ implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1'
diff --git a/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt
new file mode 100644
index 00000000..66686260
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt
@@ -0,0 +1,38 @@
+package com.futo.platformplayer
+
+import android.graphics.Color
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import toAndroidColor
+
+class CSSColorTests {
+ @Test
+ fun test1() {
+ val androidHex = "#80336699"
+ val androidColorInt = Color.parseColor(androidHex)
+
+ val cssHex = "#33669980"
+ val cssColor = CSSColor.parseColor(cssHex)
+
+ assertEquals(
+ "CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
+ androidColorInt,
+ cssColor.toAndroidColor(),
+ )
+ }
+
+ @Test
+ fun test2() {
+ val androidHex = "#123ABC"
+ val androidColorInt = Color.parseColor(androidHex)
+
+ val cssHex = "#123ABCFF"
+ val cssColor = CSSColor.parseColor(cssHex)
+
+ assertEquals(
+ "CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
+ androidColorInt,
+ cssColor.toAndroidColor()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt
new file mode 100644
index 00000000..f3e12645
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt
@@ -0,0 +1,338 @@
+package com.futo.platformplayer
+
+import com.futo.platformplayer.noise.protocol.Noise
+import com.futo.platformplayer.sync.internal.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.selects.select
+import org.junit.Assert.*
+import org.junit.Test
+import java.net.Socket
+import java.nio.ByteBuffer
+import kotlin.random.Random
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+/*
+class SyncServerTests {
+
+ //private val relayHost = "relay.grayjay.app"
+ //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
+ private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
+ private val relayHost = "192.168.1.138"
+ private val relayPort = 9000
+
+ /** Creates a client connected to the live relay server. */
+ private suspend fun createClient(
+ onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
+ onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
+ onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
+ isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
+ onException: ((Throwable) -> Unit)? = null
+ ): SyncSocketSession = withContext(Dispatchers.IO) {
+ val p = Noise.createDH("25519")
+ p.generateKeyPair()
+ val socket = Socket(relayHost, relayPort)
+ val inputStream = LittleEndianDataInputStream(socket.getInputStream())
+ val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
+ val tcs = CompletableDeferred()
+ val socketSession = SyncSocketSession(
+ relayHost,
+ p,
+ inputStream,
+ outputStream,
+ onClose = { socket.close() },
+ onHandshakeComplete = { s ->
+ onHandshakeComplete?.invoke(s)
+ tcs.complete(true)
+ },
+ onData = onData ?: { _, _, _, _ -> },
+ onNewChannel = onNewChannel ?: { _, _ -> },
+ isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
+ )
+ socketSession.authorizable = AlwaysAuthorized()
+ try {
+ socketSession.startAsInitiator(relayKey)
+ } catch (e: Throwable) {
+ onException?.invoke(e)
+ }
+ withTimeout(5000.milliseconds) { tcs.await() }
+ return@withContext socketSession
+ }
+
+ @Test
+ fun multipleClientsHandshake_Success() = runBlocking {
+ val client1 = createClient()
+ val client2 = createClient()
+ assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
+ assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
+ client1.stop()
+ client2.stop()
+ }
+
+ @Test
+ fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
+ val clientA = createClient()
+ val clientB = createClient()
+ val clientC = createClient()
+ clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
+ delay(100.milliseconds)
+ val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
+ val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
+ assertNotNull("Client B should receive connection info", infoB)
+ assertEquals(12345.toUShort(), infoB!!.port)
+ assertNull("Client C should not receive connection info (unauthorized)", infoC)
+ clientA.stop()
+ clientB.stop()
+ clientC.stop()
+ }
+
+ @Test
+ fun relayedTransport_Bidirectional_Success() = runBlocking {
+ val tcsA = CompletableDeferred()
+ val tcsB = CompletableDeferred()
+ val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
+ val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
+ val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
+ val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
+ channelA.authorizable = AlwaysAuthorized()
+ val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
+ channelB.authorizable = AlwaysAuthorized()
+ channelTask.await()
+
+ val tcsDataB = CompletableDeferred()
+ channelB.setDataHandler { _, _, o, so, d ->
+ val b = ByteArray(d.remaining())
+ d.get(b)
+ if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
+ }
+ channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
+
+ val tcsDataA = CompletableDeferred()
+ channelA.setDataHandler { _, _, o, so, d ->
+ val b = ByteArray(d.remaining())
+ d.get(b)
+ if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
+ }
+ channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
+
+ val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
+ val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
+ assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
+ assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
+ val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
+ val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
+ val tcsA = CompletableDeferred()
+ val tcsB = CompletableDeferred()
+ val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
+ val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
+ val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
+ val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
+ channelA.authorizable = AlwaysAuthorized()
+ val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
+ channelB.authorizable = AlwaysAuthorized()
+ channelTask.await()
+
+ val tcsDataB = CompletableDeferred()
+ channelB.setDataHandler { _, _, o, so, d ->
+ val b = ByteArray(d.remaining())
+ d.get(b)
+ if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
+ }
+ channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
+ val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
+ assertArrayEquals(maxSizeData, receivedData)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun publishAndGetRecord_Success() = runBlocking {
+ val clientA = createClient()
+ val clientB = createClient()
+ val clientC = createClient()
+ val data = byteArrayOf(1, 2, 3)
+ val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
+ val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
+ val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
+ assertTrue(success)
+ assertNotNull(recordB)
+ assertArrayEquals(data, recordB!!.first)
+ assertNull("Unauthorized client should not access record", recordC)
+ clientA.stop()
+ clientB.stop()
+ clientC.stop()
+ }
+
+ @Test
+ fun getNonExistentRecord_ReturnsNull() = runBlocking {
+ val clientA = createClient()
+ val clientB = createClient()
+ val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
+ assertNull("Getting non-existent record should return null", record)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun updateRecord_TimestampUpdated() = runBlocking {
+ val clientA = createClient()
+ val clientB = createClient()
+ val key = "updateKey"
+ val data1 = byteArrayOf(1)
+ val data2 = byteArrayOf(2)
+ clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
+ val record1 = clientB.getRecord(clientA.localPublicKey, key)
+ delay(1000.milliseconds)
+ clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
+ val record2 = clientB.getRecord(clientA.localPublicKey, key)
+ assertNotNull(record1)
+ assertNotNull(record2)
+ assertTrue(record2!!.second > record1!!.second)
+ assertArrayEquals(data2, record2.first)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun deleteRecord_Success() = runBlocking {
+ val clientA = createClient()
+ val clientB = createClient()
+ val data = byteArrayOf(1, 2, 3)
+ clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
+ val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
+ val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
+ assertTrue(success)
+ assertNull(record)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun listRecordKeys_Success() = runBlocking {
+ val clientA = createClient()
+ val clientB = createClient()
+ val keys = arrayOf("key1", "key2", "key3")
+ keys.forEach { key ->
+ clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
+ }
+ val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
+ assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
+ val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
+ val tcsA = CompletableDeferred()
+ val tcsB = CompletableDeferred()
+ val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
+ val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
+ val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
+ val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
+ channelA.authorizable = AlwaysAuthorized()
+ val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
+ channelB.authorizable = AlwaysAuthorized()
+ channelTask.await()
+
+ val tcsDataB = CompletableDeferred()
+ channelB.setDataHandler { _, _, o, so, d ->
+ val b = ByteArray(d.remaining())
+ d.get(b)
+ if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
+ }
+ channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
+ val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
+ assertArrayEquals(largeData, receivedData)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun publishAndGetLargeRecord_Success() = runBlocking {
+ val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
+ val clientA = createClient()
+ val clientB = createClient()
+ val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
+ val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
+ assertTrue(success)
+ assertNotNull(record)
+ assertArrayEquals(largeData, record!!.first)
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun relayedTransport_WithValidAppId_Success() = runBlocking {
+ // Arrange: Set up clients
+ val allowedAppId = 1234u
+ val tcsB = CompletableDeferred()
+
+ // Client B requires appId 1234
+ val clientB = createClient(
+ onNewChannel = { _, c -> tcsB.complete(c) },
+ isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
+ )
+
+ val clientA = createClient()
+
+ // Act: Start relayed channel with valid appId
+ val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
+ val channelB = withTimeout(5.seconds) { tcsB.await() }
+ withTimeout(5.seconds) { channelTask.await() }
+
+ // Assert: Channel is established
+ assertNotNull("Channel should be created on target with valid appId", channelB)
+
+ // Clean up
+ clientA.stop()
+ clientB.stop()
+ }
+
+ @Test
+ fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
+ // Arrange: Set up clients
+ val allowedAppId = 1234u
+ val invalidAppId = 5678u
+ val tcsB = CompletableDeferred()
+
+ // Client B requires appId 1234
+ val clientB = createClient(
+ onNewChannel = { _, c -> tcsB.complete(c) },
+ isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
+ onException = { }
+ )
+
+ val clientA = createClient()
+
+ // Act & Assert: Attempt with invalid appId should fail
+ try {
+ withTimeout(5.seconds) {
+ clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
+ }
+ fail("Starting relayed channel with invalid appId should fail")
+ } catch (e: Throwable) {
+ // Expected: The channel creation should time out or fail
+ }
+
+ // Ensure no channel was created on client B
+ val completedTask = select {
+ tcsB.onAwait { "channel" }
+ async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
+ }
+ assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
+
+ // Clean up
+ clientA.stop()
+ clientB.stop()
+ }
+}
+
+class AlwaysAuthorized : IAuthorizable {
+ override val isAuthorized: Boolean get() = true
+}*/
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
new file mode 100644
index 00000000..d34bfad4
--- /dev/null
+++ b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
@@ -0,0 +1,512 @@
+package com.futo.platformplayer
+
+import com.futo.platformplayer.noise.protocol.DHState
+import com.futo.platformplayer.noise.protocol.Noise
+import com.futo.platformplayer.sync.internal.*
+import kotlinx.coroutines.*
+import org.junit.Assert.*
+import org.junit.Test
+import java.io.PipedInputStream
+import java.io.PipedOutputStream
+import java.nio.ByteBuffer
+import kotlin.random.Random
+import java.io.InputStream
+import java.io.OutputStream
+import kotlin.time.Duration.Companion.seconds
+/*
+data class PipeStreams(
+ val initiatorInput: LittleEndianDataInputStream,
+ val initiatorOutput: LittleEndianDataOutputStream,
+ val responderInput: LittleEndianDataInputStream,
+ val responderOutput: LittleEndianDataOutputStream
+)
+
+typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
+typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
+typealias OnClose = (SyncSocketSession) -> Unit
+typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
+
+class SyncSocketTests {
+ private fun createPipeStreams(): PipeStreams {
+ val initiatorOutput = PipedOutputStream()
+ val responderOutput = PipedOutputStream()
+ val responderInput = PipedInputStream(initiatorOutput)
+ val initiatorInput = PipedInputStream(responderOutput)
+ return PipeStreams(
+ LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
+ LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
+ )
+ }
+
+ fun generateKeyPair(): DHState {
+ val p = Noise.createDH("25519")
+ p.generateKeyPair()
+ return p
+ }
+
+ private fun createSessions(
+ initiatorInput: LittleEndianDataInputStream,
+ initiatorOutput: LittleEndianDataOutputStream,
+ responderInput: LittleEndianDataInputStream,
+ responderOutput: LittleEndianDataOutputStream,
+ initiatorKeyPair: DHState,
+ responderKeyPair: DHState,
+ onInitiatorHandshakeComplete: OnHandshakeComplete,
+ onResponderHandshakeComplete: OnHandshakeComplete,
+ onInitiatorClose: OnClose? = null,
+ onResponderClose: OnClose? = null,
+ onClose: OnClose? = null,
+ isHandshakeAllowed: IsHandshakeAllowed? = null,
+ onDataA: OnData? = null,
+ onDataB: OnData? = null
+ ): Pair {
+ val initiatorSession = SyncSocketSession(
+ "", initiatorKeyPair, initiatorInput, initiatorOutput,
+ onClose = {
+ onClose?.invoke(it)
+ onInitiatorClose?.invoke(it)
+ },
+ onHandshakeComplete = onInitiatorHandshakeComplete,
+ onData = onDataA,
+ isHandshakeAllowed = isHandshakeAllowed
+ )
+
+ val responderSession = SyncSocketSession(
+ "", responderKeyPair, responderInput, responderOutput,
+ onClose = {
+ onClose?.invoke(it)
+ onResponderClose?.invoke(it)
+ },
+ onHandshakeComplete = onResponderHandshakeComplete,
+ onData = onDataB,
+ isHandshakeAllowed = isHandshakeAllowed
+ )
+
+ return Pair(initiatorSession, responderSession)
+ }
+
+ @Test
+ fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+ val validPairingCode = "secret"
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
+ responderSession.startAsResponder()
+
+ withTimeout(5.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+ }
+
+ @Test
+ fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+ val validPairingCode = "secret"
+ val invalidPairingCode = "wrong"
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val initiatorClosed = CompletableDeferred()
+ val responderClosed = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onInitiatorClose = {
+ initiatorClosed.complete(true)
+ },
+ onResponderClose = {
+ responderClosed.complete(true)
+ },
+ isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
+ responderSession.startAsResponder()
+
+ withTimeout(100.seconds) {
+ initiatorClosed.await()
+ responderClosed.await()
+ }
+
+ assertFalse(handshakeInitiatorCompleted.isCompleted)
+ assertFalse(handshakeResponderCompleted.isCompleted)
+ }
+
+ @Test
+ fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+ val validPairingCode = "secret"
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val initiatorClosed = CompletableDeferred()
+ val responderClosed = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onInitiatorClose = {
+ initiatorClosed.complete(true)
+ },
+ onResponderClose = {
+ responderClosed.complete(true)
+ },
+ isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
+ responderSession.startAsResponder()
+
+ withTimeout(5.seconds) {
+ initiatorClosed.await()
+ responderClosed.await()
+ }
+
+ assertFalse(handshakeInitiatorCompleted.isCompleted)
+ assertFalse(handshakeResponderCompleted.isCompleted)
+ }
+
+ @Test
+ fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+ val pairingCode = "unnecessary"
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
+ responderSession.startAsResponder()
+
+ withTimeout(10.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+ }
+
+ @Test
+ fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val tcsDataReceived = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onDataB = { _, opcode, subOpcode, data ->
+ if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
+ val b = ByteArray(data.remaining())
+ data.get(b)
+ tcsDataReceived.complete(b)
+ }
+ }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey)
+ responderSession.startAsResponder()
+
+ withTimeout(10.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+
+ // Ensure both sessions are authorized
+ initiatorSession.authorizable = Authorized()
+ responderSession.authorizable = Authorized()
+
+ val smallData = byteArrayOf(1, 2, 3)
+ initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
+
+ val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
+ assertArrayEquals(smallData, receivedData)
+ }
+
+ @Test
+ fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val tcsDataReceived = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onDataB = { _, opcode, subOpcode, data ->
+ if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
+ val b = ByteArray(data.remaining())
+ data.get(b)
+ tcsDataReceived.complete(b)
+ }
+ }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey)
+ responderSession.startAsResponder()
+
+ withTimeout(10.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+
+ // Ensure both sessions are authorized
+ initiatorSession.authorizable = Authorized()
+ responderSession.authorizable = Authorized()
+
+ val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
+ initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
+
+ val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
+ assertArrayEquals(maxData, receivedData)
+ }
+
+ @Test
+ fun stream_LargeData_Succeeds() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val tcsDataReceived = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onDataB = { _, opcode, subOpcode, data ->
+ if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
+ val b = ByteArray(data.remaining())
+ data.get(b)
+ tcsDataReceived.complete(b)
+ }
+ }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey)
+ responderSession.startAsResponder()
+
+ withTimeout(10.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+
+ // Ensure both sessions are authorized
+ initiatorSession.authorizable = Authorized()
+ responderSession.authorizable = Authorized()
+
+ val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
+ initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
+
+ val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
+ assertArrayEquals(largeData, receivedData)
+ }
+
+ @Test
+ fun authorizedSession_CanSendData() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val tcsDataReceived = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onDataB = { _, opcode, subOpcode, data ->
+ if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
+ val b = ByteArray(data.remaining())
+ data.get(b)
+ tcsDataReceived.complete(b)
+ }
+ }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey)
+ responderSession.startAsResponder()
+
+ withTimeout(10.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+
+ // Authorize both sessions
+ initiatorSession.authorizable = Authorized()
+ responderSession.authorizable = Authorized()
+
+ val data = byteArrayOf(1, 2, 3)
+ initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
+
+ val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
+ assertArrayEquals(data, receivedData)
+ }
+
+ @Test
+ fun unauthorizedSession_CannotSendData() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val tcsDataReceived = CompletableDeferred()
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onDataB = { _, _, _, _ -> }
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey)
+ responderSession.startAsResponder()
+
+ withTimeout(10.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+
+ // Authorize initiator but not responder
+ initiatorSession.authorizable = Authorized()
+ responderSession.authorizable = Unauthorized()
+
+ val data = byteArrayOf(1, 2, 3)
+ initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
+
+ delay(1.seconds)
+ assertFalse(tcsDataReceived.isCompleted)
+ }
+
+ @Test
+ fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+ val allowedAppId = 1234u
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+
+ val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
+ linkType == LinkType.Direct && appId == allowedAppId
+ }
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ isHandshakeAllowed = responderIsHandshakeAllowed
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
+ responderSession.startAsResponder()
+
+ withTimeout(5.seconds) {
+ handshakeInitiatorCompleted.await()
+ handshakeResponderCompleted.await()
+ }
+
+ assertNotNull(initiatorSession.remotePublicKey)
+ assertNotNull(responderSession.remotePublicKey)
+ }
+
+ @Test
+ fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
+ val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
+ val initiatorKeyPair = generateKeyPair()
+ val responderKeyPair = generateKeyPair()
+ val allowedAppId = 1234u
+ val invalidAppId = 5678u
+
+ val handshakeInitiatorCompleted = CompletableDeferred()
+ val handshakeResponderCompleted = CompletableDeferred()
+ val initiatorClosed = CompletableDeferred()
+ val responderClosed = CompletableDeferred()
+
+ val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
+ linkType == LinkType.Direct && appId == allowedAppId
+ }
+
+ val (initiatorSession, responderSession) = createSessions(
+ initiatorInput, initiatorOutput, responderInput, responderOutput,
+ initiatorKeyPair, responderKeyPair,
+ { handshakeInitiatorCompleted.complete(true) },
+ { handshakeResponderCompleted.complete(true) },
+ onInitiatorClose = {
+ initiatorClosed.complete(true)
+ },
+ onResponderClose = {
+ responderClosed.complete(true)
+ },
+ isHandshakeAllowed = responderIsHandshakeAllowed
+ )
+
+ initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
+ responderSession.startAsResponder()
+
+ withTimeout(5.seconds) {
+ initiatorClosed.await()
+ responderClosed.await()
+ }
+
+ assertFalse(handshakeInitiatorCompleted.isCompleted)
+ assertFalse(handshakeResponderCompleted.isCompleted)
+ }
+}
+
+class Authorized : IAuthorizable {
+ override val isAuthorized: Boolean = true
+}
+
+class Unauthorized : IAuthorizable {
+ override val isAuthorized: Boolean = false
+}*/
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 276e2610..ea6f3e5b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,8 +11,10 @@
+
+
+
+
+
+
+
+
-
@@ -50,11 +55,10 @@
@@ -147,34 +151,30 @@
-
-
@@ -188,44 +188,55 @@
-
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/assets/devportal/dependencies/favicon.svg b/app/src/main/assets/devportal/dependencies/favicon.svg
new file mode 100644
index 00000000..5618ebe1
--- /dev/null
+++ b/app/src/main/assets/devportal/dependencies/favicon.svg
@@ -0,0 +1,15 @@
+
diff --git a/app/src/main/assets/devportal/dev_bridge.js b/app/src/main/assets/devportal/dev_bridge.js
index 8186c8ae..34271a18 100644
--- a/app/src/main/assets/devportal/dev_bridge.js
+++ b/app/src/main/assets/devportal/dev_bridge.js
@@ -233,6 +233,9 @@ function pluginRemoteProp(objID, propName) {
function pluginRemoteCall(objID, methodName, args) {
return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args)));
}
+function pluginRemoteTest(methodName, args) {
+ return JSON.parse(syncPOST("/plugin/remoteTest?method=" + methodName, {}, JSON.stringify(args)));
+}
function pluginIsLoggedIn(cb, err) {
fetch("/plugin/isLoggedIn", {
@@ -259,6 +262,17 @@ function getDevLogs(lastIndex, cb) {
.then(x=>x.json())
.then(y=> cb && cb(y));
}
+function getDevHttpExchanges(cb) {
+ fetch("/plugin/getDevHttpExchanges", {
+ timeout: 1000
+ })
+ .then(x=>x.json())
+ .then(y=> cb && cb(y));
+}
+function setDevHttpProxy(url, port) {
+ return fetch("/dev/setDevProxy?url=" + encodeURIComponent(url) + "&port=" + port)
+ .then(x=>x.json());
+}
function sendFakeDevLog(devId, msg) {
return syncGET("/plugin/fakeDevLog?devId=" + devId + "&msg=" + msg, {});
}
diff --git a/app/src/main/assets/devportal/index.html b/app/src/main/assets/devportal/index.html
index a9f41794..9ae84f5a 100644
--- a/app/src/main/assets/devportal/index.html
+++ b/app/src/main/assets/devportal/index.html
@@ -7,6 +7,9 @@
+ DevPortal
+
+
-
+
+
Loading..
+ First load may take longer
+
+
-
+
Past Plugins
{{pastPluginUrl}}
+
{ev.stopPropagation(); deletePastPlugin(pastPluginUrl)}">
+ X
+