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! -[![Build](https://github.com/roc-streaming/roc-droid/actions/workflows/build.yaml/badge.svg)](https://github.com/roc-streaming/roc-droid/actions/workflows/build.yaml) [![GitHub release](https://img.shields.io/github/release/roc-streaming/roc-droid.svg)](https://github.com/roc-streaming/roc-droid/releases) [![Matrix chat](https://matrix.to/img/matrix-badge.svg)](https://app.element.io/#/room/#roc-streaming:matrix.org) +[![Build](https://github.com/roc-streaming/roc-droid/actions/workflows/build.yaml/badge.svg?branch=main)](https://github.com/roc-streaming/roc-droid/actions/workflows/build.yaml) [![GitHub release](https://img.shields.io/github/release/roc-streaming/roc-droid.svg)](https://github.com/roc-streaming/roc-droid/releases) [![Matrix chat](https://matrix.to/img/matrix-badge.svg)](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: [Get it on F-Droid](https://f-droid.org/packages/org.rocstreaming.rocdroid/) + height="70">](https://f-droid.org/packages/org.rocstreaming.rocdroid/) [Get it on F-Droid](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! -Donate using Liberapay - -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. +Donate on GitHub Sponsors - ``` - ./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