kopuz 0.8.2

A modern, lightweight music player built with Rust and Dioxus.
//! Build-time fixups.
//!
//! On Windows we compile the app icon into the executable.
//!
//! On Android we package the media-notification controller. `dx build` / `dx serve`
//! regenerate the Gradle project from a template embedded in the `dx` binary every
//! run, with no way to inject our media classes (`MediaSessionHelper` / `MusicService`
//! / `MediaReceiver`), the custom `MainActivity`, or the `<service>`/`<receiver>`
//! manifest entries the notification controls need.
//!
//! Wry exposes `WRY_ANDROID_KOTLIN_FILES_OUT_DIR` so a build script can drop Kotlin
//! into the generated source tree. dx sets it in the cargo environment, the manifest
//! is written before cargo runs (and Gradle assembles after), so this script runs at
//! exactly the right point to copy our Kotlin and patch the manifest. Because it's
//! part of the cargo build it fires for `dx build` and `dx serve` alike — no
//! post-build script required.

use std::fs;
use std::path::{Path, PathBuf};

/// Permissions the notification + foreground playback service require.
const PERMISSIONS: &[&str] = &[
    "android.permission.POST_NOTIFICATIONS",
    "android.permission.FOREGROUND_SERVICE",
    "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
    "android.permission.WAKE_LOCK",
    "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS",
    "android.permission.READ_EXTERNAL_STORAGE",
    "android.permission.WRITE_EXTERNAL_STORAGE",
    "android.permission.READ_MEDIA_AUDIO",
    "android.permission.READ_MEDIA_IMAGES",
];

/// R8 keep rules. In release (`dx bundle`) minification is on and only keeps
/// `dev.dioxus.main.*`; our media classes are reached purely through JNI
/// (`MediaSessionHelper` is loaded by name, `MediaReceiver.nativeOnAction` is a
/// native method), so without these R8 strips or renames them and the controls
/// silently break in release. The release build.gradle globs `**/*.pro`, so
/// dropping this file into the source tree is enough to wire it in.
const KEEP_RULES: &str = r#"# AUTO-GENERATED by kopuz/build.rs — keep JNI-reached classes from R8.
-keep class com.temidaradev.kopuz.** { *; }
-keepclassmembers class com.temidaradev.kopuz.** { *; }
-keep class dev.dioxus.main.MainActivity { *; }
"#;

/// `<service>` + `<receiver>` injected just inside `</application>`.
const INSIDE_APPLICATION: &str = r#"        <service
            android:name="com.temidaradev.kopuz.MusicService"
            android:foregroundServiceType="mediaPlayback"
            android:stopWithTask="false"
            android:exported="false" />
        <receiver
            android:name="com.temidaradev.kopuz.MediaReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="com.temidaradev.kopuz.ACTION_MEDIA" />
            </intent-filter>
        </receiver>
"#;

fn warn(msg: &str) {
    println!("cargo:warning={msg}");
}

/// Inline the vendored woff2 fonts into the bundled font CSS as base64 `data:`
/// URIs, writing the result to `OUT_DIR`. `main.rs` pulls these in via
/// `include_str!(concat!(env!("OUT_DIR"), "/..."))`, so the fonts are compiled
/// straight into the binary — styling works under a bare `cargo run` on any OS
/// (no CDN, no asset collection, no path resolution). The committed CSS keeps
/// readable `url(fonts/NAME.woff2)` refs; the woff2 live in `assets/fonts/`.
/// Regenerate the inputs with `nu scripts/vendor-fonts.nu`.
fn embed_fonts(crate_dir: &Path) {
    let assets = crate_dir.join("assets");
    let fonts = assets.join("fonts");
    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set"));

    for css_name in ["fontawesome.css", "jetbrains-mono.css", "main.css"] {
        let src = assets.join(css_name);
        println!("cargo:rerun-if-changed={}", src.display());
        let css = match fs::read_to_string(&src) {
            Ok(c) => c,
            Err(e) => {
                warn(&format!("cannot read {}: {e}", src.display()));
                continue;
            }
        };
        let baked = bake_font_css(&css, &fonts);
        let dst = out_dir.join(css_name);
        if let Err(e) = fs::write(&dst, baked) {
            warn(&format!("cannot write {}: {e}", dst.display()));
        }
    }
}

/// Emit the favicon as a `data:` URI string to `OUT_DIR/favicon.uri`, so it's
/// compiled into the binary and renders under a bare `cargo run` (an `asset!()`
/// favicon needs `dx` to collect it). `main.rs` reads it via `include_str!`.
fn embed_favicon(crate_dir: &Path) {
    use base64::Engine;
    let src = crate_dir.join("assets").join("favicon.ico");
    println!("cargo:rerun-if-changed={}", src.display());
    let bytes = match fs::read(&src) {
        Ok(b) => b,
        Err(e) => {
            warn(&format!("cannot read {}: {e}", src.display()));
            return;
        }
    };
    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
    let uri = format!("data:image/x-icon;base64,{b64}");
    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set"));
    let dst = out_dir.join("favicon.uri");
    if let Err(e) = fs::write(&dst, uri) {
        warn(&format!("cannot write {}: {e}", dst.display()));
    }
}

