Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 50 additions & 25 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
name: Android

# Builds a debug APK for 64-bit and 32-bit ARM, with libmpv and FFmpeg from
# scripts/build-libmpv.sh android. Nothing is signed or uploaded; this
# catches changes that break the Android build.
# scripts/build-libmpv.sh android, once for Google's store and once for
# Amazon's. Nothing is signed or uploaded; this catches changes that break
# the Android build.
on:
push:
branches: [main, dev]
Expand Down Expand Up @@ -130,36 +131,60 @@ jobs:
ls "$LIBCLANG_PATH"/libclang*
cargo build --locked -vv -p libmpv2-sys --target armv7-linux-androideabi 2>&1 | grep -vE '^\s+(Fresh|Compiling)' | tail -40

# android-env.sh sets what bindgen needs for armv7.
- name: Build the APK
# android-env.sh sets what bindgen needs for armv7. The Google build
# comes first. The Amazon one only differs in the store property that
# app/build.gradle.kts reads, so cargo has nothing left to build for it.
- name: Build the APKs
working-directory: apps/player
run: |
. ../../scripts/android-env.sh
pnpm tauri android build --debug --target aarch64 --target armv7 --apk --ci
OUT=src-tauri/gen/android/app/build/outputs/apk/universal/debug
for store in google amazon; do
ORG_GRADLE_PROJECT_store="$store" pnpm tauri android build --debug --target aarch64 --target armv7 --apk --ci
mv "$OUT/app-universal-debug.apk" "$RUNNER_TEMP/app-$store-debug.apk"
done

- name: Check the APK
- name: Check the APKs
run: |
APK="apps/player/src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
test -f "$APK"
for abi in arm64-v8a armeabi-v7a; do
for lib in max_video_player_lib mpv avcodec avformat avutil; do
unzip -l "$APK" | grep "lib/$abi/lib$lib.so"
AAPT2="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/aapt2"
for store in google amazon; do
APK="$RUNNER_TEMP/app-$store-debug.apk"
echo "::group::$store"
test -f "$APK"
for abi in arm64-v8a armeabi-v7a; do
for lib in max_video_player_lib mpv avcodec avformat avutil; do
unzip -l "$APK" | grep "lib/$abi/lib$lib.so"
done
done
BADGING="$RUNNER_TEMP/badging-$store.txt"
"$AAPT2" dump badging "$APK" > "$BADGING"
cat "$BADGING"
# minSdk is set in app/build.gradle.kts. aapt2 from build-tools 34
# and older calls the line sdkVersion.
if ! grep -Eq "^(minSdkVersion|sdkVersion):'28'$" "$BADGING"; then
echo "::error::The $store APK's minimum SDK isn't 28"
exit 1
fi
if ! grep -q "^leanback-launchable-activity:" "$BADGING"; then
echo "::error::The $store APK has no TV launcher activity"
exit 1
fi
# Both stores hide an app from every device that lacks a feature
# it requires, including one a permission implies, like Wi-Fi.
if grep -E "^\s*uses-feature:" "$BADGING"; then
echo "::error::The $store APK requires the features above, which hides it from TVs without them"
exit 1
fi
# android_device.rs reads the store from this meta-data.
"$AAPT2" dump xmltree --file AndroidManifest.xml "$APK" > "$RUNNER_TEMP/manifest-$store.txt"
if ! grep -A1 'A: http://schemas.android.com/apk/res/android:name(0x01010003)="app.cinette.STORE"' "$RUNNER_TEMP/manifest-$store.txt" \
| grep -q "android:value(0x01010024)=\"$store\""; then
grep -A1 "app.cinette.STORE" "$RUNNER_TEMP/manifest-$store.txt" || true
echo "::error::The $store APK's manifest doesn't say app.cinette.STORE=$store"
exit 1
fi
echo "::endgroup::"
done
AAPT2="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/aapt2"
BADGING="$RUNNER_TEMP/badging.txt"
"$AAPT2" dump badging "$APK" > "$BADGING"
cat "$BADGING"
# minSdk is set in app/build.gradle.kts. aapt2 from build-tools 34
# and older calls the line sdkVersion.
if ! grep -Eq "^(minSdkVersion|sdkVersion):'28'$" "$BADGING"; then
echo "::error::The APK's minimum SDK isn't 28"
exit 1
fi
if ! grep -q "^leanback-launchable-activity:" "$BADGING"; then
echo "::error::The APK has no TV launcher activity"
exit 1
fi

# The build above shows warnings without failing. This lints the
# workspace for both Android targets, including the code that only
Expand Down
34 changes: 34 additions & 0 deletions apps/player/src-tauri/gen/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,53 @@ plugins {
id("rust")
}

