diff --git a/.aiderignore b/.aiderignore
new file mode 100644
index 0000000..b5f02f1
--- /dev/null
+++ b/.aiderignore
@@ -0,0 +1,2 @@
+*.g.dart
+*.g.kt
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 9d520e6..017bbd3 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,3 @@
+github: roc-streaming
open_collective: roc-streaming
liberapay: roc-streaming
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
deleted file mode 100644
index c447139..0000000
--- a/.github/workflows/build.yaml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: build
-
-on:
- push:
- branches:
- - main
- tags:
- - v*
-
- pull_request:
- branches:
- - main
-
- workflow_dispatch:
-
- schedule:
- - cron: '0 0 * * 1'
-
-jobs:
- build:
- strategy:
- matrix:
- os: [ubuntu-latest, macos-latest]
-
- runs-on: ${{ matrix.os }}
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Setup Java
- run: echo "JAVA_HOME=$JAVA_HOME_17_X64$JAVA_HOME_17_arm64" >> $GITHUB_ENV
-
- - name: Install SDK
- uses: malinskiy/action-android/install-sdk@release/0.1.4
-
- - name: Install NDK
- run: |
- sdkmanager --install "ndk;$(grep ndkVersion app/gradle.properties | cut -d= -f2)"
-
- - name: Build
- run: |
- ./gradlew build
-
- release:
- if: startsWith(github.ref, 'refs/tags/v')
- needs: [build]
-
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v3
- with:
- fetch-depth: 0
-
- - name: Setup Java
- run: echo "JAVA_HOME=$JAVA_HOME_17_X64" >> $GITHUB_ENV
-
- - name: Install SDK
- uses: malinskiy/action-android/install-sdk@release/0.1.4
-
- - name: Install NDK
- run: |
- sdkmanager --install "ndk;$(grep ndkVersion app/gradle.properties | cut -d= -f2)"
-
- - name: Check version
- run: |
- ./gradlew checkVersion
-
- - name: Build APK
- env:
- SIGNING_STORE_FILE: roc-droid.jks
- SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
- SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
- SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
- run: |
- echo "${{ secrets.SIGNING_STORE_BASE64 }}" | base64 -di > app/${{ env.SIGNING_STORE_FILE }}
- ./gradlew assembleRelease
-
- - name: Release
- uses: softprops/action-gh-release@v1
- with:
- draft: true
- files: app/build/outputs/apk/release/roc-droid-*.apk
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..d522f58
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,161 @@
+name: "build"
+
+on:
+ push:
+ branches:
+ - flutter
+
+ pull_request:
+
+ repository_dispatch:
+ types:
+ - trigger_build
+
+ workflow_dispatch:
+ workflow_call:
+
+ schedule:
+ - cron: '0 0 * * 1'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # native build on desktop
+ desktop:
+ strategy:
+ matrix:
+ include:
+ - host: linux
+ image: ubuntu-latest
+
+ runs-on: ${{ matrix.image }}
+ name: desktop/${{ matrix.host }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install dependencies
+ uses: roc-streaming/ci/actions/install-packages@main
+ with:
+ packages: ninja-build libgtk-3-dev
+
+ - name: Install flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+
+ - name: Install python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+
+ - name: Install doit
+ run: |
+ pip install doit
+
+ - name: Run checks
+ run: |
+ doit desktop:check
+
+ - name: Run build (debug)
+ run: |
+ doit desktop:build variant=debug
+
+ - name: Run build (release)
+ run: |
+ doit desktop:build variant=release
+
+ - name: Run tests
+ run: |
+ doit desktop:test
+
+ # build for android on different hosts
+ android:
+ strategy:
+ matrix:
+ include:
+ - host: linux
+ image: ubuntu-latest
+
+ - host: macos
+ image: macos-latest
+
+ - host: windows
+ image: windows-latest
+
+ runs-on: ${{ matrix.image }}
+ name: android/${{ matrix.host }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install JDK
+ uses: actions/setup-java@v4
+ with:
+ java-version: 17
+ distribution: temurin
+
+ - name: Install flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+
+ - name: Install python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+
+ - name: Install doit
+ run: |
+ pip install doit
+
+ - name: Run checks
+ run: |
+ doit android:check
+
+ - name: Run build (debug)
+ run: |
+ doit android:build variant=debug
+
+ - name: Run build (release)
+ run: |
+ doit android:build variant=release
+
+ - name: Run tests
+ run: |
+ doit android:test
+
+ # build for different targets on different hosts using docker
+ docker:
+ strategy:
+ matrix:
+ include:
+ - host: linux
+ target: android
+ image: ubuntu-latest
+
+ runs-on: ${{ matrix.image }}
+ name: docker/${{ matrix.target }}-${{ matrix.host }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Build
+ if: runner.os != 'Windows'
+ run: |
+ ./script/docker_build.sh
+
+ - name: Build (Windows)
+ if: runner.os == 'Windows'
+ run: |
+ .\script\docker_build.bat
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c5bed2e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,71 @@
+name: "release"
+
+on:
+ push:
+ tags:
+ - v*
+
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+
+jobs:
+ build:
+ uses: ./.github/workflows/build.yml
+
+ release:
+ needs: [build]
+
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install JDK
+ uses: actions/setup-java@v4
+ with:
+ java-version: 17
+ distribution: temurin
+
+ - name: Install flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+
+ - name: Install python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+
+ - name: Install doit
+ run: |
+ pip install doit
+
+ - name: Check manifests
+ run: |
+ ./script/version_ctl.py check_release
+
+ - name: Build APK
+ env:
+ SIGNING_STORE_FILE: roc-droid.jks
+ SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
+ SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
+ SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
+ run: |
+ echo "${{ secrets.SIGNING_STORE_BASE64 }}" | base64 -di > app/${{ env.SIGNING_STORE_FILE }}
+ doit android:build variant=release
+
+ - name: Deploy APK
+ uses: softprops/action-gh-release@v2
+ with:
+ draft: true
+ files: dist/android/release/roc-droid-*.apk
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index b98248d..045017a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,46 @@
+# build
+/build
+/dist
+/site
+
+# flutter
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+app.*.map.json
+app.*.symbols
+
+# java
+*.class
*.iml
-.gradle
*.jks
-/local.properties
-/.idea/*
-.DS_Store
-/build
-/captures
-.externalNativeBuild
.cxx
-.idea
+.externalNativeBuild
+.gradle
+local.properties
+
+# doit and scripts
+.doit.db*
+*.pyc
+
+# temp
+.DS_Store
+.buildlog
+
+# editors
+*.iml
+*.ipr
+*.iws
+*.swp
+.atom
+.idea/
.vscode
+.$*
+
+# personal
+/TODO.org
+.aider.*
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..6eb54a1
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,45 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: android
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: ios
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: linux
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: macos
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: web
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ - platform: windows
+ create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+ base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..1125187
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,25 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "roc-droid",
+ "request": "launch",
+ "type": "dart"
+ },
+ {
+ "name": "roc-droid (profile mode)",
+ "request": "launch",
+ "type": "dart",
+ "flutterMode": "profile"
+ },
+ {
+ "name": "roc-droid (release mode)",
+ "request": "launch",
+ "type": "dart",
+ "flutterMode": "release"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 237b863..345b652 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Roc for Android!
-[](https://github.com/roc-streaming/roc-droid/actions/workflows/build.yaml) [](https://github.com/roc-streaming/roc-droid/releases) [](https://app.element.io/#/room/#roc-streaming:matrix.org)
+[](https://github.com/roc-streaming/roc-droid/actions/workflows/build.yaml) [](https://github.com/roc-streaming/roc-droid/releases) [](https://app.element.io/#/room/#roc-streaming:matrix.org)
Android app implementing Roc sender and receiver. **Work in progress!**
@@ -9,29 +9,34 @@ Features:
* **receive** sound from remote Roc-compatible sender and **play** to local audio device
* **capture** sound from apps or microphone and **send** to remote Roc-compatible receiver
-Download
---------
+## Installation
-* Download APK from [latest release](https://github.com/roc-streaming/roc-droid/releases/latest)
+#### From repository
-* Download from F-Droid or IzzyOnDroid:
+Download from F-Droid or IzzyOnDroid:
[
](https://f-droid.org/packages/org.rocstreaming.rocdroid/)
+ height="70">](https://f-droid.org/packages/org.rocstreaming.rocdroid/)
[
](https://apt.izzysoft.de/fdroid/index/apk/org.rocstreaming.rocdroid)
+ height="70">](https://apt.izzysoft.de/fdroid/index/apk/org.rocstreaming.rocdroid)
-Screenshot
-----------
+#### From binaries
-
+Download pre-built APK from latest [github release](https://github.com/roc-streaming/roc-droid/releases/latest).
-Features
---------
+#### From sources
-Key features of [Roc Toolkit](https://github.com/roc-streaming/roc-toolkit) streaming engine, used by Roc Droid:
+Follow instructions here: [build project](https://roc-streaming.org/droid/building/build_project).
+
+## Screenshot
+
+
+
+## Features
+
+Roc Droid is based on [Roc Toolkit](https://github.com/roc-streaming/roc-toolkit) streaming engine, which notable features are:
* real-time streaming with guaranteed latency;
* robust work on unreliable networks like Wi-Fi, due to use of Forward Erasure Correction codes;
@@ -52,109 +57,20 @@ If you would like to support the project financially, please refer to [this page
Thank you!
-
-
-Building
---------
-
-The app uses [Java bindings for Roc Toolkit](https://github.com/roc-streaming/roc-java). You don't need to install them manually; gradle will automatically download AAR from maven central, which contains both libroc and Java bindings built for all Android ABIs.
-
-The easiest way to build the app is using Android Studio.
-
-Alternatively, you can build and deploy APK from command-line.
-
-Build:
-
-```
-./gradlew build
-```
-
-Install to device:
-
-```
-adb install app/build/outputs/apk/debug/roc-droid-*.apk
-```
-
-Development
------------
-
-To check code style use:
-
-```
-./gradlew spotlessCheck
-```
-
-To apply code style use:
-
-```
-./gradlew spotlessApply
-```
-
-To check consistency of version name and code:
-
-```
-./gradlew checkVersion
-```
-
-Signing
--------
-
-Keystore with certificates was generated using this command:
-
-```
-keytool -genkey -v -keystore roc-droid.jks -alias apk -keyalg RSA -keysize 2048 -validity 10000
-```
-
-Then it was encoded to base64:
-
-```
-base64 roc-droid.jks
-```
-
-Then the following secrets were added to the repo:
-
-* `SIGNING_STORE_BASE64` - base64-encoded keystore (`roc-droid.jks`)
-* `SIGNING_STORE_PASSWORD` - keystore password
-* `SIGNING_KEY_ALIAS` - key alias (`apk`)
-* `SIGNING_KEY_PASSWORD` - key password (same as keystore password)
-
-GitHub actions decode `SIGNING_STORE_BASE64` into a temporary `.jks` file and set `SIGNING_*` environment variables with the name of the file and credentials.
-
-Then the following command is run:
-
-```
-./gradlew assembleRelease
-```
-
-It reads credentials from the environment variables and signs release APK using them.
-
-Release
--------
-
-To release a new version:
-
-* Create git tag
-
- ```
- ./tag.py --push
- ```
-
- e.g.
+
- ```
- ./tag.py --push origin 1.2.3
- ```
+## Hacking
- Or use **tag.py** without **--push** to only create a tag locally, and then push it manually.
+Contributions in any form are very welcome! You can find issues needing help using [help wanted](https://github.com/roc-streaming/roc-droid/labels/help%20wanted) and [good first issue](https://github.com/roc-streaming/roc-droid/labels/good%20first%20issue) labels.
-* Wait until "Release" CI job completes and creates GitHub release draft.
+Please refer to [online documentation](https://roc-streaming.org/droid/) to get an idea about project internals and development flow.
-* Edit GitHub release created by CI and publish it.
+Welcome to join our matrix chat rooms for [users](https://app.element.io/#/room/#roc-streaming:matrix.org) and [developers](https://app.element.io/#/room/#roc-streaming-dev:matrix.org).
Authors
-------
-See [here](https://github.com/roc-streaming/roc-droid/graphs/contributors).
+You can find the list of maintainer and contributors on [this page](https://roc-streaming.org/droid/authors/).
License
-------
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..29c8b68
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,69 @@
+analyzer:
+ strong-mode:
+ implicit-casts: false
+ implicit-dynamic: false
+ errors:
+ todo: ignore
+ exclude:
+ - 'build/**'
+ - '**/*.g.dart'
+
+linter:
+ rules:
+ # based on effective_dart rules
+ # https://github.com/tenhobi/effective_dart/blob/master/lib/analysis_options.1.2.0.yaml
+ # https://dart.dev/tools/linter-rules
+ - annotate_overrides
+ - avoid_catches_without_on_clauses
+ - avoid_relative_lib_imports
+ - avoid_return_types_on_setters
+ - avoid_returning_null
+ - avoid_returning_null_for_void
+ - await_only_futures
+ - camel_case_extensions
+ - camel_case_types
+ - collection_methods_unrelated_type
+ - constant_identifier_names
+ - control_flow_in_finally
+ - curly_braces_in_flow_control_structures
+ - depend_on_referenced_packages
+ - directives_ordering
+ - discarded_futures
+ - empty_constructor_bodies
+ - exhaustive_cases
+ - file_names
+ - hash_and_equals
+ - implementation_imports
+ - library_names
+ - library_prefixes
+ - no_duplicate_case_values
+ - no_leading_underscores_for_library_prefixes
+ - no_leading_underscores_for_local_identifiers
+ - no_wildcard_variable_uses
+ - non_constant_identifier_names
+ - null_check_on_nullable_type_parameter
+ - null_closures
+ - package_names
+ - prefer_equal_for_default_values
+ - prefer_generic_function_type_aliases
+ - prefer_mixin
+ - prefer_relative_imports
+ - recursive_getters
+ - slash_for_doc_comments
+ - type_annotate_public_apis
+ - type_init_formals
+ - unawaited_futures
+ - unintended_html_in_doc_comment
+ - unnecessary_const
+ - unnecessary_late
+ - unnecessary_new
+ - unnecessary_null_aware_assignments
+ - unnecessary_null_in_if_null_operators
+ - unnecessary_nullable_for_final_variable_declarations
+ - unnecessary_to_list_in_spreads
+ - unrelated_type_equality_checks
+ - use_function_type_syntax_for_parameters
+ - use_rethrow_when_possible
+ - use_string_in_part_of_directives
+ - valid_regexps
+ - void_checks
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..8eaf34c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,11 @@
+/.gradle
+/captures/
+/local.properties
+/build
+GeneratedPluginRegistrant.java
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..0e84c0e
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,175 @@
+import org.yaml.snakeyaml.Yaml
+
+plugins {
+ id "com.android.application"
+ id "kotlin-android"
+ // flutter gradle plugin must be applied after android and kotlin plugins
+ id "dev.flutter.flutter-gradle-plugin"
+}
+
+def localProperties = new Properties()
+def localPropertiesFile = rootProject.file("local.properties")
+if (localPropertiesFile.exists()) {
+ localPropertiesFile.withReader("UTF-8") { reader ->
+ localProperties.load(reader)
+ }
+}
+
+def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
+if (flutterVersionCode == null) {
+ flutterVersionCode = "1"
+}
+
+def flutterVersionName = localProperties.getProperty("flutter.versionName")
+if (flutterVersionName == null) {
+ flutterVersionName = "1.0"
+}
+
+def readPubspecVersion = { ->
+ def pubspec = new Yaml().load(file("../../pubspec.yaml").text)
+ return pubspec.version
+}
+
+def readManifestVersion = { ->
+ def manifest = new XmlSlurper().parse(file("src/main/AndroidManifest.xml"))
+ return manifest.@"android:versionName".text()
+}
+
+def readManifestVersionCode = { ->
+ def manifest = new XmlSlurper().parse(file("src/main/AndroidManifest.xml"))
+ return manifest.@"android:versionCode".text().toInteger()
+}
+
+def validateVersion = { ->
+ def pubspecVersion = readPubspecVersion()
+ def manifestVersion = readManifestVersion()
+
+ if (pubspecVersion != manifestVersion) {
+ throw new GradleException(
+ "Mismatched versions in pubspec.yaml and AndroidManifest.xml:"+
+ "\n pubspecVersion = $pubspecVersion"+
+ "\n manifestVersion = $manifestVersion")
+ }
+}
+
+apply plugin: "com.android.application"
+apply plugin: "kotlin-android"
+
+android {
+ namespace = "org.rocstreaming.rocdroid"
+
+ compileSdk = project.compileSdkVersion.toInteger()
+ ndkVersion = project.ndkVersion
+
+ defaultConfig {
+ applicationId = "org.rocstreaming.rocdroid"
+
+ targetSdkVersion = project.targetSdkVersion.toInteger()
+ minSdkVersion = project.minSdkVersion.toInteger()
+
+ versionName readManifestVersion()
+ versionCode readManifestVersionCode()
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion "1.4.0"
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+
+ if (System.getenv("SIGNING_STORE_FILE") != null) {
+ signingConfigs {
+ release {
+ storeFile file(System.getenv("SIGNING_STORE_FILE"))
+ storePassword System.getenv("SIGNING_STORE_PASSWORD")
+ keyAlias System.getenv("SIGNING_KEY_ALIAS")
+ keyPassword System.getenv("SIGNING_KEY_PASSWORD")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ shrinkResources true
+ minifyEnabled true
+
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
+ 'proguard-rules.pro'
+
+ if (System.getenv("SIGNING_STORE_FILE") != null) {
+ signingConfig signingConfigs.release
+ } else {
+ signingConfig signingConfigs.debug
+ }
+ }
+ }
+
+ // https://android.izzysoft.de/articles/named/iod-scan-apkchecks#blobs
+ // https://gist.github.com/obfusk/31c332b884464cd8aa06ce1ba1583c05
+ dependenciesInfo {
+ includeInApk = false
+ includeInBundle = false
+ }
+}
+
+flutter {
+ source = "../.."
+}
+
+dependencies {
+ // kotlin
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ // androidx
+ implementation "androidx.core:core-ktx:1.9.0"
+ implementation "androidx.activity:activity-ktx:1.9.2"
+ implementation "androidx.fragment:fragment-ktx:1.8.4"
+ implementation "androidx.appcompat:appcompat:1.1.0"
+ implementation "androidx.constraintlayout:constraintlayout:2.1.4"
+ implementation "androidx.viewpager2:viewpager2:1.1.0"
+ // flutter
+ implementation "com.google.android.material:material:1.3.0"
+ implementation "pl.droidsonroids.gif:android-gif-drawable:1.2.25"
+ // roc
+ implementation "org.roc-streaming.roctoolkit:roc-android:0.2.1"
+}
+
+task validateVersionTask {
+ doLast {
+ validateVersion()
+ }
+}
+
+tasks.matching { it.name.startsWith("assemble") }.configureEach {
+ dependsOn validateVersionTask
+}
+
+// copy apk into dist/android//roc-droid-.apk
+android.applicationVariants.all { variant ->
+ variant.outputs.all { output ->
+ def buildDir = project.layout.buildDirectory.get().asFile.absolutePath
+ def apk = output.outputFile
+ if (apk.name == "app-${variant.name}.apk") {
+ def task = tasks.register("copy${variant.name.capitalize()}Apk", Copy) {
+ from apk.parent
+ into "$buildDir/../../dist/android/${variant.name}"
+ include apk.name
+ rename { "roc-droid-${readManifestVersion()}.apk" }
+ }
+ variant.assembleProvider.configure {
+ finalizedBy(task)
+ }
+ }
+ }
+}
diff --git a/android/app/gradle.properties b/android/app/gradle.properties
new file mode 100644
index 0000000..e4bf95c
--- /dev/null
+++ b/android/app/gradle.properties
@@ -0,0 +1,8 @@
+# android 15
+compileSdkVersion=35
+# android 10
+targetSdkVersion=29
+# android 10
+minSdkVersion=29
+# ndk
+ndkVersion=26.1.10909125
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..16bcaa3
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+-dontwarn lombok.Generated
+-keep class org.rocstreaming.roctoolkit.** { *; }
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9546985
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/org/rocstreaming/rocdroid/AndroidBridge.g.kt b/android/app/src/main/java/org/rocstreaming/rocdroid/AndroidBridge.g.kt
new file mode 100644
index 0000000..5e72814
--- /dev/null
+++ b/android/app/src/main/java/org/rocstreaming/rocdroid/AndroidBridge.g.kt
@@ -0,0 +1,519 @@
+// Autogenerated from Pigeon (v22.7.2), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
+
+
+import android.util.Log
+import io.flutter.plugin.common.BasicMessageChannel
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MessageCodec
+import io.flutter.plugin.common.StandardMethodCodec
+import io.flutter.plugin.common.StandardMessageCodec
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+
+private fun wrapResult(result: Any?): List {
+ return listOf(result)
+}
+
+private fun wrapError(exception: Throwable): List {
+ return if (exception is FlutterError) {
+ listOf(
+ exception.code,
+ exception.message,
+ exception.details
+ )
+ } else {
+ listOf(
+ exception.javaClass.simpleName,
+ exception.toString(),
+ "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+ )
+ }
+}
+
+private fun createConnectionError(channelName: String): FlutterError {
+ return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "")}
+
+/**
+ * Error class for passing custom error details to Flutter via a thrown PlatformException.
+ * @property code The error code.
+ * @property message The error message.
+ * @property details The error details. Must be a datatype supported by the api codec.
+ */
+class FlutterError (
+ val code: String,
+ override val message: String? = null,
+ val details: Any? = null
+) : Throwable()
+
+/** Where sender gets sound. */
+enum class AndroidCaptureSource(val raw: Int) {
+ /** Capture from locally playing apps. */
+ CAPTURE_APPS(0),
+ /** Capture from local microphone. */
+ CAPTURE_MIC(1);
+
+ companion object {
+ fun ofRaw(raw: Int): AndroidCaptureSource? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+/** Asynchronous events produced by android service. */
+enum class AndroidServiceEvent(val raw: Int) {
+ STREAMING_SERVICE_CONNECTED(0),
+ STREAMING_SERVICE_DISCONNECTED(1),
+ SENDER_STATE_CHANGED(2),
+ RECEIVER_STATE_CHANGED(3);
+
+ companion object {
+ fun ofRaw(raw: Int): AndroidServiceEvent? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+/** Asynchronous errors produced by android service. */
+enum class AndroidServiceError(val raw: Int) {
+ AUDIO_RECORD_FAILED(0),
+ AUDIO_TRACK_FAILED(1),
+ SENDER_CONNECT_FAILED(2),
+ RECEIVER_BIND_FAILED(3);
+
+ companion object {
+ fun ofRaw(raw: Int): AndroidServiceError? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+/**
+ * Receiver settings.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AndroidReceiverSettings (
+ /** Local port to receive source packets. */
+ val sourcePort: Long,
+ /** Local port to receive repair packets. */
+ val repairPort: Long
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AndroidReceiverSettings {
+ val sourcePort = pigeonVar_list[0] as Long
+ val repairPort = pigeonVar_list[1] as Long
+ return AndroidReceiverSettings(sourcePort, repairPort)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ sourcePort,
+ repairPort,
+ )
+ }
+}
+
+/**
+ * Sender settings.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AndroidSenderSettings (
+ /** From where to capture stream. */
+ val captureSource: AndroidCaptureSource,
+ /** IP address or hostname where to send packets. */
+ val host: String,
+ /** Remote port where to send source packets. */
+ val sourcePort: Long,
+ /** Remote port where to send repair packets. */
+ val repairPort: Long
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AndroidSenderSettings {
+ val captureSource = pigeonVar_list[0] as AndroidCaptureSource
+ val host = pigeonVar_list[1] as String
+ val sourcePort = pigeonVar_list[2] as Long
+ val repairPort = pigeonVar_list[3] as Long
+ return AndroidSenderSettings(captureSource, host, sourcePort, repairPort)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ captureSource,
+ host,
+ sourcePort,
+ repairPort,
+ )
+ }
+}
+private open class AndroidBridgePigeonCodec : StandardMessageCodec() {
+ override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+ return when (type) {
+ 129.toByte() -> {
+ return (readValue(buffer) as Long?)?.let {
+ AndroidCaptureSource.ofRaw(it.toInt())
+ }
+ }
+ 130.toByte() -> {
+ return (readValue(buffer) as Long?)?.let {
+ AndroidServiceEvent.ofRaw(it.toInt())
+ }
+ }
+ 131.toByte() -> {
+ return (readValue(buffer) as Long?)?.let {
+ AndroidServiceError.ofRaw(it.toInt())
+ }
+ }
+ 132.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ AndroidReceiverSettings.fromList(it)
+ }
+ }
+ 133.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ AndroidSenderSettings.fromList(it)
+ }
+ }
+ else -> super.readValueOfType(type, buffer)
+ }
+ }
+ override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
+ when (value) {
+ is AndroidCaptureSource -> {
+ stream.write(129)
+ writeValue(stream, value.raw)
+ }
+ is AndroidServiceEvent -> {
+ stream.write(130)
+ writeValue(stream, value.raw)
+ }
+ is AndroidServiceError -> {
+ stream.write(131)
+ writeValue(stream, value.raw)
+ }
+ is AndroidReceiverSettings -> {
+ stream.write(132)
+ writeValue(stream, value.toList())
+ }
+ is AndroidSenderSettings -> {
+ stream.write(133)
+ writeValue(stream, value.toList())
+ }
+ else -> super.writeValue(stream, value)
+ }
+ }
+}
+
+
+/**
+ * Allows to invoke kotlin methods from dart.
+ *
+ * This declaration emits 2 classes:
+ * dart: AndroidController implementation class, which methods invoke kotlin
+ * methods under the hood (via platform channels)
+ * kotlin: AndroidController interface, which we implement in
+ * AndroidControllerImpl, where the actual work is done
+ *
+ * Generated interface from Pigeon that represents a handler of messages from Flutter.
+ */
+interface AndroidController {
+ /**
+ * Request permission to post notifications, if no already granted.
+ * Must be called before acquiring projection first time.
+ * Throws exception if:
+ * - permission was not granted
+ */
+ fun requestNotifications(callback: (Result) -> Unit)
+ /**
+ * Request permission to capture local microphone, if not already granted.
+ * Must be called before starting sender when using AndroidCaptureSource.captureMic.
+ * Throws exception if:
+ * - permission was not granted
+ */
+ fun requestMicrophone(callback: (Result) -> Unit)
+ /**
+ * Request access to media projection, if not already granted.
+ * Must be called before starting sender or receiver.
+ * If returns false, user rejected access and sender/receiver won't start.
+ * Throws exception if:
+ * - media projection wasn't acquired
+ * - lost connection to foreground service
+ */
+ fun acquireProjection(callback: (Result) -> Unit)
+ /**
+ * Allow service to stop projection when it's not needed.
+ * Must be called after *starting* sender or receiver.
+ */
+ fun releaseProjection()
+ /**
+ * Start receiver.
+ * Receiver gets stream from network and plays to local speakers.
+ * Must be called between acquireProjection() and releaseProjection().
+ * Throws exception if:
+ * - media projection wasn't acquired
+ * - lost connection to foreground service
+ */
+ fun startReceiver(settings: AndroidReceiverSettings)
+ /** Stop receiver. */
+ fun stopReceiver()
+ /** Check if receiver is running. */
+ fun isReceiverAlive(): Boolean
+ /**
+ * Start sender.
+ * Sender gets stream from local microphone OR media system apps, and streams to network.
+ * Must be called between acquireProjection() and releaseProjection().
+ * Throws exception if:
+ * - microphone permission is needed and wasn't granted
+ * - media projection not acquired
+ * - lost connection to foreground service
+ */
+ fun startSender(settings: AndroidSenderSettings)
+ /** Stop sender. */
+ fun stopSender()
+ /** Check if sender is running. */
+ fun isSenderAlive(): Boolean
+
+ companion object {
+ /** The codec used by AndroidController. */
+ val codec: MessageCodec by lazy {
+ AndroidBridgePigeonCodec()
+ }
+ /** Sets up an instance of `AndroidController` to handle messages through the `binaryMessenger`. */
+ @JvmOverloads
+ fun setUp(binaryMessenger: BinaryMessenger, api: AndroidController?, messageChannelSuffix: String = "") {
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.requestNotifications$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.requestNotifications{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(wrapError(error))
+ } else {
+ reply.reply(wrapResult(null))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.requestMicrophone$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.requestMicrophone{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(wrapError(error))
+ } else {
+ reply.reply(wrapResult(null))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.acquireProjection$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.acquireProjection{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(wrapError(error))
+ } else {
+ reply.reply(wrapResult(null))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.releaseProjection$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.releaseProjection()
+ listOf(null)
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.startReceiver$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val settingsArg = args[0] as AndroidReceiverSettings
+ val wrapped: List = try {
+ api.startReceiver(settingsArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.stopReceiver$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.stopReceiver()
+ listOf(null)
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.isReceiverAlive$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.isReceiverAlive())
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.startSender$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val settingsArg = args[0] as AndroidSenderSettings
+ val wrapped: List = try {
+ api.startSender(settingsArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.stopSender$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.stopSender()
+ listOf(null)
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.roc_droid.AndroidController.isSenderAlive$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.isSenderAlive())
+ } catch (exception: Throwable) {
+ wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ }
+ }
+}
+/**
+ * Allows to invoke dart methods from kotlin.
+ *
+ * This declaration emits 2 classes:
+ * dart: AndroidListener interface class, which is implemented
+ * by AndroidBackend
+ * kotlin: AndroidListener implementation class, which methods invoke
+ * dart methods under the hood (via platform channels)
+ *
+ * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
+ */
+class AndroidListener(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
+ companion object {
+ /** The codec used by AndroidListener. */
+ val codec: MessageCodec by lazy {
+ AndroidBridgePigeonCodec()
+ }
+ }
+ /**
+ * Invoked when an asynchronous event occurs.
+ * For example, sender is started or stopped by UI, notification button,
+ * tile button, or because of failure.
+ */
+ fun onEvent(eventCodeArg: AndroidServiceEvent, callback: (Result) -> Unit)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.roc_droid.AndroidListener.onEvent$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(binaryMessenger, channelName, codec)
+ channel.send(listOf(eventCodeArg)) {
+ if (it is List<*>) {
+ if (it.size > 1) {
+ callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
+ } else {
+ callback(Result.success(Unit))
+ }
+ } else {
+ callback(Result.failure(createConnectionError(channelName)))
+ }
+ }
+ }
+ /**
+ * Invoked when an asynchronous error occurs.
+ * For example, sender encounters network error.
+ */
+ fun onError(errorCodeArg: AndroidServiceError, callback: (Result) -> Unit)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.roc_droid.AndroidListener.onError$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(binaryMessenger, channelName, codec)
+ channel.send(listOf(errorCodeArg)) {
+ if (it is List<*>) {
+ if (it.size > 1) {
+ callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
+ } else {
+ callback(Result.success(Unit))
+ }
+ } else {
+ callback(Result.failure(createConnectionError(channelName)))
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/rocstreaming/rocdroid/AndroidControllerImpl.kt b/android/app/src/main/java/org/rocstreaming/rocdroid/AndroidControllerImpl.kt
new file mode 100644
index 0000000..2113258
--- /dev/null
+++ b/android/app/src/main/java/org/rocstreaming/rocdroid/AndroidControllerImpl.kt
@@ -0,0 +1,234 @@
+package org.rocstreaming.rocdroid
+
+import AndroidController
+import AndroidReceiverSettings
+import AndroidSenderSettings
+import FlutterError
+import android.Manifest
+import android.media.projection.MediaProjection
+import android.util.Log
+
+private const val BAD_SEQUENCE_CODE = "rocdroid.BAD_SEQUENCE"
+private const val BAD_SEQUENCE_TEXT = "Invalid method invocation sequence"
+
+private const val NO_SERVICE_CODE = "rocdroid.NO_SERVICE"
+private const val NO_SERVICE_TEXT = "Lost connection to streaming service"
+
+private const val NO_PROJECTION_CODE = "rocdroid.NO_PROJECTION"
+private const val NO_PROJECTION_TEXT = "Media projection wasn't acquired"
+
+private const val NO_PERMISSION_CODE = "rocdroid.NO_PERMISSION"
+private const val NO_PERMISSION_TEXT = "Microhpone permission wasn't granted"
+
+private const val LOG_TAG = "rocdroid.AndroidControllerImpl"
+
+// Implementation of generated interface AndroidController, which methods
+// are invoked from the dart side.
+class AndroidControllerImpl : AndroidController {
+ private var projectionAcquired: Boolean = false
+
+ fun getMainActivity(): MainActivity {
+ return MainActivity.instance
+ }
+
+ // Note: When targetSdkVersion is 32 or below, we should not explicitly ask for
+ // POST_NOTIFICATIONS permission. Instead, it will be asked automatically
+ // when we'll try to create notification channel.
+ //
+ // See:
+ // https://developer.android.com/develop/ui/views/notifications/notification-permission#new-apps
+ override fun requestNotifications(callback: (Result) -> Unit) {
+ if (getMainActivity().getApplicationContext().getApplicationInfo().targetSdkVersion <= 32) {
+ Log.i(LOG_TAG, "No need to request POST_NOTIFICATIONS permission")
+ callback(Result.success(Unit))
+ return
+ }
+
+ Log.i(LOG_TAG, "Requesting POST_NOTIFICATIONS permission")
+
+ getMainActivity().requestPermission(
+ Manifest.permission.POST_NOTIFICATIONS,
+ R.string.allow_notifications_title,
+ R.string.allow_notifications_message,
+ { isGranted: Boolean ->
+ if (!isGranted) {
+ Log.w(LOG_TAG, "Permission request failed")
+ callback(Result.failure(FlutterError(NO_PERMISSION_CODE, NO_PERMISSION_TEXT)))
+ return@requestPermission
+ }
+
+ Log.d(LOG_TAG, "Permission request succeeded")
+ callback(Result.success(Unit))
+ }
+ )
+ }
+
+ override fun requestMicrophone(callback: (Result) -> Unit) {
+ Log.i(LOG_TAG, "Requesting RECORD_AUDIO permission")
+
+ getMainActivity().requestPermission(
+ Manifest.permission.RECORD_AUDIO,
+ R.string.allow_mic_title,
+ R.string.allow_mic_message,
+ { isGranted: Boolean ->
+ if (!isGranted) {
+ Log.w(LOG_TAG, "Permission request failed")
+ callback(Result.failure(FlutterError(NO_PERMISSION_CODE, NO_PERMISSION_TEXT)))
+ return@requestPermission
+ }
+
+ Log.d(LOG_TAG, "Permission request succeeded")
+ callback(Result.success(Unit))
+ }
+ )
+ }
+
+ override fun acquireProjection(callback: (Result) -> Unit) {
+ Log.i(LOG_TAG, "Acquiring media projection")
+
+ if (projectionAcquired) {
+ Log.e(LOG_TAG, "Unpaired acquireProjection/releaseProjection calls")
+ callback(Result.failure(FlutterError(BAD_SEQUENCE_CODE, BAD_SEQUENCE_TEXT)))
+ return
+ }
+
+ projectionAcquired = true
+
+ // If service isn't started yet, start it and invoke callback when we've connected.
+ // If service is already started, invoke callback immediately.
+ getMainActivity().startStreamingService({ service: StreamingService ->
+ // Ensure that service won't detach projection until releaseProjection().
+ service.disableAutoDetach()
+
+ if (service.hasProjection()) {
+ Log.d(LOG_TAG, "Projection already acquired")
+ callback(Result.success(Unit))
+ return@startStreamingService
+ }
+
+ getMainActivity().requestProjection({ projection: MediaProjection? ->
+ if (projection == null) {
+ Log.w(LOG_TAG, "Projection request failed")
+ callback(Result.failure(FlutterError(NO_PROJECTION_CODE, NO_PROJECTION_TEXT)))
+ return@requestProjection
+ }
+
+ Log.d(LOG_TAG, "Projection request succeeded")
+ service.attachProjection(projection)
+ callback(Result.success(Unit))
+ })
+ })
+ }
+
+ override fun releaseProjection() {
+ Log.i(LOG_TAG, "Releasing media projection")
+
+ if (!projectionAcquired) {
+ Log.e(LOG_TAG, "Unpaired acquireProjection/releaseProjection calls")
+ throw FlutterError(BAD_SEQUENCE_CODE, BAD_SEQUENCE_TEXT)
+ }
+
+ projectionAcquired = false
+
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ return
+ }
+
+ // Allow service to detach projection when it's not needed.
+ service.enableAutoDetach()
+ }
+
+ override fun startReceiver(settings: AndroidReceiverSettings) {
+ Log.i(LOG_TAG, "Starting receiver if needed")
+
+ if (!projectionAcquired) {
+ Log.e(LOG_TAG, "startReceiver must be called between acquireProjection/releaseProjection")
+ throw FlutterError(BAD_SEQUENCE_CODE, BAD_SEQUENCE_TEXT)
+ }
+
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ Log.e(LOG_TAG, "Lost connection to service")
+ throw FlutterError(NO_SERVICE_CODE, NO_SERVICE_TEXT)
+ }
+
+ if (!service.hasProjection()) {
+ Log.e(LOG_TAG, "Lost acquired projection")
+ throw FlutterError(NO_PROJECTION_CODE, NO_PROJECTION_TEXT)
+ }
+
+ service.startReceiver(settings)
+ }
+
+ override fun stopReceiver() {
+ Log.i(LOG_TAG, "Stopping receiver if needed")
+
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ Log.d(LOG_TAG, "Lost connection to service")
+ return
+ }
+
+ service.stopReceiver()
+ }
+
+ override fun isReceiverAlive(): Boolean {
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ return false
+ }
+
+ return service.isReceiverAlive()
+ }
+
+ override fun startSender(settings: AndroidSenderSettings) {
+ Log.i(LOG_TAG, "Starting sender if needed")
+
+ if (!projectionAcquired) {
+ Log.e(LOG_TAG, "startSender must be called between acquireProjection/releaseProjection")
+ throw FlutterError(BAD_SEQUENCE_CODE, BAD_SEQUENCE_TEXT)
+ }
+
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ Log.e(LOG_TAG, "Lost connection to service")
+ throw FlutterError(NO_SERVICE_CODE, NO_SERVICE_TEXT)
+ }
+
+ if (!service.hasProjection()) {
+ Log.e(LOG_TAG, "Lost acquired projection")
+ throw FlutterError(NO_PROJECTION_CODE, NO_PROJECTION_TEXT)
+ }
+
+ if (settings.captureSource == AndroidCaptureSource.CAPTURE_MIC &&
+ !getMainActivity().hasPermission(Manifest.permission.RECORD_AUDIO)
+ ) {
+ Log.e(LOG_TAG, "Microphone permission must be granted when using CAPTURE_MIC")
+ throw FlutterError(NO_PERMISSION_CODE, NO_PERMISSION_TEXT)
+ }
+
+ service.startSender(settings)
+ }
+
+ override fun stopSender() {
+ Log.i(LOG_TAG, "Stopping sender if needed")
+
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ Log.d(LOG_TAG, "Lost connection to service")
+ return
+ }
+
+ service.stopSender()
+ }
+
+ override fun isSenderAlive(): Boolean {
+ val service = getMainActivity().getStreamingService()
+ if (service == null) {
+ return false
+ }
+
+ return service.isSenderAlive()
+ }
+}
diff --git a/android/app/src/main/java/org/rocstreaming/rocdroid/MainActivity.kt b/android/app/src/main/java/org/rocstreaming/rocdroid/MainActivity.kt
new file mode 100644
index 0000000..ecdd35d
--- /dev/null
+++ b/android/app/src/main/java/org/rocstreaming/rocdroid/MainActivity.kt
@@ -0,0 +1,242 @@
+package org.rocstreaming.rocdroid
+
+import AndroidListener
+import AndroidServiceError
+import AndroidServiceEvent
+import android.content.Context.MEDIA_PROJECTION_SERVICE
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.media.AudioManager
+import android.media.projection.MediaProjection
+import android.media.projection.MediaProjectionManager
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
+import io.flutter.embedding.android.FlutterFragmentActivity
+import io.flutter.embedding.engine.FlutterEngine
+
+private const val LOG_TAG = "rocdroid.MainActivity"
+
+class MainActivity : FlutterFragmentActivity() {
+ // main activity is a singleton used by AndroidControllerImpl
+ companion object {
+ lateinit var instance: MainActivity
+ }
+
+ // called when we've started the service and connected to it
+ private var serviceStartedCallback: ((StreamingService) -> Unit)? = null
+
+ // projectionRequestLauncher acquires access to projection from projectionManager
+ // and invokes projectionRequestCallback
+ private var projectionRequestCallback: ((MediaProjection?) -> Unit)? = null
+ private lateinit var projectionRequestLauncher: ActivityResultLauncher
+ private lateinit var projectionManager: MediaProjectionManager
+
+ // permissionRequestLauncher invokes permissionRequestCallback when
+ // permission is granted or rejected
+ private var permissionRequestCallback: ((Boolean) -> Unit)? = null
+ private lateinit var permissionRequestLauncher: ActivityResultLauncher
+
+ // bridge to invoke dart methods from kotlin
+ private lateinit var eventListener: AndroidListener
+
+ // called at start
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ Log.d(LOG_TAG, "Configuring flutter engine")
+
+ super.configureFlutterEngine(flutterEngine)
+
+ // AndroidController interface is generated by pigeon and is called from dart
+ // AndroidControllerImpl implements its methods
+ // here we link them together
+ AndroidController.setUp(flutterEngine.dartExecutor.binaryMessenger, AndroidControllerImpl())
+
+ // AndroidListener class is generated by pigeon and allows kotlin to
+ // invoke dart methods
+ eventListener = AndroidListener(flutterEngine.dartExecutor.binaryMessenger)
+ }
+
+ // when app is opened
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Log.i(LOG_TAG, "Creating main activity")
+
+ super.onCreate(savedInstanceState)
+
+ instance = this
+
+ // setup for permission requests
+ permissionRequestLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ this::onPermissionResult
+ )
+
+ // setup for projection request
+ projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ projectionRequestLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ this::onProjectionResult
+ )
+
+ // bind to service if it's running
+ streamingConnector.bindService()
+ }
+
+ // when app is resumed
+ override fun onResume() {
+ Log.i(LOG_TAG, "Resuming main activity")
+
+ super.onResume()
+ volumeControlStream = AudioManager.STREAM_MUSIC
+ }
+
+ // when app is closed
+ override fun onDestroy() {
+ Log.i(LOG_TAG, "Destroying main activity")
+
+ streamingConnector.unbindService()
+
+ super.onDestroy()
+ }
+
+ // handler for StreamingConnector events
+ private val streamingListener: StreamingListener =
+ object : StreamingListener {
+ override fun onConnected() {
+ // callback stored by startService()
+ val service = streamingConnector.getService()
+ if (service != null) {
+ serviceStartedCallback?.invoke(service)
+ serviceStartedCallback = null
+ // notify dart
+ emitEvent(AndroidServiceEvent.STREAMING_SERVICE_CONNECTED)
+ }
+ }
+
+ override fun onEvent(event: AndroidServiceEvent) {
+ runOnUiThread {
+ // notify dart
+ emitEvent(event)
+ }
+ }
+
+ override fun onError(error: AndroidServiceError) {
+ runOnUiThread {
+ // notify dart
+ emitError(error)
+ }
+ }
+
+ override fun onDisconnected() {
+ // notify dart
+ emitEvent(AndroidServiceEvent.STREAMING_SERVICE_DISCONNECTED)
+ }
+ }
+
+ val streamingConnector = StreamingConnector(this, streamingListener)
+
+ fun getStreamingService(): StreamingService? {
+ return streamingConnector.getService()
+ }
+
+ // start service if not started yet
+ fun startStreamingService(callback: (StreamingService) -> Unit) {
+ val service = streamingConnector.getService()
+ if (service != null) {
+ Log.d(LOG_TAG, "Service already started, nothing to do")
+ callback(service)
+ return
+ }
+
+ // callback will be invoked from onConnected()
+ serviceStartedCallback = callback
+ streamingConnector.startService()
+ }
+
+ fun requestProjection(callback: (MediaProjection?) -> Unit) {
+ Log.d(LOG_TAG, "Issuing media projection request")
+
+ // callback will be invoked from onProjectionResult()
+ val projectionIntent = projectionManager.createScreenCaptureIntent()
+ projectionRequestCallback = callback
+ projectionRequestLauncher.launch(projectionIntent)
+ }
+
+ private fun onProjectionResult(result: ActivityResult) {
+ if (result.data != null) {
+ Log.d(LOG_TAG, "Media projection acquired with code " + result.resultCode.toString())
+ val projection = projectionManager.getMediaProjection(result.resultCode, result.data!!)
+ projectionRequestCallback?.invoke(projection)
+ projectionRequestCallback = null
+ } else {
+ Log.d(LOG_TAG, "Media projection rejected with code " + result.resultCode.toString())
+ projectionRequestCallback?.invoke(null)
+ projectionRequestCallback = null
+ }
+ }
+
+ fun hasPermission(permission: String): Boolean {
+ return ContextCompat.checkSelfPermission(this, permission) ==
+ PackageManager.PERMISSION_GRANTED
+ }
+
+ fun requestPermission(
+ permission: String,
+ titleID: Int,
+ messageID: Int,
+ callback: (Boolean) -> Unit
+ ) {
+ if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+ ) {
+ Log.d(LOG_TAG, "Permission already granted, nothing to do")
+ callback(true)
+ return
+ }
+
+ if (shouldShowRequestPermissionRationale(permission)) {
+ Log.d(LOG_TAG, "Showing rationale dialog")
+ AlertDialog.Builder(this, R.style.PermissionDialog)
+ .apply {
+ setTitle(titleID)
+ setMessage(messageID)
+ setPositiveButton(R.string.ok) { _, _ ->
+ Log.d(LOG_TAG, "Dialog finished, issuing request")
+ // callback will be invoked from onPermissionResult()
+ permissionRequestCallback = callback
+ permissionRequestLauncher.launch(permission)
+ }
+ }
+ .show()
+ } else {
+ Log.d(LOG_TAG, "Requesting permission directly")
+ // callback will be invoked from onPermissionResult()
+ permissionRequestCallback = callback
+ permissionRequestLauncher.launch(permission)
+ }
+ }
+
+ private fun onPermissionResult(isGranted: Boolean) {
+ if (isGranted) {
+ Log.d(LOG_TAG, "Permission is granted")
+ } else {
+ Log.w(LOG_TAG, "Permission is rejected")
+ }
+ permissionRequestCallback?.invoke(isGranted)
+ permissionRequestCallback = null
+ }
+
+ private fun emitEvent(event: AndroidServiceEvent) {
+ Log.d(LOG_TAG, "Sending event: " + event.toString())
+ eventListener.onEvent(event) { _ -> }
+ }
+
+ private fun emitError(error: AndroidServiceError) {
+ Log.d(LOG_TAG, "Sending error: " + error.toString())
+ eventListener.onError(error) { _ -> }
+ }
+}
diff --git a/android/app/src/main/java/org/rocstreaming/rocdroid/QuicktileService.kt b/android/app/src/main/java/org/rocstreaming/rocdroid/QuicktileService.kt
new file mode 100644
index 0000000..ac5f4d6
--- /dev/null
+++ b/android/app/src/main/java/org/rocstreaming/rocdroid/QuicktileService.kt
@@ -0,0 +1,169 @@
+package org.rocstreaming.rocdroid
+
+import AndroidServiceError
+import AndroidServiceEvent
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Intent
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.util.Log
+
+private const val LOG_TAG = "rocdroid.QuicktileService"
+
+class QuicktileService : TileService() {
+ private var isListening = false
+ private var pendingStop = false
+
+ // handler for StreamingConnector events
+ private val streamingListener: StreamingListener =
+ object : StreamingListener {
+ override fun onConnected() {
+ if (pendingStop) {
+ processStop()
+ }
+ updateTile()
+ }
+
+ override fun onEvent(event: AndroidServiceEvent) {
+ updateTile()
+ }
+
+ override fun onError(error: AndroidServiceError) {
+ updateTile()
+ }
+
+ override fun onDisconnected() {
+ updateTile()
+ }
+ }
+
+ val streamingConnector = StreamingConnector(this, streamingListener)
+
+ override fun onCreate() {
+ Log.d(LOG_TAG, "Tile service created")
+
+ super.onCreate()
+
+ // bind to service if it's running
+ streamingConnector.bindService()
+ }
+
+ // when app is closed
+ override fun onDestroy() {
+ Log.d(LOG_TAG, "Tile service destroyed")
+
+ // unbind if was bound
+ streamingConnector.unbindService()
+
+ super.onDestroy()
+ }
+
+ override fun onTileAdded() {
+ Log.d(LOG_TAG, "Tile added")
+
+ super.onTileAdded()
+
+ // start service if it's not running
+ streamingConnector.startService()
+ }
+
+ override fun onStartListening() {
+ Log.d(LOG_TAG, "Tile shown, setting listening to TRUE")
+
+ super.onStartListening()
+
+ // start service if it's not running
+ streamingConnector.startService()
+
+ isListening = true
+ updateTile()
+ }
+
+ override fun onClick() {
+ super.onClick()
+
+ if (this.qsTile.state == Tile.STATE_ACTIVE) {
+ processStop()
+ } else {
+ processStart()
+ }
+ }
+
+ override fun onStopListening() {
+ Log.d(LOG_TAG, "Tile hidden, setting listening to FALSE")
+
+ super.onStopListening()
+
+ isListening = false
+ }
+
+ override fun onTileRemoved() {
+ Log.d(LOG_TAG, "Tile removed")
+
+ super.onTileRemoved()
+
+ isListening = false
+ }
+
+ private fun processStart() {
+ // For now, when tile is switched on, we just open app.
+ // See gh-137.
+ Log.d(LOG_TAG, "Tile is switched ON, opening app")
+
+ pendingStop = false
+
+ if (isListening) {
+ qsTile.state = Tile.STATE_ACTIVE
+ qsTile.updateTile()
+ }
+
+ val intent = Intent().apply {
+ component = ComponentName(this@QuicktileService, MainActivity::class.java)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ startActivityAndCollapse(pendingIntent)
+ }
+
+ private fun processStop() {
+ if (isListening) {
+ qsTile.state = Tile.STATE_INACTIVE
+ qsTile.updateTile()
+ }
+
+ val service = streamingConnector.getService()
+ if (service == null) {
+ Log.d(LOG_TAG, "Not connected to streaming service")
+ pendingStop = true
+ return
+ }
+
+ pendingStop = false
+
+ service.stopSender()
+ service.stopReceiver()
+ }
+
+ private fun updateTile() {
+ if (isListening) {
+ val service = streamingConnector.getService()
+ val isRunning =
+ service != null && (service.isReceiverAlive() || service.isSenderAlive())
+
+ if (isRunning) {
+ Log.d(LOG_TAG, "Setting tile state to ACTIVE")
+ qsTile.state = Tile.STATE_ACTIVE
+ } else {
+ Log.d(LOG_TAG, "Setting tile state to INACTIVE")
+ qsTile.state = Tile.STATE_INACTIVE
+ }
+ qsTile.updateTile()
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/rocstreaming/rocdroid/StreamingConnector.kt b/android/app/src/main/java/org/rocstreaming/rocdroid/StreamingConnector.kt
new file mode 100644
index 0000000..1223534
--- /dev/null
+++ b/android/app/src/main/java/org/rocstreaming/rocdroid/StreamingConnector.kt
@@ -0,0 +1,119 @@
+package org.rocstreaming.rocdroid
+
+import AndroidServiceError
+import AndroidServiceEvent
+import android.app.ActivityManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import android.util.Log
+
+private const val LOG_TAG = "rocdroid.StreamingConnector"
+
+interface StreamingListener {
+ fun onConnected()
+ fun onEvent(event: AndroidServiceEvent)
+ fun onError(error: AndroidServiceError)
+ fun onDisconnected()
+}
+
+class StreamingConnector(val context: Context, val listener: StreamingListener) {
+ // non-null once successfully connected to server
+ // may temporarily become null when connection is lost
+ private var service: StreamingService? = null
+
+ fun getService(): StreamingService? {
+ return service
+ }
+
+ // bind to service if it's running
+ fun bindService() {
+ if (service != null) {
+ return
+ }
+
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+
+ @Suppress("DEPRECATION")
+ val allServices = activityManager.getRunningServices(Integer.MAX_VALUE)
+
+ for (service in allServices) {
+ if (service.service.className == StreamingService::class.java.name) {
+ Log.d(LOG_TAG, "Found running service, binding")
+
+ val serviceIntent = Intent(context, StreamingService::class.java)
+ context.bindService(serviceIntent, serviceHandler, 0)
+
+ return
+ }
+ }
+
+ Log.d(LOG_TAG, "No running service found")
+ }
+
+ // start service if not started yet
+ fun startService() {
+ if (service != null) {
+ return
+ }
+
+ Log.d(LOG_TAG, "Starting service")
+
+ val serviceIntent = Intent(context, StreamingService::class.java)
+ context.startForegroundService(serviceIntent)
+ context.bindService(serviceIntent, serviceHandler, Context.BIND_AUTO_CREATE)
+ }
+
+ // unbind service if bound
+ fun unbindService() {
+ Log.d(LOG_TAG, "Unbinding service")
+
+ context.unbindService(serviceHandler)
+ service?.removeEventListener(serviceSubscriber)
+ service = null
+ }
+
+ // handler for connect & disconnect events
+ private val serviceHandler =
+ object : ServiceConnection {
+ // called when we've successfully connected to the service
+ override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
+ Log.i(LOG_TAG, "Service connected")
+
+ // remember service reference
+ service = (binder as StreamingService.LocalBinder).getService()
+ service?.addEventListener(serviceSubscriber)
+
+ listener.onConnected()
+ }
+
+ // called when we've lost connectio to service
+ override fun onServiceDisconnected(componentName: ComponentName) {
+ Log.w(LOG_TAG, "Service disconnected")
+
+ // forget service reference
+ service?.removeEventListener(serviceSubscriber)
+ service = null
+
+ listener.onDisconnected()
+
+ // (re)start & reconnect
+ Log.d(LOG_TAG, "Initiating asynchronous reconnect")
+ startService()
+ }
+ }
+
+ // handler for events produced by streaming service
+ private val serviceSubscriber: StreamingServiceSubscriber =
+ object : StreamingServiceSubscriber {
+ override fun processEvent(event: AndroidServiceEvent) {
+ listener.onEvent(event)
+ }
+
+ override fun processError(error: AndroidServiceError) {
+ listener.onError(error)
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/rocstreaming/rocdroid/StreamingService.kt b/android/app/src/main/java/org/rocstreaming/rocdroid/StreamingService.kt
new file mode 100644
index 0000000..6cccc7a
--- /dev/null
+++ b/android/app/src/main/java/org/rocstreaming/rocdroid/StreamingService.kt
@@ -0,0 +1,783 @@
+package org.rocstreaming.rocdroid
+
+import AndroidReceiverSettings
+import AndroidSenderSettings
+import AndroidServiceError
+import AndroidServiceEvent
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.drawable.Icon
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioPlaybackCaptureConfiguration
+import android.media.AudioRecord
+import android.media.AudioTrack
+import android.media.MediaRecorder
+import android.media.projection.MediaProjection
+import android.os.Binder
+import android.os.IBinder
+import android.util.Log
+import org.rocstreaming.roctoolkit.ChannelSet
+import org.rocstreaming.roctoolkit.ClockSource
+import org.rocstreaming.roctoolkit.Endpoint
+import org.rocstreaming.roctoolkit.FrameEncoding
+import org.rocstreaming.roctoolkit.Interface
+import org.rocstreaming.roctoolkit.Protocol
+import org.rocstreaming.roctoolkit.RocContext
+import org.rocstreaming.roctoolkit.RocReceiver
+import org.rocstreaming.roctoolkit.RocReceiverConfig
+import org.rocstreaming.roctoolkit.RocSender
+import org.rocstreaming.roctoolkit.RocSenderConfig
+import org.rocstreaming.roctoolkit.Slot
+
+private const val SAMPLE_RATE = 44100
+private const val BUFFER_SIZE = 100
+
+private const val NOTIFICATION_CHANNEL_ID = "StreamingService"
+private const val NOTIFICATION_ID = 1
+
+private const val NOTIFICATION_ACTION_DELETE = "org.rocstreaming.rocdroid.NotificationActionDelete"
+private const val NOTIFICATION_ACTION_STOP = "org.rocstreaming.rocdroid.NotificationActionStop"
+
+private const val LOG_TAG = "rocdroid.StreamingService"
+
+// Used to report asynchronous events and errors from service.
+interface StreamingServiceSubscriber {
+ fun processEvent(event: AndroidServiceEvent)
+ fun processError(error: AndroidServiceError)
+}
+
+// This service runs even when the app is closed.
+// Related docs:
+// https://medium.com/@domen.lanisnik/guide-to-foreground-services-on-android-9d0127dc8f9a
+// https://developer.android.com/reference/android/media/projection/MediaProjectionManager
+class StreamingService : Service() {
+ private var receiverThread: Thread? = null
+ private var senderThread: Thread? = null
+ private var receiverStarted = false
+ private var senderStarted = false
+ private var eventListeners: MutableList = mutableListOf()
+ private var autoDetach: Boolean = true
+ private var currentProjection: MediaProjection? = null
+
+ private var notificationEnabled: Boolean = true
+ private var notificationRegistered: Boolean = false
+ private val notificationActionHandler: BroadcastReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ Log.i(LOG_TAG, "Handling notification action: " + intent.action)
+
+ when (intent.action) {
+ NOTIFICATION_ACTION_DELETE -> stopAndExit()
+ NOTIFICATION_ACTION_STOP -> {
+ stopSender()
+ stopReceiver()
+ }
+ }
+ }
+ }
+
+ private val binder = LocalBinder()
+
+ inner class LocalBinder : Binder() {
+ fun getService(): StreamingService = this@StreamingService
+ }
+
+ override fun onBind(intent: Intent): IBinder {
+ Log.i(LOG_TAG, "Binding service")
+
+ return binder
+ }
+
+ override fun onCreate() {
+ Log.i(LOG_TAG, "Creating service")
+
+ super.onCreate()
+
+ autoInitNotification()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.i(LOG_TAG, "Starting service")
+
+ startForeground(NOTIFICATION_ID, buildNotification())
+
+ // restart us if we got killed
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ Log.i(LOG_TAG, "Destroying service")
+
+ terminate()
+ deinitNotification()
+
+ super.onDestroy()
+ }
+
+ private fun terminate() {
+ Log.d(LOG_TAG, "Stopping threads")
+
+ stopSender()
+ stopReceiver()
+
+ Log.d(LOG_TAG, "Waiting threads")
+
+ senderThread?.join()
+ receiverThread?.join()
+
+ Log.d(LOG_TAG, "Stopping media projection")
+
+ currentProjection?.stop()
+ currentProjection = null
+
+ Log.d(LOG_TAG, "Stopping foreground service")
+
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ }
+
+ @Synchronized
+ fun hasProjection(): Boolean {
+ return currentProjection != null
+ }
+
+ @Synchronized
+ fun attachProjection(projection: MediaProjection) {
+ Log.i(LOG_TAG, "Attaching media projection")
+
+ currentProjection = projection
+ }
+
+ @Synchronized
+ fun enableAutoDetach() {
+ autoDetach = true
+ autoDetachProjection()
+ }
+
+ @Synchronized
+ fun disableAutoDetach() {
+ autoDetach = false
+ }
+
+ private fun autoDetachProjection() {
+ if (!senderStarted && !receiverStarted && currentProjection != null && autoDetach) {
+ Log.i(LOG_TAG, "Detaching media projection")
+
+ currentProjection?.stop()
+ currentProjection = null
+ }
+ }
+
+ @Synchronized
+ fun isSenderAlive(): Boolean {
+ return senderStarted
+ }
+
+ @Synchronized
+ fun startSender(settings: AndroidSenderSettings) {
+ if (senderStarted) return
+
+ Log.i(LOG_TAG, "Starting sender")
+
+ val projection = currentProjection
+ if (projection == null) {
+ throw IllegalStateException("Projection not attached")
+ }
+
+ val previousThread = senderThread
+
+ senderThread = Thread {
+ try {
+ if (previousThread != null) {
+ Log.d(LOG_TAG, "Joining previous sender thread")
+ previousThread.join()
+ }
+
+ runSenderThread(settings, projection)
+ } finally {
+ val currentThread = Thread.currentThread()
+
+ synchronized(this@StreamingService) {
+ if (senderThread == currentThread) {
+ stopSender()
+ } else {
+ Log.d(LOG_TAG, "Ignoring dangling sender thread")
+ }
+ }
+ }
+ }
+
+ senderStarted = true
+ senderThread!!.start()
+
+ autoEnableNotification()
+ updateNotification()
+
+ reportEvent(AndroidServiceEvent.SENDER_STATE_CHANGED)
+ }
+
+ @Synchronized
+ fun stopSender() {
+ if (!senderStarted) return
+
+ Log.i(LOG_TAG, "Stopping sender")
+
+ senderStarted = false
+ senderThread?.interrupt()
+
+ updateNotification()
+ autoDetachProjection()
+
+ reportEvent(AndroidServiceEvent.SENDER_STATE_CHANGED)
+ }
+
+ @Synchronized
+ fun isReceiverAlive(): Boolean {
+ return receiverStarted
+ }
+
+ @Synchronized
+ fun startReceiver(settings: AndroidReceiverSettings) {
+ if (receiverStarted) return
+
+ Log.i(LOG_TAG, "Starting receiver")
+
+ val projection = currentProjection
+ if (projection == null) {
+ throw IllegalStateException("Projection not attached")
+ }
+
+ val previousThread = receiverThread
+
+ receiverThread = Thread {
+ try {
+ if (previousThread != null) {
+ Log.d(LOG_TAG, "Joining previous receiver thread")
+ previousThread.join()
+ }
+
+ runReceiverThread(settings, projection)
+ } finally {
+ val currentThread = Thread.currentThread()
+
+ synchronized(this@StreamingService) {
+ if (receiverThread == currentThread) {
+ stopReceiver()
+ } else {
+ Log.d(LOG_TAG, "Ignoring dangling receiver thread")
+ }
+ }
+ }
+ }
+
+ receiverStarted = true
+ receiverThread!!.start()
+
+ autoEnableNotification()
+ updateNotification()
+
+ reportEvent(AndroidServiceEvent.RECEIVER_STATE_CHANGED)
+ }
+
+ @Synchronized
+ fun stopReceiver() {
+ if (!receiverStarted) return
+
+ Log.i(LOG_TAG, "Stopping receiver")
+
+ receiverStarted = false
+ receiverThread?.interrupt()
+
+ updateNotification()
+ autoDetachProjection()
+
+ reportEvent(AndroidServiceEvent.RECEIVER_STATE_CHANGED)
+ }
+
+ @Synchronized
+ fun stopAndExit() {
+ if (senderStarted) {
+ Log.i(LOG_TAG, "Stopping sender")
+
+ senderStarted = false
+ senderThread?.interrupt()
+
+ reportEvent(AndroidServiceEvent.SENDER_STATE_CHANGED)
+ }
+
+ if (receiverStarted) {
+ Log.i(LOG_TAG, "Stopping receiver")
+
+ receiverStarted = false
+ receiverThread?.interrupt()
+
+ reportEvent(AndroidServiceEvent.RECEIVER_STATE_CHANGED)
+ }
+
+ // we don't need projection anymore
+ autoDetachProjection()
+
+ // if notification is swiped away, stopAndExit() is called, and if there
+ // are no connected clients, stop service
+ autoStopService()
+
+ // even if we don't stop service, at least hide notification until
+ // sender/receiver is explicitly started
+ disableNotification()
+ }
+
+ @Synchronized
+ fun addEventListener(listener: StreamingServiceSubscriber) {
+ Log.d(LOG_TAG, "Adding event listener")
+
+ eventListeners.add(listener)
+ }
+
+ @Synchronized
+ fun removeEventListener(listener: StreamingServiceSubscriber) {
+ Log.d(LOG_TAG, "Removing event listener")
+
+ eventListeners.remove(listener)
+
+ // if notification was swiped away and stopAndExit() was called, but there
+ // were connected clients, so it didn't stop the service, and *now* last
+ // client disconnects (app or tile service), then stop the service
+ if (!notificationEnabled) {
+ autoStopService()
+ }
+ }
+
+ @Synchronized
+ private fun reportEvent(event: AndroidServiceEvent) {
+ Log.d(LOG_TAG, "Reporting event: " + event.toString())
+
+ eventListeners.forEach { it.processEvent(event) }
+ }
+
+ @Synchronized
+ private fun reportError(error: AndroidServiceError) {
+ Log.d(LOG_TAG, "Reporting error: " + error.toString())
+
+ eventListeners.forEach { it.processError(error) }
+ }
+
+ private fun autoStopService() {
+ if (eventListeners.count() != 0) {
+ Log.d(LOG_TAG, "Still has " + eventListeners.count() + " listener(s), keeping service")
+ return
+ }
+
+ Log.i(LOG_TAG, "No registered listeners, stopping service")
+ stopSelf()
+ }
+
+ private fun runSenderThread(settings: AndroidSenderSettings, projection: MediaProjection) {
+ Log.d(LOG_TAG, "Running sender thread")
+
+ var audioRecord: AudioRecord? = null
+
+ try {
+ try {
+ if (settings.captureSource == AndroidCaptureSource.CAPTURE_APPS) {
+ audioRecord = createProjectionAudioRecord(projection)
+ } else {
+ audioRecord = createMicrophoneAudioRecord()
+ }
+ } catch (e: Exception) {
+ Log.e(LOG_TAG, "Failed to create audio record: " + e.toString())
+ reportError(AndroidServiceError.AUDIO_RECORD_FAILED)
+ return
+ }
+
+ if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
+ Log.e(LOG_TAG, "Failed to initialize audio record: " + audioRecord.state.toString())
+ reportError(AndroidServiceError.AUDIO_RECORD_FAILED)
+ return
+ }
+
+ val senderConfig =
+ RocSenderConfig.builder()
+ .frameSampleRate(44100)
+ .frameChannels(ChannelSet.STEREO)
+ .frameEncoding(FrameEncoding.PCM_FLOAT)
+ .clockSource(ClockSource.EXTERNAL)
+ .build()
+
+ RocContext().use { context ->
+ RocSender(context, senderConfig).use useSender@{ sender ->
+ try {
+ sender.connect(
+ Slot.DEFAULT,
+ Interface.AUDIO_SOURCE,
+ Endpoint(
+ Protocol.RTP_RS8M_SOURCE,
+ settings.host,
+ settings.sourcePort.toInt()
+ )
+ )
+ sender.connect(
+ Slot.DEFAULT,
+ Interface.AUDIO_REPAIR,
+ Endpoint(
+ Protocol.RS8M_REPAIR,
+ settings.host,
+ settings.repairPort.toInt()
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(LOG_TAG, "Failed to connect sender: " + e.toString())
+ reportError(AndroidServiceError.SENDER_CONNECT_FAILED)
+ return@useSender
+ }
+
+ audioRecord.startRecording()
+
+ val samples = FloatArray(BUFFER_SIZE)
+ while (!Thread.currentThread().isInterrupted) {
+ audioRecord.read(samples, 0, samples.size, AudioRecord.READ_BLOCKING)
+ sender.write(samples)
+ }
+ }
+ }
+ } finally {
+ Log.d(LOG_TAG, "Releasing sender resources")
+
+ audioRecord?.release()
+
+ Log.d(LOG_TAG, "Exiting sender thread")
+ }
+ }
+
+ private fun runReceiverThread(
+ settings: AndroidReceiverSettings,
+ @Suppress("UNUSED_PARAMETER") projection: MediaProjection
+ ) {
+ Log.d(LOG_TAG, "Running receiver thread")
+
+ var audioTrack: AudioTrack? = null
+
+ try {
+ try {
+ audioTrack = createAudioTrack()
+ } catch (e: Exception) {
+ Log.e(LOG_TAG, "Failed to create audio track: " + e.toString())
+ reportError(AndroidServiceError.AUDIO_TRACK_FAILED)
+ return
+ }
+
+ if (audioTrack.state != AudioTrack.STATE_INITIALIZED) {
+ Log.e(LOG_TAG, "Failed to initialize audio track: " + audioTrack.state.toString())
+ reportError(AndroidServiceError.AUDIO_TRACK_FAILED)
+ return
+ }
+
+ val receiverConfig =
+ RocReceiverConfig.builder()
+ .frameSampleRate(44100)
+ .frameChannels(ChannelSet.STEREO)
+ .frameEncoding(FrameEncoding.PCM_FLOAT)
+ .clockSource(ClockSource.EXTERNAL)
+ .build()
+
+ RocContext().use { context ->
+ RocReceiver(context, receiverConfig).use useReceiver@{ receiver ->
+ try {
+ receiver.bind(
+ Slot.DEFAULT,
+ Interface.AUDIO_SOURCE,
+ Endpoint(
+ Protocol.RTP_RS8M_SOURCE,
+ "0.0.0.0",
+ settings.sourcePort.toInt()
+ )
+ )
+ receiver.bind(
+ Slot.DEFAULT,
+ Interface.AUDIO_REPAIR,
+ Endpoint(
+ Protocol.RS8M_REPAIR,
+ "0.0.0.0",
+ settings.repairPort.toInt()
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(LOG_TAG, "Failed to bind receiver: " + e.toString())
+ reportError(AndroidServiceError.RECEIVER_BIND_FAILED)
+ return@useReceiver
+ }
+
+ audioTrack.play()
+
+ val samples = FloatArray(BUFFER_SIZE)
+ while (!Thread.currentThread().isInterrupted) {
+ receiver.read(samples)
+ audioTrack.write(samples, 0, samples.size, AudioTrack.WRITE_BLOCKING)
+ }
+ }
+ }
+ } finally {
+ Log.d(LOG_TAG, "Releasing receiver resources")
+
+ audioTrack?.release()
+
+ Log.d(LOG_TAG, "Exiting receiver thread")
+ }
+ }
+
+ private fun createAudioTrack(): AudioTrack {
+ Log.d(LOG_TAG, "Creating audio track")
+
+ val audioAttributes =
+ AudioAttributes.Builder()
+ .apply {
+ setUsage(AudioAttributes.USAGE_MEDIA)
+ setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ }
+ .build()
+ val audioFormat =
+ AudioFormat.Builder()
+ .apply {
+ setSampleRate(SAMPLE_RATE)
+ setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
+ setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
+ }
+ .build()
+ val bufferSize =
+ AudioTrack.getMinBufferSize(
+ audioFormat.sampleRate,
+ audioFormat.channelMask,
+ audioFormat.encoding
+ )
+ return AudioTrack.Builder()
+ .apply {
+ setAudioAttributes(audioAttributes)
+ setAudioFormat(audioFormat)
+ setBufferSizeInBytes(bufferSize)
+ setTransferMode(AudioTrack.MODE_STREAM)
+ setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
+ }
+ .build()
+ }
+
+ private fun createMicrophoneAudioRecord(): AudioRecord {
+ Log.d(LOG_TAG, "Creating microphone audio record")
+
+ val format =
+ AudioFormat.Builder()
+ .apply {
+ setSampleRate(SAMPLE_RATE)
+ setChannelMask(AudioFormat.CHANNEL_IN_STEREO)
+ setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
+ }
+ .build()
+ val bufferSize =
+ AudioRecord.getMinBufferSize(
+ SAMPLE_RATE,
+ AudioFormat.CHANNEL_IN_STEREO,
+ AudioFormat.ENCODING_PCM_FLOAT
+ )
+ return AudioRecord.Builder()
+ .apply {
+ setAudioSource(MediaRecorder.AudioSource.DEFAULT)
+ setAudioFormat(format)
+ setBufferSizeInBytes(bufferSize)
+ }
+ .build()
+ }
+
+ private fun createProjectionAudioRecord(projection: MediaProjection): AudioRecord {
+ Log.d(LOG_TAG, "Creating projection audio record")
+
+ val format =
+ AudioFormat.Builder()
+ .apply {
+ setSampleRate(SAMPLE_RATE)
+ setChannelMask(AudioFormat.CHANNEL_IN_STEREO)
+ setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
+ }
+ .build()
+ val bufferSize =
+ AudioRecord.getMinBufferSize(
+ SAMPLE_RATE,
+ AudioFormat.CHANNEL_IN_STEREO,
+ AudioFormat.ENCODING_PCM_FLOAT
+ )
+ val config =
+ AudioPlaybackCaptureConfiguration.Builder(projection)
+ .apply {
+ addMatchingUsage(AudioAttributes.USAGE_MEDIA)
+ addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
+ addMatchingUsage(AudioAttributes.USAGE_GAME)
+ }
+ .build()
+ return AudioRecord.Builder()
+ .apply {
+ setAudioPlaybackCaptureConfig(config)
+ setAudioFormat(format)
+ setBufferSizeInBytes(bufferSize)
+ }
+ .build()
+ }
+
+ private fun autoInitNotification() {
+ if (!notificationEnabled || notificationRegistered) {
+ return
+ }
+
+ Log.d(LOG_TAG, "Initializing notification")
+
+ val channel =
+ NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+
+ val notificationManager =
+ getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ notificationManager.createNotificationChannel(channel)
+
+ registerReceiver(
+ notificationActionHandler,
+ IntentFilter().apply {
+ addAction(NOTIFICATION_ACTION_DELETE)
+ addAction(NOTIFICATION_ACTION_STOP)
+ },
+ RECEIVER_EXPORTED
+ )
+
+ notificationRegistered = true
+ }
+
+ private fun deinitNotification() {
+ if (!notificationRegistered) {
+ return
+ }
+
+ Log.d(LOG_TAG, "Deinitializing notifications")
+
+ unregisterReceiver(notificationActionHandler)
+
+ notificationRegistered = false
+ }
+
+ private fun autoEnableNotification() {
+ if (notificationEnabled) {
+ return
+ }
+
+ Log.i(LOG_TAG, "Enabling notification")
+
+ notificationEnabled = true
+ autoInitNotification()
+ }
+
+ private fun disableNotification() {
+ if (!notificationEnabled) {
+ return
+ }
+
+ Log.i(LOG_TAG, "Disabling notification")
+
+ notificationEnabled = false
+ deinitNotification()
+ }
+
+ private fun buildNotification(): Notification {
+ Log.d(LOG_TAG, "Building notification: actions=" + getNotificationDesc())
+
+ // invoked when notification is tapped
+ // we want to open main activity
+ val contentIntent = Intent(this, MainActivity::class.java)
+ val pendingContentIntent =
+ PendingIntent.getActivity(this, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
+
+ // invoked when notification is dismissed (swiped away)
+ // we want to stop sender & receiver
+ val deleteIntent = Intent(NOTIFICATION_ACTION_DELETE)
+ val pendingDeleteIntent =
+ PendingIntent.getBroadcast(this, 0, deleteIntent, PendingIntent.FLAG_IMMUTABLE)
+
+ // invoked when "stop streaming" notification button is pressed
+ // we want to stop sender & receiver
+ val stopIntent = Intent(NOTIFICATION_ACTION_STOP)
+ val pendingStopIntent =
+ PendingIntent.getBroadcast(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
+ val stopAction =
+ Notification.Action.Builder(
+ Icon.createWithResource(this@StreamingService, R.drawable.ic_stop),
+ getString(R.string.notification_stop_action),
+ pendingStopIntent
+ )
+ .build()
+
+ return Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .apply {
+ // appearance
+ setSmallIcon(R.drawable.ic_notification)
+ setContentTitle(getNotificationTitle())
+ setContentText(getNotificationText())
+ // when notification is tapped
+ setContentIntent(pendingContentIntent)
+ // when notification is swiped away
+ setDeleteIntent(pendingDeleteIntent)
+ // don't allow to dimiss notification on lock screen
+ setOngoing(true)
+ // show on lock screen
+ setVisibility(Notification.VISIBILITY_PUBLIC)
+ // notification buttons
+ if (senderStarted || receiverStarted) {
+ addAction(stopAction)
+ }
+ }
+ .build()
+ }
+
+ private fun updateNotification() {
+ if (!notificationRegistered) {
+ return
+ }
+
+ Log.d(LOG_TAG, "Updating notification: actions=" + getNotificationDesc())
+
+ val notification = buildNotification()
+ val notificationManager =
+ getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun getNotificationTitle(): String {
+ return when {
+ senderStarted || receiverStarted -> getString(R.string.notification_title_active)
+ else -> getString(R.string.notification_title_inactive)
+ }
+ }
+
+ private fun getNotificationText(): String {
+ return when {
+ senderStarted && receiverStarted ->
+ getString(R.string.notification_sender_and_receiver_running)
+ senderStarted -> getString(R.string.notification_sender_running)
+ receiverStarted -> getString(R.string.notification_receiver_running)
+ else -> getString(R.string.notification_sender_and_receiver_not_running)
+ }
+ }
+
+ private fun getNotificationDesc(): String {
+ return when {
+ senderStarted && receiverStarted -> "[sender, receiver]"
+ senderStarted -> "[sender]"
+ receiverStarted -> "[receiver]"
+ else -> "[]"
+ }
+ }
+}
diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png
new file mode 100644
index 0000000..49590ff
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-hdpi/ic_notification.png
rename to android/app/src/main/res/drawable-hdpi/ic_notification.png
diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png
new file mode 100644
index 0000000..be87b57
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png
new file mode 100644
index 0000000..87aa4f9
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-mdpi/ic_notification.png
rename to android/app/src/main/res/drawable-mdpi/ic_notification.png
diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png
new file mode 100644
index 0000000..a71f5f8
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png
new file mode 100644
index 0000000..49590ff
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png
new file mode 100644
index 0000000..87aa4f9
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png
new file mode 100644
index 0000000..5f4f7a0
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-night-xhdpi/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-xhdpi/ic_notification.png
rename to android/app/src/main/res/drawable-night-xhdpi/ic_notification.png
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png
new file mode 100644
index 0000000..579a3f3
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-night-xxhdpi/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-xxhdpi/ic_notification.png
rename to android/app/src/main/res/drawable-night-xxhdpi/ic_notification.png
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..f0f9a7f
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-night-xxxhdpi/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-xxxhdpi/ic_notification.png
rename to android/app/src/main/res/drawable-night-xxxhdpi/ic_notification.png
diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png
new file mode 100644
index 0000000..8e21404
Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png
new file mode 100644
index 0000000..5f4f7a0
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png
new file mode 100644
index 0000000..1e71647
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png
new file mode 100644
index 0000000..579a3f3
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png
new file mode 100644
index 0000000..f3765c3
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..f0f9a7f
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png
new file mode 100644
index 0000000..01a3c39
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png
new file mode 100644
index 0000000..8e21404
Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ
diff --git a/app/src/main/res/drawable/ic_stop.xml b/android/app/src/main/res/drawable/ic_stop.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_stop.xml
rename to android/app/src/main/res/drawable/ic_stop.xml
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png
new file mode 100644
index 0000000..2095012
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png
new file mode 100644
index 0000000..3927f3c
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
new file mode 100644
index 0000000..0cd4dc1
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
new file mode 100644
index 0000000..07d37e3
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
new file mode 100644
index 0000000..0a9e811
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..52437c3
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..a70eb5a
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..292e608
--- /dev/null
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..44dcd59
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+ Roc Droid
+
+ OK
+ Cancel
+
+ Notification permission
+ Notification permission is required to display audio controls.
+
+ Microphone permission
+ Microphone permission is required to stream your audio to remote peers.
+
+ Streaming foreground service
+ Receiver running
+ Sender running
+ Sender and receiver running
+ Neither sender or receiver running
+ Streaming active
+ Streaming inactive
+ Stop streaming
+
+ Roc Droid
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..aa8d39c
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..57c1bda
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,71 @@
+import com.github.jk1.license.render.*
+
+buildscript {
+ ext {
+ // must be in-sync with settings.gradle
+ gradle_plugin_version = "8.6.0"
+ kotlin_version = "1.8.10"
+ }
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath "com.android.tools.build:gradle:$gradle_plugin_version"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath "org.yaml:snakeyaml:2.2"
+ }
+}
+
+plugins {
+ id "com.diffplug.spotless" version "6.12.0"
+ id "com.github.jk1.dependency-license-report" version "2.9"
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.buildDir = "../build"
+subprojects {
+ project.buildDir = "${rootProject.buildDir}/${project.name}"
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+subprojects {
+ beforeEvaluate { project ->
+ if (project.name == "flutter_localization") {
+ project.buildscript.dependencies.classpath \
+ "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlin_version}"
+ }
+ }
+}
+
+spotless {
+ format "misc", {
+ target "**/*.gradle", "**/*.xml"
+ indentWithSpaces()
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+ kotlin {
+ target "**/*.kt"
+ targetExclude "**/*.g.kt"
+ ktlint()
+ trimTrailingWhitespace()
+ indentWithSpaces()
+ endWithNewline()
+ }
+}
+
+licenseReport {
+ configurations = ["releaseRuntimeClasspath"]
+ outputDir = project.layout.buildDirectory.get().asFile.path
+ renderers = [new ExtendedJsonReportRenderer("android_licenses.json")]
+}
diff --git a/gradle.properties b/android/gradle.properties
similarity index 51%
rename from gradle.properties
rename to android/gradle.properties
index 23339e0..07228ce 100644
--- a/gradle.properties
+++ b/android/gradle.properties
@@ -1,21 +1,16 @@
# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
+
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx1536m
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
+org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
+
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
+
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
+
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000..13372ae
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
similarity index 80%
rename from gradle/wrapper/gradle-wrapper.properties
rename to android/gradle/wrapper/gradle-wrapper.properties
index 0e87abc..3c85cfe 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,5 @@
-#Sun May 31 15:05:46 MSK 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
diff --git a/gradlew b/android/gradlew
similarity index 84%
rename from gradlew
rename to android/gradlew
index cccdd3d..9d82f78 100755
--- a/gradlew
+++ b/android/gradlew
@@ -1,4 +1,4 @@
-#!/usr/bin/env sh
+#!/usr/bin/env bash
##############################################################################
##
@@ -6,38 +6,20 @@
##
##############################################################################
-# Attempt to set APP_HOME
-# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
-done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS=""
-
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
-warn () {
+warn ( ) {
echo "$*"
}
-die () {
+die ( ) {
echo
echo "$*"
echo
@@ -48,7 +30,6 @@ die () {
cygwin=false
msys=false
darwin=false
-nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
@@ -59,11 +40,26 @@ case "`uname`" in
MINGW* )
msys=true
;;
- NONSTOP* )
- nonstop=true
- ;;
esac
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -89,7 +85,7 @@ location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
@@ -154,19 +150,11 @@ if $cygwin ; then
esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
}
-APP_ARGS=$(save "$@")
-
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-
-# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
-if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
- cd "$(dirname "$0")"
-fi
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
-exec "$JAVACMD" "$@"
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/gradlew.bat b/android/gradlew.bat
old mode 100644
new mode 100755
similarity index 88%
rename from gradlew.bat
rename to android/gradlew.bat
index e95643d..aec9973
--- a/gradlew.bat
+++ b/android/gradlew.bat
@@ -8,14 +8,14 @@
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
-
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
@@ -46,9 +46,10 @@ echo location of your Java installation.
goto fail
:init
-@rem Get command-line arguments, handling Windows variants
+@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
@@ -59,6 +60,11 @@ set _SKIP=2
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..97055a0
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,27 @@
+pluginManagement {
+ def flutterSdkPath = {
+ def properties = new Properties()
+ file("local.properties").withInputStream { properties.load(it) }
+ def flutterSdkPath = properties.getProperty("flutter.sdk")
+ assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+ return flutterSdkPath
+ }()
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+ // same as com.android.tools.build:gradle
+ id "com.android.application" version "8.6.0" apply false
+ // same as org.jetbrains.kotlin:kotlin-gradle-plugin
+ id "org.jetbrains.kotlin.android" version "1.8.10" apply false
+}
+
+include ":app"
diff --git a/app/.gitignore b/app/.gitignore
deleted file mode 100644
index 796b96d..0000000
--- a/app/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index e2021ed..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,133 +0,0 @@
-def getManifestVersions = { ->
- def manifest = new XmlSlurper().parse(file('src/main/AndroidManifest.xml'))
- def versionName = manifest.@'android:versionName'.text()
- def versionCode = manifest.@'android:versionCode'.text().toInteger()
- return [versionName, versionCode]
-}
-
-def getVersionName = { ->
- def (versionName, _) = getManifestVersions()
- return versionName
-}
-
-def getVersionCode = { ->
- def (_, versionCode) = getManifestVersions()
- return versionCode
-}
-
-def validateVersion = { ->
- def (versionName, versionCode) = getManifestVersions()
-
- project.logger.lifecycle("Checking version name and code...")
- project.logger.lifecycle("versionName = $versionName")
- project.logger.lifecycle("versionCode = $versionCode")
-
- def gitStdout = new ByteArrayOutputStream()
- exec {
- commandLine 'git', 'describe', '--tags', '--abbrev=0'
- standardOutput = gitStdout
- }
- def versionGit = gitStdout.toString().trim().replaceAll('v', '')
-
- if (versionName != versionGit) {
- throw new GradleException(
- "Mismatched android:versionName in AndroidManifest.xml and git tag: "+
- "versionName = $versionName, "+
- "versionGit = $versionGit")
- }
-
- def expectedCode = 0
- def mult = 1
- versionName.tokenize('.').reverse().each {
- expectedCode += it.toInteger() * mult
- mult *= 1000
- }
-
- if (versionCode != expectedCode) {
- throw new GradleException(
- "Mismatched android:versionName and android:versionCode in AndroidManifest.xml: "+
- "versionName = $versionName, "+
- "versionCode = $versionCode, "+
- "expectedCode = $expectedCode")
- }
-}
-
-task checkVersion {
- doLast {
- validateVersion()
- }
-}
-
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply plugin: 'kotlin-android-extensions'
-
-android {
- compileSdk project.compileSdkVersion.toInteger()
-
- ndkVersion project.ndkVersion
-
- defaultConfig {
- applicationId 'org.rocstreaming.rocdroid'
-
- minSdkVersion project.minSdkVersion.toInteger()
- targetSdkVersion project.targetSdkVersion.toInteger()
-
- versionName getVersionName()
- versionCode getVersionCode()
- }
-
- applicationVariants.all { variant ->
- variant.outputs.all {
- outputFileName = "roc-droid-${versionName}.apk"
- }
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = '1.8'
- }
-
- lintOptions {
- abortOnError false
- }
-
- signingConfigs {
- release {
- if (System.getenv("SIGNING_STORE_FILE") != null) {
- storeFile file(System.getenv("SIGNING_STORE_FILE"))
- storePassword System.getenv("SIGNING_STORE_PASSWORD")
- keyAlias System.getenv("SIGNING_KEY_ALIAS")
- keyPassword System.getenv("SIGNING_KEY_PASSWORD")
- }
- }
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
-
- if (System.getenv("SIGNING_STORE_FILE") != null) {
- signingConfig signingConfigs.release
- }
- }
- }
-}
-
-dependencies {
- implementation 'org.roc-streaming.roctoolkit:roc-android:0.2.1'
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
- implementation 'androidx.appcompat:appcompat:1.1.0'
- implementation 'androidx.core:core-ktx:1.3.0'
- implementation 'androidx.activity:activity-ktx:1.2.4'
- implementation 'androidx.fragment:fragment-ktx:1.3.6'
- implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
- implementation 'com.google.android.material:material:1.3.0'
- implementation 'androidx.viewpager2:viewpager2:1.0.0'
- implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
-}
diff --git a/app/gradle.properties b/app/gradle.properties
deleted file mode 100644
index 4b7d882..0000000
--- a/app/gradle.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-compileSdkVersion=29
-targetSdkVersion=29
-minSdkVersion=26
-ndkVersion=25.2.9519653
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 7b62584..0000000
--- a/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/AboutActivity.kt b/app/src/main/java/org/rocstreaming/rocdroid/AboutActivity.kt
deleted file mode 100644
index 93c338e..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/AboutActivity.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.rocstreaming.rocdroid
-
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Bundle
-import android.widget.Button
-import android.widget.TextView
-import androidx.appcompat.app.AppCompatActivity
-import java.lang.String.format
-
-class AboutActivity : AppCompatActivity() {
- private lateinit var sourceCodeButton: Button
- private lateinit var bugTrackerButton: Button
- private lateinit var contributorsButton: Button
- private lateinit var licenseButton: Button
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_about)
-
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
- sourceCodeButton = findViewById(R.id.about_source_code)
- bugTrackerButton = findViewById(R.id.about_bug_tracker)
- contributorsButton = findViewById(R.id.about_contributors)
- licenseButton = findViewById(R.id.app_license)
-
- val manager = this.packageManager
- val info = manager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES)
-
- findViewById(R.id.app_version).text = format("v%s", info.versionName)
-
- sourceCodeButton.setOnClickListener {
- openLink(getString(R.string.url_repo))
- }
- bugTrackerButton.setOnClickListener {
- openLink(getString(R.string.url_bugs))
- }
- contributorsButton.setOnClickListener {
- openLink(getString(R.string.url_contributors))
- }
- licenseButton.setOnClickListener {
- openLink(getString(R.string.uri_license))
- }
- }
-
- private fun openLink(link: String) {
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
- startActivity(browserIntent)
- }
-}
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/MainActivity.kt b/app/src/main/java/org/rocstreaming/rocdroid/MainActivity.kt
deleted file mode 100644
index efa9b86..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/MainActivity.kt
+++ /dev/null
@@ -1,130 +0,0 @@
-package org.rocstreaming.rocdroid
-
-import android.content.ComponentName
-import android.content.Intent
-import android.content.ServiceConnection
-import android.content.SharedPreferences
-import android.media.AudioManager
-import android.os.Bundle
-import android.os.IBinder
-import android.util.Log
-import android.view.Menu
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.viewpager2.widget.ViewPager2
-import com.google.android.material.tabs.TabLayout
-import com.google.android.material.tabs.TabLayoutMediator
-import org.rocstreaming.rocdroid.adapter.ViewPagerAdapter
-import org.rocstreaming.rocdroid.fragment.ReceiverFragment
-import org.rocstreaming.rocdroid.fragment.SenderFragment
-
-private const val LOG_TAG = "[rocdroid.MainActivity]"
-
-class MainActivity : AppCompatActivity() {
- private lateinit var pager: ViewPager2
- private lateinit var tabs: TabLayout
- private val senderFragment = SenderFragment()
- private val receiverFragment = ReceiverFragment()
-
- private lateinit var tabsTitle: Array
-
- private lateinit var prefs: SharedPreferences
- private lateinit var senderReceiverService: SenderReceiverService
-
- private val senderReceiverServiceConnection = object : ServiceConnection {
- override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
- senderReceiverService = (binder as SenderReceiverService.LocalBinder).getService()
-
- senderFragment.onServiceConnected(
- senderReceiverService,
- { showActiveIcon(1) },
- { hideActiveIcon(1) }
- )
- receiverFragment.onServiceConnected(
- senderReceiverService,
- { showActiveIcon(0) },
- { hideActiveIcon(0) }
- )
- }
-
- override fun onServiceDisconnected(componentName: ComponentName) {
- senderReceiverService.removeListeners()
- }
- }
-
- fun showActiveIcon(tabIdx: Int) {
- tabs.getTabAt(tabIdx)?.icon?.alpha = 255
- }
-
- fun hideActiveIcon(tabIdx: Int) {
- tabs.getTabAt(tabIdx)?.icon?.alpha = 0
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- Log.d(LOG_TAG, "Create Main Activity")
-
- setContentView(R.layout.activity_main)
- volumeControlStream = AudioManager.STREAM_MUSIC
-
- pager = findViewById(R.id.viewPager)
- tabs = findViewById(R.id.tabs)
-
- tabsTitle = arrayOf(
- getString(R.string.receiver),
- getString(R.string.sender)
- )
-
- val adapter = ViewPagerAdapter(
- supportFragmentManager,
- lifecycle
- ).setFragments(
- receiverFragment,
- senderFragment
- )
-
- pager.setAdapter(adapter)
-
- TabLayoutMediator(tabs, pager) { tab, position ->
- tab.text = tabsTitle[position]
- tab.icon = ContextCompat.getDrawable(this@MainActivity, R.drawable.round_indicator)
- tab.icon?.alpha = 0
- pager.setCurrentItem(tab.position, true)
- }.attach()
-
- prefs = getSharedPreferences("settings", android.content.Context.MODE_PRIVATE)
-
- val serviceIntent = Intent(this, SenderReceiverService::class.java)
- bindService(serviceIntent, senderReceiverServiceConnection, BIND_AUTO_CREATE)
- }
-
- override fun onResume() {
- super.onResume()
- volumeControlStream = AudioManager.STREAM_MUSIC
- }
-
- override fun onDestroy() {
- super.onDestroy()
- unbindService(senderReceiverServiceConnection)
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- super.onCreateOptionsMenu(menu)
- menuInflater.inflate(R.menu.menu, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.about -> {
- val intent = Intent(this, AboutActivity::class.java)
- startActivity(intent)
-
- true
- }
- else -> false
- }
- }
-}
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/ReceiverTileService.kt b/app/src/main/java/org/rocstreaming/rocdroid/ReceiverTileService.kt
deleted file mode 100644
index 38bf6f8..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/ReceiverTileService.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package org.rocstreaming.rocdroid
-
-import android.content.ComponentName
-import android.content.Intent
-import android.content.ServiceConnection
-import android.os.IBinder
-import android.service.quicksettings.Tile
-import android.service.quicksettings.TileService
-import android.util.Log
-
-private const val LOG_TAG = "[rocdroid.ReceiverTileService]"
-
-class ReceiverTileService : TileService() {
-
- private var senderReceiverService: SenderReceiverService? = null
-
- private val senderReceiverServiceConnection = object : ServiceConnection {
-
- override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
- senderReceiverService = (binder as SenderReceiverService.LocalBinder).getService()
- }
-
- override fun onServiceDisconnected(componentName: ComponentName) {
- senderReceiverService?.removeListeners()
- senderReceiverService = null
- }
- }
-
- override fun onCreate() {
- Log.d(LOG_TAG, "Creating Receiver Tile Service")
-
- val intent = Intent(this.baseContext, SenderReceiverService::class.java)
- this.applicationContext.bindService(intent, senderReceiverServiceConnection, BIND_AUTO_CREATE)
- }
-
- override fun onStartListening() {
- Log.d(LOG_TAG, "Start listening to Tile")
-
- if (senderReceiverService?.isReceiverAlive() == true) {
- this.qsTile.state = Tile.STATE_ACTIVE
- } else {
- this.qsTile.state = Tile.STATE_INACTIVE
- }
-
- this.qsTile.updateTile()
- }
-
- override fun onClick() {
- Log.d(LOG_TAG, "Tile click event")
-
- senderReceiverService?.let {
- if (it.isReceiverAlive()) {
- it.stopReceiver()
- this.qsTile.state = Tile.STATE_INACTIVE
- } else {
- it.startReceiver()
- this.qsTile.state = Tile.STATE_ACTIVE
- }
- }
-
- this.qsTile.updateTile()
- }
-}
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/SenderReceiverService.kt b/app/src/main/java/org/rocstreaming/rocdroid/SenderReceiverService.kt
deleted file mode 100644
index 2f5be92..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/SenderReceiverService.kt
+++ /dev/null
@@ -1,474 +0,0 @@
-package org.rocstreaming.rocdroid
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.app.Service
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.graphics.drawable.Icon
-import android.media.AudioAttributes
-import android.media.AudioFormat
-import android.media.AudioPlaybackCaptureConfiguration
-import android.media.AudioRecord
-import android.media.AudioTrack
-import android.media.MediaRecorder
-import android.media.projection.MediaProjection
-import android.os.Binder
-import android.os.Build
-import android.os.IBinder
-import android.util.Log
-import androidx.annotation.RequiresApi
-import androidx.appcompat.app.AlertDialog
-import org.rocstreaming.roctoolkit.ChannelSet
-import org.rocstreaming.roctoolkit.ClockSource
-import org.rocstreaming.roctoolkit.Endpoint
-import org.rocstreaming.roctoolkit.FrameEncoding
-import org.rocstreaming.roctoolkit.Interface
-import org.rocstreaming.roctoolkit.Protocol
-import org.rocstreaming.roctoolkit.RocContext
-import org.rocstreaming.roctoolkit.RocReceiver
-import org.rocstreaming.roctoolkit.RocReceiverConfig
-import org.rocstreaming.roctoolkit.RocSender
-import org.rocstreaming.roctoolkit.RocSenderConfig
-import org.rocstreaming.roctoolkit.Slot
-
-private const val SAMPLE_RATE = 44100
-private const val BUFFER_SIZE = 100
-
-private const val DEFAULT_RTP_PORT_SOURCE = 10001
-private const val DEFAULT_RTP_PORT_REPAIR = 10002
-
-private const val CHANNEL_ID = "SenderReceiverService"
-private const val NOTIFICATION_ID = 1
-
-private const val BROADCAST_STOP_SENDER_ACTION =
- "org.rocstreaming.rocdroid.NotificationSenderStopAction"
-private const val BROADCAST_STOP_RECEIVER_ACTION =
- "org.rocstreaming.rocdroid.NotificationReceiverStopAction"
-
-private const val LOG_TAG = "[rocdroid.SenderReceiverService]"
-
-class SenderReceiverService : Service() {
- private var receiverThread: Thread? = null
- private var senderThread: Thread? = null
- private var receiverChanged: ((Boolean) -> Unit)? = null
- private var senderChanged: ((Boolean) -> Unit)? = null
- private var isForegroundRunning = false
-
- private val notificationStopActionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- when (intent.action) {
- BROADCAST_STOP_SENDER_ACTION -> stopSender()
- BROADCAST_STOP_RECEIVER_ACTION -> stopReceiver()
- }
- }
- }
-
- private val binder = LocalBinder()
-
- inner class LocalBinder : Binder() {
- fun getService(): SenderReceiverService = this@SenderReceiverService
- }
-
- override fun onBind(intent: Intent): IBinder {
- Log.d(LOG_TAG, "Bind Service")
-
- return binder
- }
-
- override fun onCreate() {
- Log.d(LOG_TAG, "Creating Sender/Receiver Service")
-
- createNotificationChannel()
- registerReceiver(
- notificationStopActionReceiver,
- IntentFilter().apply {
- addAction(BROADCAST_STOP_SENDER_ACTION)
- addAction(BROADCAST_STOP_RECEIVER_ACTION)
- }
- )
- }
-
- override fun onDestroy() {
- Log.d(LOG_TAG, "Destroying Sender/Receiver Service")
-
- super.onDestroy()
- unregisterReceiver(notificationStopActionReceiver)
- }
-
- private fun createNotificationChannel() {
- Log.d(LOG_TAG, "Creating Notification Channel")
-
- val channel = NotificationChannel(
- CHANNEL_ID,
- getString(R.string.notification_channel_name),
- NotificationManager.IMPORTANCE_LOW
- )
- val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- service.createNotificationChannel(channel)
- }
-
- private fun buildNotification(sending: Boolean, receiving: Boolean): Notification {
- Log.d(
- LOG_TAG,
- String.format(
- "Building Notification for %s %s",
- if (sending) "Sender" else "",
- if (receiving) "Receiver" else ""
- )
- )
-
- val mainActivityIntent = Intent(this, MainActivity::class.java)
- val pendingMainActivityIntent = PendingIntent.getActivity(
- this,
- 0,
- mainActivityIntent,
- PendingIntent.FLAG_UPDATE_CURRENT
- )
- val stopSenderIntent = Intent(BROADCAST_STOP_SENDER_ACTION)
- val pendingStopSenderIntent = PendingIntent.getBroadcast(
- this@SenderReceiverService,
- 0,
- stopSenderIntent,
- PendingIntent.FLAG_UPDATE_CURRENT
- )
- val stopReceiverIntent = Intent(BROADCAST_STOP_RECEIVER_ACTION)
- val pendingStopReceiverIntent = PendingIntent.getBroadcast(
- this@SenderReceiverService,
- 0,
- stopReceiverIntent,
- PendingIntent.FLAG_UPDATE_CURRENT
- )
- val stopSenderAction = Notification.Action.Builder(
- Icon.createWithResource(this@SenderReceiverService, R.drawable.ic_stop),
- getString(R.string.notification_stop_sender_action),
- pendingStopSenderIntent
- ).build()
- val stopReceiverAction = Notification.Action.Builder(
- Icon.createWithResource(this@SenderReceiverService, R.drawable.ic_stop),
- getString(R.string.notification_stop_receiver_action),
- pendingStopReceiverIntent
- ).build()
- return Notification.Builder(this, CHANNEL_ID).apply {
- setContentTitle(getString(R.string.notification_title))
- setContentText(getContentText(sending, receiving))
- setSmallIcon(R.drawable.ic_notification)
- setVisibility(Notification.VISIBILITY_PUBLIC)
- setContentIntent(pendingMainActivityIntent)
- if (sending) {
- addAction(stopSenderAction)
- }
- if (receiving) {
- addAction(stopReceiverAction)
- }
- }.build()
- }
-
- private fun updateNotification(sending: Boolean, receiving: Boolean) {
- Log.d(
- LOG_TAG,
- String.format(
- "Updating Notification for %s %s",
- if (sending) "Sender" else "",
- if (receiving) "Receiver" else ""
- )
- )
-
- if (!isForegroundRunning) {
- return
- }
-
- if (receiving || sending) {
- val notification = buildNotification(sending, receiving)
- val notificationManager =
- getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.notify(NOTIFICATION_ID, notification)
- } else {
- stopForegroundService()
- }
- }
-
- private fun getContentText(sending: Boolean, receiving: Boolean): String {
- Log.d(LOG_TAG, "Getting Notification Content Text")
-
- if (sending && receiving) {
- return getString(R.string.notification_sender_and_receiver_running)
- }
- if (receiving) {
- return getString(R.string.notification_receiver_running)
- }
- if (sending) {
- return getString(R.string.notification_sender_running)
- }
- return getString(R.string.notification_sender_and_receiver_not_running) // this shouldn't happen
- }
-
- fun preStartSender() {
- Log.d(LOG_TAG, "Prestart Sender")
-
- if (isForegroundRunning) {
- updateNotification(true, isReceiverAlive())
- } else {
- startForegroundService(true, isReceiverAlive())
- }
- }
-
- fun startSender(ip: String, projection: MediaProjection?) {
- Log.d(LOG_TAG, "Starting Sender")
-
- if (senderThread?.isAlive == true) return
-
- senderThread = Thread {
- val record = createAudioRecord(projection)
-
- val config = RocSenderConfig.builder()
- .frameSampleRate(44100)
- .frameChannels(ChannelSet.STEREO)
- .frameEncoding(FrameEncoding.PCM_FLOAT)
- .clockSource(ClockSource.INTERNAL)
- .build()
-
- RocContext().use { context ->
- if (record.state != AudioRecord.STATE_INITIALIZED) return@use
-
- record.startRecording()
-
- RocSender(context, config).use useSender@{ sender ->
-
- try {
- sender.connect(
- Slot.DEFAULT,
- Interface.AUDIO_SOURCE,
- Endpoint(Protocol.RTP_RS8M_SOURCE, ip, DEFAULT_RTP_PORT_SOURCE)
- )
- sender.connect(
- Slot.DEFAULT,
- Interface.AUDIO_REPAIR,
- Endpoint(Protocol.RS8M_REPAIR, ip, DEFAULT_RTP_PORT_REPAIR)
- )
- } catch (e: Exception) {
- AlertDialog.Builder(this@SenderReceiverService).apply {
- setTitle(getString(R.string.invalid_ip_title))
- setMessage(getString(R.string.invalid_ip_message))
- setCancelable(false)
- setPositiveButton(R.string.ok) { _, _ -> }
- }.show()
- return@useSender
- }
-
- senderChanged?.invoke(true)
-
- val samples = FloatArray(BUFFER_SIZE)
- while (!Thread.currentThread().isInterrupted) {
- record.read(samples, 0, samples.size, AudioRecord.READ_BLOCKING)
- sender.write(samples)
- }
- }
-
- record.stop()
- record.release()
- senderChanged?.invoke(false)
- updateNotification(false, isReceiverAlive())
- }
- }
-
- senderThread!!.start()
- }
-
- fun startReceiver() {
- Log.d(LOG_TAG, "Starting Receiver")
-
- if (receiverThread?.isAlive == true) return
-
- receiverThread = Thread {
- val audioTrack = createAudioTrack()
- audioTrack.play()
-
- val config = RocReceiverConfig.builder()
- .frameSampleRate(44100)
- .frameChannels(ChannelSet.STEREO)
- .frameEncoding(FrameEncoding.PCM_FLOAT)
- .clockSource(ClockSource.INTERNAL)
- .build()
-
- RocContext().use { context ->
- RocReceiver(context, config).use { receiver ->
- receiver.bind(
- Slot.DEFAULT,
- Interface.AUDIO_SOURCE,
- Endpoint(Protocol.RTP_RS8M_SOURCE, "0.0.0.0", DEFAULT_RTP_PORT_SOURCE)
- )
- receiver.bind(
- Slot.DEFAULT,
- Interface.AUDIO_REPAIR,
- Endpoint(Protocol.RS8M_REPAIR, "0.0.0.0", DEFAULT_RTP_PORT_REPAIR)
- )
-
- receiverChanged?.invoke(true)
-
- val samples = FloatArray(BUFFER_SIZE)
- while (!Thread.currentThread().isInterrupted) {
- receiver.read(samples)
- audioTrack.write(samples, 0, samples.size, AudioTrack.WRITE_BLOCKING)
- }
- }
- }
-
- audioTrack.release()
- receiverChanged?.invoke(false)
- updateNotification(isSenderAlive(), false)
- }
-
- receiverThread!!.start()
-
- if (isForegroundRunning) {
- updateNotification(isSenderAlive(), true)
- } else {
- startForegroundService(isSenderAlive(), true)
- }
- }
-
- fun stopSender() {
- Log.d(LOG_TAG, "Stopping Sender")
-
- senderThread?.interrupt()
- }
-
- fun stopReceiver() {
- Log.d(LOG_TAG, "Stopping Receiver")
-
- receiverThread?.interrupt()
- }
-
- fun isReceiverAlive(): Boolean {
- Log.d(LOG_TAG, "Checking If Receiver Alive")
-
- return receiverThread?.isAlive == true
- }
-
- fun isSenderAlive(): Boolean {
- Log.d(LOG_TAG, "Checking If Sender Alive")
-
- return senderThread?.isAlive == true
- }
-
- private fun startForegroundService(sending: Boolean, receiving: Boolean) {
- Log.d(
- LOG_TAG,
- String.format(
- "Starting Foreground Service for %s %s",
- if (sending) "Sender" else "",
- if (receiving) "Receiver" else ""
- )
- )
-
- isForegroundRunning = true
- startForeground(NOTIFICATION_ID, buildNotification(sending, receiving))
- }
-
- private fun stopForegroundService() {
- Log.d(LOG_TAG, "Stopping Foreground Service")
-
- isForegroundRunning = false
- stopForeground(true)
- }
-
- private fun createAudioTrack(): AudioTrack {
- Log.d(LOG_TAG, "Creating Audio Track")
-
- val audioAttributes = AudioAttributes.Builder().apply {
- setUsage(AudioAttributes.USAGE_MEDIA)
- setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
- }.build()
- val audioFormat = AudioFormat.Builder().apply {
- setSampleRate(SAMPLE_RATE)
- setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
- setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
- }.build()
- val bufferSize = AudioTrack.getMinBufferSize(
- audioFormat.sampleRate,
- audioFormat.channelMask,
- audioFormat.encoding
- )
- return AudioTrack.Builder().apply {
- setAudioAttributes(audioAttributes)
- setAudioFormat(audioFormat)
- setBufferSizeInBytes(bufferSize)
- setTransferMode(AudioTrack.MODE_STREAM)
- setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
- }.build()
- }
-
- private fun createAudioRecord(projection: MediaProjection?): AudioRecord {
- Log.d(LOG_TAG, "Creating Audio Record")
-
- val format = AudioFormat.Builder().apply {
- setSampleRate(SAMPLE_RATE)
- setChannelMask(AudioFormat.CHANNEL_IN_STEREO)
- setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
- }.build()
- val bufferSize = AudioRecord.getMinBufferSize(
- SAMPLE_RATE,
- AudioFormat.CHANNEL_IN_STEREO,
- AudioFormat.ENCODING_PCM_FLOAT
- )
-
- return if (projection != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- createPaybackRecord(projection, format, bufferSize)
- } else {
- AudioRecord.Builder().apply {
- setAudioSource(MediaRecorder.AudioSource.DEFAULT)
- setAudioFormat(format)
- setBufferSizeInBytes(bufferSize)
- }.build()
- }
- }
-
- @RequiresApi(Build.VERSION_CODES.Q)
- private fun createPaybackRecord(
- projection: MediaProjection,
- format: AudioFormat,
- bufferSize: Int
- ): AudioRecord {
- Log.d(LOG_TAG, "Creating Playback Record")
-
- val config = AudioPlaybackCaptureConfiguration.Builder(projection).apply {
- addMatchingUsage(AudioAttributes.USAGE_MEDIA)
- addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
- addMatchingUsage(AudioAttributes.USAGE_GAME)
- }.build()
-
- return AudioRecord.Builder().apply {
- setAudioPlaybackCaptureConfig(config)
- setAudioFormat(format)
- setBufferSizeInBytes(bufferSize)
- }.build()
- }
-
- fun setSenderStateChangedListeners(
- senderChanged: (Boolean) -> Unit
- ) {
- Log.d(LOG_TAG, "Setting Sender State Changed Listener")
-
- this.senderChanged = senderChanged
- }
-
- fun setReceiverStateChangedListeners(
- receiverChanged: (Boolean) -> Unit
- ) {
- Log.d(LOG_TAG, "Setting Receiver State Changed Listener")
-
- this.receiverChanged = receiverChanged
- }
-
- fun removeListeners() {
- Log.d(LOG_TAG, "Removing State Changed Listeners")
-
- this.receiverChanged = null
- this.senderChanged = null
- }
-}
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/adapter/ViewPagerAdapter.kt b/app/src/main/java/org/rocstreaming/rocdroid/adapter/ViewPagerAdapter.kt
deleted file mode 100644
index 22b9087..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/adapter/ViewPagerAdapter.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package org.rocstreaming.rocdroid.adapter
-
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
-import androidx.lifecycle.Lifecycle
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import org.rocstreaming.rocdroid.fragment.ReceiverFragment
-import org.rocstreaming.rocdroid.fragment.SenderFragment
-
-private const val NUM_TABS = 2
-
-class ViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
- FragmentStateAdapter(fragmentManager, lifecycle) {
- private lateinit var receiverFragment: ReceiverFragment
- private lateinit var senderFragment: SenderFragment
-
- override fun getItemCount(): Int {
- return NUM_TABS
- }
-
- override fun createFragment(position: Int): Fragment {
- return if (position == 0) receiverFragment else senderFragment
- }
-
- fun setFragments(receiver: ReceiverFragment, sender: SenderFragment): ViewPagerAdapter {
- receiverFragment = receiver
- senderFragment = sender
-
- return this
- }
-}
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/component/CopyBlock.kt b/app/src/main/java/org/rocstreaming/rocdroid/component/CopyBlock.kt
deleted file mode 100644
index 0815600..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/component/CopyBlock.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package org.rocstreaming.rocdroid.component
-
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
-import android.os.Handler
-import android.util.AttributeSet
-import android.util.Log
-import android.view.View
-import android.view.animation.Animation
-import android.view.animation.AnimationUtils
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.constraintlayout.widget.ConstraintLayout
-import org.rocstreaming.rocdroid.R
-
-private const val LOG_TAG = "[rocdroid.component.CopyBlock]"
-
-class CopyBlock : ConstraintLayout {
- constructor(context: Context) : super(context)
- constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
-
- private val textBlock: TextView?
-
- init {
- Log.d(LOG_TAG, "Init Copy Block")
-
- val view: View = inflate(context, R.layout.copy_block_component, this)
- textBlock = view.findViewById(R.id.block_label)
-
- view.findViewById(R.id.copy_block).setOnClickListener {
- setClipboard(context, textBlock.text, it.findViewById(R.id.copy_icon))
- }
- }
-
- fun setText(text: String) {
- Log.d(LOG_TAG, String.format("Setting Text To Copy Block: %s", text))
-
- textBlock?.text = text
- }
-
- private fun setClipboard(context: Context, text: CharSequence, icon: ImageView) {
- Log.d(LOG_TAG, String.format("Copying Text: %s", text))
-
- val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
-
- val clipData = ClipData.newPlainText("text", text)
- clipboardManager.setPrimaryClip(clipData)
-
- animateImageChange(context, icon, R.drawable.ic_done)
- Handler().postDelayed({
- animateImageChange(context, icon, R.drawable.ic_copy)
- }, 1000)
- }
-
- private fun animateImageChange(c: Context?, icon: ImageView, image: Int) {
- Log.d(LOG_TAG, String.format("Changing image in Copy Block"))
-
- val animOut: Animation = AnimationUtils.loadAnimation(c, android.R.anim.fade_out)
- val animIn: Animation = AnimationUtils.loadAnimation(c, android.R.anim.fade_in)
- animOut.setAnimationListener(object : Animation.AnimationListener {
- override fun onAnimationStart(animation: Animation?) {}
- override fun onAnimationRepeat(animation: Animation?) {}
- override fun onAnimationEnd(animation: Animation?) {
- icon.setImageResource(image)
- icon.startAnimation(animIn)
- }
- })
- icon.startAnimation(animOut)
- }
-}
diff --git a/app/src/main/java/org/rocstreaming/rocdroid/fragment/ReceiverFragment.kt b/app/src/main/java/org/rocstreaming/rocdroid/fragment/ReceiverFragment.kt
deleted file mode 100644
index b44e18a..0000000
--- a/app/src/main/java/org/rocstreaming/rocdroid/fragment/ReceiverFragment.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package org.rocstreaming.rocdroid.fragment
-
-import android.os.Bundle
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Button
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.fragment.app.Fragment
-import org.rocstreaming.rocdroid.R
-import org.rocstreaming.rocdroid.SenderReceiverService
-import org.rocstreaming.rocdroid.component.CopyBlock
-import java.net.NetworkInterface
-
-private const val LOG_TAG = "[rocdroid.fragment.ReceiverFragment]"
-
-class ReceiverFragment : Fragment() {
-
- private lateinit var receiverService: SenderReceiverService
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? {
- Log.d(LOG_TAG, "Create Receiver Fragment View")
-
- val view = inflater.inflate(R.layout.receiver_fragment, container, false)
-
- view.findViewById(R.id.sourcePortValue)?.setText("10001")
- view.findViewById(R.id.repairPortValue)?.setText("10002")
-
- val ipAddressesContainer: LinearLayout = view.findViewById(R.id.IPAddresses)
-
- getIpAddresses().forEach {
- val copyBlock = CopyBlock(requireActivity())
- copyBlock.setText(it)
-
- ipAddressesContainer.addView(copyBlock)
- }
-
- view.findViewById(R.id.portForSource).text =
- getString(R.string.receiver_sender_port_for_source).format(3)
-
- view.findViewById(R.id.portForRepair).text =
- getString(R.string.receiver_sender_port_for_repair).format(4)
-
- view.findViewById