/// Replace every `url(fonts/NAME.EXT)` with an inline `data:` URI read from
/// `fonts/NAME.EXT`. Panics on a missing/unterminated ref so a broken vendor
/// step fails the build loudly instead of silently shipping unstyled icons.
fn bake_font_css(css: &str, fonts: &Path) -> String {
    use base64::Engine;
    const PREFIX: &str = "url(fonts/";

    let mut out = String::with_capacity(css.len());
    let mut rest = css;
    while let Some(idx) = rest.find(PREFIX) {
        out.push_str(&rest[..idx]);
        let after = &rest[idx + PREFIX.len()..];
        let end = after
            .find(')')
            .unwrap_or_else(|| panic!("unterminated `{PREFIX}` in font CSS"));
        let file = &after[..end];
        let path = fonts.join(file);
        println!("cargo:rerun-if-changed={}", path.display());
        let bytes =
            fs::read(&path).unwrap_or_else(|e| panic!("cannot read font {}: {e}", path.display()));
        let mime = match Path::new(file).extension().and_then(|e| e.to_str()) {
            Some("woff2") => "font/woff2",
            Some("woff") => "font/woff",
            Some("otf") => "font/otf",
            Some("ttf") => "font/ttf",
            _ => "application/octet-stream",
        };
        let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
        out.push_str("url(data:");
        out.push_str(mime);
        out.push_str(";base64,");
        out.push_str(&b64);
        out.push(')');
        rest = &after[end + 1..];
    }
    out.push_str(rest);
    out
}

fn main() {
    let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    embed_fonts(&crate_dir);
    embed_favicon(&crate_dir);

    #[cfg(target_os = "windows")]
    {
        let mut res = winres::WindowsResource::new();
        res.set_icon("assets/app.ico");
        res.compile().expect("failed to compile Windows resources");
    }

    println!("cargo:rerun-if-env-changed=WRY_ANDROID_KOTLIN_FILES_OUT_DIR");

    // Only do anything android-specific on Android builds; on desktop the env var
    // is absent and we return after the Windows resource step above.
    let out_dir = match std::env::var("WRY_ANDROID_KOTLIN_FILES_OUT_DIR") {
        Ok(d) => PathBuf::from(d),
        Err(_) => return,
    };

    // android-src lives at the repo root (crates/kopuz/../../android-src).
    let android_src = crate_dir.join("..").join("..").join("android-src");
    // Re-run when files are added/removed under android-src. Note: a directory's mtime
    // does NOT change when an existing file inside it is edited, so this alone misses
    // edits — copy_file/copy_kt_dir additionally emit rerun-if-changed per source file.
    println!("cargo:rerun-if-changed={}", android_src.display());

    // out_dir = <project>/app/src/main/kotlin/dev/dioxus/main
    //   ancestors: [.../dev/dioxus/main, .../dev/dioxus, .../dev, .../kotlin]
    let kotlin_root = match out_dir.ancestors().nth(3) {
        Some(p) => p.to_path_buf(),
        None => {
            warn(&format!(
                "unexpected WRY kotlin out dir: {}",
                out_dir.display()
            ));
            return;
        }
    };
    let main_dir = match kotlin_root.parent() {
        Some(p) => p.to_path_buf(),
        None => return,
    };
    let manifest = main_dir.join("AndroidManifest.xml");

    // Older (manual) builds copied the helper classes under the java/ source root.
    // If those linger alongside our kotlin/ copies, both source sets compile the same
    // classes and Kotlin fails with "Redeclaration". Remove the stale java/ copies.
    let stale_java = main_dir.join("java/com/temidaradev/kopuz");
    if stale_java.exists() {
        let _ = fs::remove_dir_all(&stale_java);
    }

    // 1. Copy the media helper classes (package com.temidaradev.kopuz). Clear the dest
    //    first so a renamed/removed source class can't leave an orphan .kt behind.
    let helper_src = android_src.join("java/com/temidaradev/kopuz");
    let helper_dst = kotlin_root.join("com/temidaradev/kopuz");
    let _ = fs::remove_dir_all(&helper_dst);
    copy_kt_dir(&helper_src, &helper_dst);

    // 2. Override the generated MainActivity (package dev.dioxus.main) so it wires
    //    up MediaSessionHelper and requests the notification permission at startup.
    let main_activity_src = android_src.join("kotlin/dev/dioxus/main/MainActivity.kt");
    let main_activity_dst = out_dir.join("MainActivity.kt");
    copy_file(&main_activity_src, &main_activity_dst);

    // 3. Patch the freshly-generated manifest with permissions + service + receiver.
    //    dx rewrites this file every build, so re-run whenever it changes.
    if manifest.exists() {
        println!("cargo:rerun-if-changed={}", manifest.display());
        patch_manifest(&manifest);
    } else {
        warn(&format!(
            "AndroidManifest.xml not found at {}",
            manifest.display()
        ));
    }

    // 4. Drop R8 keep rules so release (`dx bundle`) minification doesn't strip the
    //    JNI-reached Kotlin. The release build.gradle globs **/*.pro under the module.
    copy_str(&main_dir.join("kopuz-keep.pro"), KEEP_RULES);

    // 5. Generate launcher icons from our logo. dx only ships its default icon, so
    //    build them here.
    generate_launcher_icons(&main_dir.join("res"));
}