// The store the build is for: google (the default) or amazon. Set it with
// -Pstore=amazon, or ORG_GRADLE_PROJECT_store=amazon around
// `pnpm tauri android build`. A property rather than a flavor dimension,
// because a second dimension renames the variant tasks that Tauri's
// RustPlugin.kt and CLI expect. Both stores get the same Rust library.
val stores = listOf("google", "amazon")
val store = (findProperty("store") as? String ?: "google").also {
require(it in stores) { "store must be one of $stores, not \"$it\"" }
}

val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}

// The upload key for release builds, from gen/android/keystore.properties,
// which .gitignore keeps out of the repo, as it does the key. See
// docs/amazon-appstore.md. Without the file, release builds come out
// unsigned.
val keystoreProperties = Properties().apply {
val propFile = rootProject.file("keystore.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}

android {
compileSdk = 37
namespace = "app.cinette"
if (!keystoreProperties.isEmpty) {
signingConfigs {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
defaultConfig {
// Many IPTV servers only speak plain HTTP. res/xml/network_security_config.xml
// allows it too, and that file wins on every Android version the app runs on.
manifestPlaceholders["usesCleartextTraffic"] = "true"
// android_device.rs reads it from the manifest's app.cinette.STORE.
manifestPlaceholders["store"] = store
applicationId = "app.cinette"
minSdk = 28
targetSdk = 37
Expand All @@ -40,6 +73,7 @@ android {
}
}
getByName("release") {
signingConfigs.findByName("release")?.let { signingConfig = it }
optimization {
enable = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
packets many Wi-Fi drivers drop unless an app holds a multicast
lock. -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- That permission implies Wi-Fi, which the stores would then require.
TVs on Ethernet have none, and android_device.rs copes without it. -->
<uses-feature android:name="android.hardware.wifi" android:required="false" />

<!-- TVs have no touch screen. Without these the store hides the app from
them, and the Leanback one from phones. -->
Expand All @@ -20,6 +23,10 @@
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<!-- The store this build is for, google or amazon. app/build.gradle.kts
sets it from the store property. -->
<meta-data android:name="app.cinette.STORE" android:value="${store}" />

<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
Expand Down
57 changes: 54 additions & 3 deletions apps/player/src-tauri/src/android_device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.

//! What the app needs to know from Android: whether this is a TV, told to
//! the page before any page script runs, and the device type remote control
//! advertises. Also the Wi-Fi multicast lock that DNS-SD needs.
//! the page before any page script runs, the device type remote control
//! advertises, and the store the build is for. Also the Wi-Fi multicast lock
//! that DNS-SD needs.

use crate::remote::device::{AndroidFacts, TV_FEATURES};
use crate::remote::held::Held;
use jni::objects::{Global, JObject, JValue};
use jni::objects::{Global, JObject, JString, JValue};
use jni::{jni_sig, jni_str};
use mvp_core::remote::message::DeviceType;
use std::sync::{Mutex, OnceLock};
Expand Down Expand Up @@ -94,6 +95,52 @@ fn facts() -> AndroidFacts {
})
}

/// The manifest's `app.cinette.STORE`, which `app/build.gradle.kts` sets
/// from its `store` property: `google` or `amazon`.
fn read_store() -> jni::errors::Result<String> {
with_context(|env, context| {
let packages = env
.call_method(
context,
jni_str!("getPackageManager"),
jni_sig!(() -> android.content.pm.PackageManager),
&[],
)?
.l()?;
let name = env
.call_method(
context,
jni_str!("getPackageName"),
jni_sig!(() -> java.lang.String),
&[],
)?
.l()?;
// PackageManager.GET_META_DATA
const GET_META_DATA: i32 = 128;
let info = env
.call_method(
&packages,
jni_str!("getApplicationInfo"),
jni_sig!((java.lang.String, jint) -> android.content.pm.ApplicationInfo),
&[JValue::from(&name), JValue::Int(GET_META_DATA)],
)?
.l()?;
let meta = env
.get_field(&info, jni_str!("metaData"), jni_sig!(android.os.Bundle))?
.l()?;
let key = env.new_string("app.cinette.STORE")?;
let value = env
.call_method(
&meta,
jni_str!("getString"),
jni_sig!((java.lang.String) -> java.lang.String),
&[JValue::from(&key)],
)?
.l()?;
env.cast_local::<JString>(value)?.try_to_string(env)
})
}

/// The type remote control advertises.
pub fn device_type() -> DeviceType {
facts().device_type()
Expand Down Expand Up @@ -170,6 +217,10 @@ fn release_multicast(lock: Option<Global<JObject<'static>>>) -> jni::errors::Res
pub fn plugin<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
let tv = facts().tv;
tracing::info!("Android device is a TV: {tv}");
match read_store() {
Ok(store) => tracing::info!("Built for the {store} store"),
Err(e) => tracing::warn!("Couldn't read the store from the manifest: {e}"),
}
tauri::plugin::Builder::new("android-device")
.js_init_script(format!("window.__CINETTE_TV__ = {tv};"))
.build()
Expand Down
23 changes: 23 additions & 0 deletions apps/player/src-tauri/src/android_project_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,26 @@ fn the_manifest_asks_for_the_multicast_lock() {
r#"<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />"#
));
}

/// `android_device.rs` reads the store from the manifest, which gets it from
/// the Gradle property through a placeholder.
#[test]
fn the_manifest_names_the_store_from_gradle() {
let manifest = include_str!("../gen/android/app/src/main/AndroidManifest.xml");
assert!(manifest.contains(
r#"<meta-data android:name="app.cinette.STORE" android:value="${store}" />"#
));
let gradle = include_str!("../gen/android/app/build.gradle.kts");
assert!(gradle.contains(r#"manifestPlaceholders["store"] = store"#));
assert!(gradle.contains(r#"val stores = listOf("google", "amazon")"#));
}

/// The multicast permission implies Wi-Fi, so without this the stores would
/// hide the app from TVs on Ethernet only.
#[test]
fn wifi_is_not_required() {
let manifest = include_str!("../gen/android/app/src/main/AndroidManifest.xml");
assert!(manifest.contains(
r#"<uses-feature android:name="android.hardware.wifi" android:required="false" />"#
));
}
2 changes: 2 additions & 0 deletions crates/tauri-plugin-mpv/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ kotlin {
}

dependencies {
// For the lifecycle callbacks' AppCompatActivity. The app's version.
implementation("androidx.appcompat:appcompat:1.7.1")
implementation(project(":tauri-android"))
}
47 changes: 47 additions & 0 deletions crates/tauri-plugin-mpv/android/src/main/java/MpvPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
package app.cinette.mpv

import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.media.AudioManager
import android.os.Build
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
Expand All @@ -17,6 +23,7 @@ import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.Plugin
import androidx.appcompat.app.AppCompatActivity

/** The Rust half of the plugin, in `src/android.rs`. */
object MpvNative {
Expand All @@ -26,6 +33,10 @@ object MpvNative {
*/
@JvmStatic
external fun surfaceChanged(surface: Surface?, width: Int, height: Int)

/** Pauses the stream that plays, if one does. */
@JvmStatic
external fun pause()
}

@InvokeArg
Expand Down Expand Up @@ -56,8 +67,30 @@ class MpvPlugin(private val activity: Activity) : Plugin(activity) {
private var surfaceView: SurfaceView? = null
private var webView: WebView? = null

/**
* Pauses when the HDMI output goes, as when the TV turns off or the
* stick is unplugged from it, and Fire TV's test criteria ask. Playback
* stays paused when it comes back. The broadcast is sticky, so the one
* that arrives on registering only reports the current state.
*/
private val hdmiReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (isInitialStickyBroadcast) return
if (intent.getIntExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 1) == 0) {
MpvNative.pause()
}
}
}

override fun load(webView: WebView) {
this.webView = webView
val filter = IntentFilter(AudioManager.ACTION_HDMI_AUDIO_PLUG)
// A system broadcast, which reaches a receiver that isn't exported.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(hdmiReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
activity.registerReceiver(hdmiReceiver, filter)
}
val parent = webView.parent as? ViewGroup ?: return
val view = SurfaceView(activity)
view.holder.addCallback(object : SurfaceHolder.Callback {
Expand All @@ -79,6 +112,20 @@ class MpvPlugin(private val activity: Activity) : Plugin(activity) {
surfaceView = view
}

/**
* Pauses when the app leaves the screen, as on Home. Android has already
* taken the surface, and nothing plays in the background: there's no
* media notification or MediaSession to control it from, and Fire TV
* expects playback to stop. The player page shows it paused on return.
*/
override fun onStop(activity: AppCompatActivity) {
MpvNative.pause()
}

override fun onDestroy(activity: AppCompatActivity) {
activity.unregisterReceiver(hdmiReceiver)
}

/** Moves the view to a rect in the page's CSS pixels. */
@Command
fun setFrame(invoke: Invoke) {
Expand Down
Loading
Loading