/// Resize kopuz/assets/logo.png into the Android launcher mipmaps, replacing dx's
/// default icon. Writes raster PNGs and removes dx's webp + the adaptive-icon XML so the
/// logo is used directly (avoids "duplicate resources" and adaptive overriding the raster).
fn generate_launcher_icons(res: &Path) {
    if !res.is_dir() {
        return;
    }
    let logo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/logo.png");
    println!("cargo:rerun-if-changed={}", logo.display());
    let img = match image::open(&logo) {
        Ok(i) => i,
        Err(e) => {
            warn(&format!("cannot open logo {}: {e}", logo.display()));
            return;
        }
    };

    // (density dir suffix, px size)
    let densities = [
        ("mdpi", 48u32),
        ("hdpi", 72),
        ("xhdpi", 96),
        ("xxhdpi", 144),
        ("xxxhdpi", 192),
    ];
    for (suffix, size) in densities {
        let dir = res.join(format!("mipmap-{suffix}"));
        if !dir.is_dir() {
            continue;
        }
        // Remove dx's webp variants so they don't collide with our PNG.
        let _ = fs::remove_file(dir.join("ic_launcher.webp"));
        let _ = fs::remove_file(dir.join("ic_launcher_round.webp"));
        let resized = img.resize_exact(size, size, image::imageops::FilterType::Lanczos3);
        if let Err(e) = resized.save(dir.join("ic_launcher.png")) {
            warn(&format!("cannot write {} icon: {e}", suffix));
        }
    }

    // Drop the adaptive-icon descriptors so API 26+ uses our raster icon directly
    // instead of dx's default vector foreground/background.
    let anydpi = res.join("mipmap-anydpi-v26");
    let _ = fs::remove_file(anydpi.join("ic_launcher.xml"));
    let _ = fs::remove_file(anydpi.join("ic_launcher_round.xml"));
}

fn copy_str(dst: &Path, contents: &str) {
    if let Some(parent) = dst.parent() {
        let _ = fs::create_dir_all(parent);
    }
    if let Err(e) = fs::write(dst, contents) {
        warn(&format!("cannot write {}: {e}", dst.display()));
    }
}

fn copy_kt_dir(src: &Path, dst: &Path) {
    let entries = match fs::read_dir(src) {
        Ok(e) => e,
        Err(e) => {
            warn(&format!("cannot read {}: {e}", src.display()));
            return;
        }
    };
    if let Err(e) = fs::create_dir_all(dst) {
        warn(&format!("cannot create {}: {e}", dst.display()));
        return;
    }
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) == Some("kt")
            && let Some(name) = path.file_name()
        {
            copy_file(&path, &dst.join(name));
        }
    }
}

fn copy_file(src: &Path, dst: &Path) {
    // Per-file so editing this exact source re-triggers the build script.
    println!("cargo:rerun-if-changed={}", src.display());
    if let Some(parent) = dst.parent() {
        let _ = fs::create_dir_all(parent);
    }
    if let Err(e) = fs::copy(src, dst) {
        warn(&format!(
            "cannot copy {} -> {}: {e}",
            src.display(),
            dst.display()
        ));
    }
}

/// Mirror of android-src/patch_manifest.py's manifest logic, in Rust so it runs as
/// part of the cargo build. Idempotent: each insertion is guarded by a presence check.
fn patch_manifest(path: &Path) {
    let mut content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            warn(&format!("cannot read {}: {e}", path.display()));
            return;
        }
    };
    let original = content.clone();

    for perm in PERMISSIONS {
        let marker = format!("android:name=\"{perm}\"");
        if !content.contains(&marker) {
            content = content.replacen(
                "    <application ",
                &format!("    <uses-permission android:name=\"{perm}\" />\n    <application "),
                1,
            );
        }
    }

    if !content.contains("android:requestLegacyExternalStorage=\"true\"") {
        content = content.replacen(
            "    <application ",
            "    <application android:requestLegacyExternalStorage=\"true\" ",
            1,
        );
    }

    // singleTask: the foreground service + wake lock keep our process alive in the
    // background, so relaunching from the launcher would otherwise spin up a *second*
    // MainActivity in the live process and call WryActivity_create twice — which tao
    // can't survive and aborts with SIGABRT. singleTask reuses the existing instance
    // (onNewIntent instead of a fresh onCreate) so native init only ever runs once.
    if !content.contains("android:launchMode=") {
        content = content.replacen(
            "<activity ",
            "<activity android:launchMode=\"singleTask\" ",
            1,
        );
    }

    if !content.contains("MusicService") {
        content = content.replacen(
            "    </application>",
            &format!("{INSIDE_APPLICATION}    </application>"),
            1,
        );
    }

    if content != original
        && let Err(e) = fs::write(path, &content)
    {
        warn(&format!("cannot write {}: {e}", path.display()));
    }
}