cloudfox-coreshift-core 2.32.0

Low-level Linux and Android systems primitives for CoreShift (CloudFox)
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! One-shot Android system probes for the deep-sleep-on-inactivity watcher
//! (utensil): single DEX-resolved transactions at the decision moment, never a
//! poll loop. The audio probe is the reliable screen-on activity gate; the
//! FGS-process probe was dropped by design decision (2026-08-19) — process
//! importance is not a reliable activity signal.

use crate::CoreError;

/// `AudioPlaybackConfiguration.PLAYER_STATE_STARTED` — the only player state
/// that counts as active (`isActive()` = started && not muted by
/// client-volume/volume-shaper/app-ops). `PLAYER_STATE_IDLE = 1` is inactive.
pub(crate) const PLAYER_STATE_STARTED: i32 = 2;

/// `AudioAttributes.ATTR_PARCEL_IS_NULL_BUNDLE` — the bundle-slot marker
/// written when no attributes `Bundle` is attached. A valid bundle would be
/// marked `ATTR_PARCEL_IS_VALID_BUNDLE = 1980` and carry a payload the walk
/// cannot skip; the audio service never attaches one to playback configs.
const ATTR_PARCEL_IS_NULL_BUNDLE: i32 = -1977;

/// Per-config leading prefix: 7 ints — piid, deviceId, mutedState,
/// playerType, clientUid, clientPid, playerState.
const CONFIG_LEAD: usize = 7;

/// `AudioAttributes` fixed prefix before the tag array: usage, contentType,
/// source, flags, parcelFlags.
const ATTR_FIXED: usize = 5;

/// Probe whether any audio playback is currently active on the device.
///
/// Opens the `audio` service and transacts `getActivePlaybackConfigurations`
/// (DEX-resolved; never hardcoded). The reply is a bare typed list:
/// `writeInt(N)` then N configs, each a `writeTypedObject` (`writeInt(1)`
/// marker + `AudioPlaybackConfiguration.writeToParcel` body). Each body is
/// fully walked: 7 leading ints (piid, deviceId, mutedState, playerType,
/// clientUid, clientPid, playerState), the `AudioAttributes` (fixed prefix +
/// tag array + bundle marker), the `IPlayer` strong binder, sessionId, and the
/// `FormatInfo` tail. A config with `playerState == PLAYER_STATE_STARTED`
/// means audio is active.
///
/// This is the **event-driven audio gate** for the utensil watcher: one
/// transaction at each backoff step answers "is music/cast/VoIP playing" —
/// the activity the mailbox-stability probe cannot see. Non-privileged callers
/// (shell/root daemon) receive only `isActive()` configs, so a non-zero active
/// count here is authoritative.
#[cfg(target_os = "android")]
pub fn audio_has_active_playback() -> Result<bool, CoreError> {
    use super::raw::RawBinderService;
    use super::sys::ParcelReader;
    use crate::android::dex::resolve_active_playback_configurations_tx;

    let svc = RawBinderService::open("audio", "android.media.IAudioService")?;
    let tx = resolve_active_playback_configurations_tx()
        .ok_or_else(|| CoreError::binder(-1, "dex:TRANSACTION_getActivePlaybackConfigurations"))?;
    let out = svc.raw_noarg(tx)?;
    let mut r = ParcelReader::owned(svc.vtable(), &out);
    decode_audio_active(&mut r)
}

/// The wire-field reader the audio probe decode needs: a leading exception
/// i32 then a bare typed list of configs. The serving path reads a framework
/// `ParcelReader`; host tests drive a fake over an in-memory i32 buffer, so
/// the decode path is exercised without a live `AParcel`.
pub(crate) trait ProbeRead {
    fn read_i32(&mut self) -> Result<i32, ()>;
    /// Skip one String16 (Java `writeString`) payload — the `AudioAttributes`
    /// tag-array elements.
    fn skip_string(&mut self) -> Result<(), ()>;
    /// Skip one strong-binder slot — the `IPlayer` field of each config.
    fn skip_binder(&mut self) -> Result<(), ()>;
}

#[cfg(target_os = "android")]
impl<'a> ProbeRead for super::sys::ParcelReader<'a> {
    fn read_i32(&mut self) -> Result<i32, ()> {
        // Disambiguate from the trait method: the inherent `read_i32` on
        // `ParcelReader` returns `Result<i32, CoreError>`.
        super::sys::ParcelReader::read_i32(self).map_err(|_| ())
    }
    fn skip_string(&mut self) -> Result<(), ()> {
        // Tags are String16 (`Parcel::writeString16`: writeInt32(len) then
        // (len+1)*2 UTF-16 bytes padded to 4). `skip_string16` never touches
        // the decoded value, so it is immune to the UTF-16→UTF-8 allocation
        // path.
        super::sys::ParcelReader::skip_string16(self)
            .map(|_| ())
            .map_err(|_| ())
    }
    fn skip_binder(&mut self) -> Result<(), ()> {
        // The config's IPlayer strong binder: read and drop it, releasing the
        // strong reference via `AIBinder_decStrong` on drop.
        super::sys::ParcelReader::read_strong_binder(self)
            .map(|_| ())
            .map_err(|_| ())
    }
}

/// Decode the `getActivePlaybackConfigurations` reply body: exception header,
/// `writeInt(N)` config count, then per config the full
/// `AudioPlaybackConfiguration.writeToParcel` body — a `writeTypedObject`
/// marker, 7 leading ints (piid, deviceId, mutedState, playerType, clientUid,
/// clientPid, playerState), the `AudioAttributes` (fixed prefix, tag array,
/// null-bundle marker), the `IPlayer` strong binder, sessionId, and the
/// `FormatInfo` tail (isSpatialized, channelMask, sampleRate).
/// `true` when any config has `playerState == PLAYER_STATE_STARTED` — such a
/// config is returned immediately without walking its tail.
pub(crate) fn decode_audio_active(r: &mut impl ProbeRead) -> Result<bool, CoreError> {
    let ex = r
        .read_i32()
        .map_err(|_| CoreError::binder(-1, "audio:exception"))?;
    if ex != crate::binder::wire::EX_NONE {
        return Err(CoreError::binder(ex, "audio_active:exception"));
    }
    let count = r
        .read_i32()
        .map_err(|_| CoreError::binder(-1, "audio:count"))?;
    if count < 0 {
        return Err(CoreError::binder(-1, "audio_active:negative_count"));
    }
    for _ in 0..count {
        // writeTypedList element: writeTypedObject writes `writeInt(1)` for a
        // non-null object. The service never writes null configs.
        let marker = r
            .read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:marker"))?;
        if marker != 1 {
            return Err(CoreError::binder(-1, "audio_active:null_config"));
        }
        // Skip the 6 leading fields (piid, deviceId, mutedState, playerType,
        // clientUid, clientPid), read playerState (field 7).
        for _ in 0..CONFIG_LEAD - 1 {
            r.read_i32()
                .map_err(|_| CoreError::binder(-1, "audio:field"))?;
        }
        let state = r
            .read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:state"))?;
        if state == PLAYER_STATE_STARTED {
            return Ok(true);
        }
        // AudioAttributes fixed prefix: usage, contentType, source, flags,
        // parcelFlags.
        for _ in 0..ATTR_FIXED {
            r.read_i32()
                .map_err(|_| CoreError::binder(-1, "audio:attr"))?;
        }
        // The tag string array: `writeInt(N)` then N String16 payloads.
        let tags = r
            .read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:tags"))?;
        if tags < 0 {
            return Err(CoreError::binder(-1, "audio_active:bad_tags"));
        }
        for _ in 0..tags {
            r.skip_string()
                .map_err(|_| CoreError::binder(-1, "audio:tag"))?;
        }
        // Bundle slot: the null marker (-1977). A valid bundle (1980) would
        // carry a payload the walk cannot skip; the service never attaches
        // one to a config.
        let bundle = r
            .read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:bundle"))?;
        if bundle != ATTR_PARCEL_IS_NULL_BUNDLE {
            return Err(CoreError::binder(-1, "audio_active:unexpected_bundle"));
        }
        // IPlayer strong binder.
        r.skip_binder()
            .map_err(|_| CoreError::binder(-1, "audio:binder"))?;
        // sessionId, then FormatInfo: isSpatialized, channelMask, sampleRate.
        r.read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:session"))?;
        r.read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:spatialized"))?;
        r.read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:channel"))?;
        r.read_i32()
            .map_err(|_| CoreError::binder(-1, "audio:rate"))?;
    }
    Ok(false)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// One config body (marker + 7 leading ints + AudioAttributes + binder +
    /// sessionId + FormatInfo), matching the Android 14 / API 34 wire layout
    /// verified against `service call audio 122` (26 words with no tags).
    const BINDER_WORDS: usize = 7;

    /// In-memory i32 buffer reader (mirrors wire.rs ByteCursor / display_tail.rs
    /// TailCursor): a cursor over little-endian i32 words, Err on overrun.
    struct I32Cursor {
        words: Vec<i32>,
        pos: usize,
    }

    impl I32Cursor {
        fn from_words(words: Vec<i32>) -> Self {
            Self { words, pos: 0 }
        }
    }

    impl ProbeRead for I32Cursor {
        fn read_i32(&mut self) -> Result<i32, ()> {
            let v = self.words.get(self.pos).copied().ok_or(())?;
            self.pos += 1;
            Ok(v)
        }
        fn skip_string(&mut self) -> Result<(), ()> {
            // Java writeString: `writeInt32(len)` then (len+1)*2 UTF-16 bytes
            // padded to 4 (AOSP Parcel::writeString16, null-terminated).
            let len = self.read_i32()?;
            if len < 0 {
                return Ok(());
            }
            let words = (len as usize)
                .saturating_add(1)
                .saturating_mul(2)
                .div_ceil(4);
            self.pos += words;
            if self.pos > self.words.len() {
                return Err(());
            }
            Ok(())
        }
        fn skip_binder(&mut self) -> Result<(), ()> {
            // flat_binder_object footprint on this device (Android 14 arm64):
            // empirically 7 words (28 bytes).
            self.pos += BINDER_WORDS;
            if self.pos > self.words.len() {
                return Err(());
            }
            Ok(())
        }
    }

    /// One config body: marker, 7 leading ints, AudioAttributes (no tags,
    /// null bundle), IPlayer binder, sessionId, FormatInfo.
    fn cfg(
        piid: i32,
        device_id: i32,
        player_type: i32,
        uid: i32,
        pid: i32,
        state: i32,
        usage: i32,
        content_type: i32,
    ) -> Vec<i32> {
        let mut w = vec![1]; // writeTypedObject non-null marker
        w.extend_from_slice(&[piid, device_id, 0, player_type, uid, pid, state]);
        // AudioAttributes: usage, contentType, source=-1, flags=0,
        // parcelFlags=0, tagsCount=0, bundleMarker=-1977 (null bundle).
        w.extend_from_slice(&[usage, content_type, -1, 0, 0, 0, ATTR_PARCEL_IS_NULL_BUNDLE]);
        w.extend(vec![0; BINDER_WORDS]); // IPlayer strong binder
        w.push(0); // sessionId
        // FormatInfo: isSpatialized=false, channelMask=2, sampleRate=48000.
        w.extend_from_slice(&[0, 2, 48_000]);
        w
    }

    /// A reply body: exception header + config count + config bodies.
    fn reply(configs: Vec<Vec<i32>>) -> Vec<i32> {
        let mut w = vec![0, configs.len() as i32];
        for c in configs {
            w.extend(c);
        }
        w
    }

    #[test]
    fn active_config_detected() {
        // One config with playerState STARTED (field 7). The early return
        // happens right after reading playerState, before the tail walk.
        let words = reply(vec![cfg(
            103,
            0,
            3,
            1000,
            1644,
            PLAYER_STATE_STARTED,
            13,
            4,
        )]);
        let mut c = I32Cursor::from_words(words);
        assert!(decode_audio_active(&mut c).unwrap());
        assert_eq!(c.pos, 2 + 1 + 7);
    }

    #[test]
    fn idle_configs_are_not_active() {
        // Two configs, both playerState IDLE (=1): not active. Both bodies
        // are fully walked (2 + 2 * 26 words).
        let words = reply(vec![
            cfg(103, 0, 3, 1000, 1644, 1, 13, 4),
            cfg(111, 0, 3, 10145, 2831, 1, 13, 4),
        ]);
        let mut c = I32Cursor::from_words(words);
        assert!(!decode_audio_active(&mut c).unwrap());
        assert_eq!(c.pos, 2 + 2 * 26);
    }

    #[test]
    fn zero_configs_is_not_active() {
        let mut c = I32Cursor::from_words(vec![0, 0]);
        assert!(!decode_audio_active(&mut c).unwrap());
    }

    #[test]
    fn exception_reply_is_an_error() {
        let mut c = I32Cursor::from_words(vec![1, 0]);
        assert!(decode_audio_active(&mut c).is_err());
    }

    #[test]
    fn truncated_reply_is_an_error() {
        // EX_NONE + count=1 but no config fields.
        let mut c = I32Cursor::from_words(vec![0, 1]);
        assert!(decode_audio_active(&mut c).is_err());
    }

    #[test]
    fn negative_count_is_an_error() {
        let mut c = I32Cursor::from_words(vec![0, -3]);
        assert!(decode_audio_active(&mut c).is_err());
    }

    #[test]
    fn null_config_marker_is_an_error() {
        // writeTypedObject with the null marker (0) instead of 1.
        let mut c = I32Cursor::from_words(vec![0, 1, 0]);
        assert!(decode_audio_active(&mut c).is_err());
    }

    #[test]
    fn music_playing_config_is_active() {
        // The exact device shape observed with music playing: two system
        // IDLE players (uid 1000/10145), then a STARTED media player
        // (uid 10195, playerType 1, state 2, sampleRate 48000).
        let words = reply(vec![
            cfg(103, 0, 3, 1000, 1644, 1, 13, 4),
            cfg(111, 0, 3, 10145, 2831, 1, 13, 4),
            cfg(17727, 3, 1, 10195, 27644, PLAYER_STATE_STARTED, 1, 2),
        ]);
        let mut c = I32Cursor::from_words(words);
        assert!(decode_audio_active(&mut c).unwrap());
        // Configs 1-2 fully walked, config 3 early-returns after its lead.
        assert_eq!(c.pos, 2 + 2 * 26 + 1 + 7);
    }

    #[test]
    fn idle_config_with_tags_is_skipped() {
        // A config whose AudioAttributes carries a tag array (String16) must
        // be fully walked to reach the next config. "media" (4 UTF-16 chars)
        // = len word + (4*2+2)=10 payload bytes → 4 words total.
        let mut words = vec![0, 2]; // EX_NONE, count=2
        let mut c1 = vec![1];
        c1.extend_from_slice(&[103, 0, 0, 3, 1000, 1644, 1]); // lead, IDLE
        c1.extend_from_slice(&[13, 4, -1, 0, 0]); // attrs fixed
        c1.push(1); // tagsCount
        // "media" (4 UTF-16 chars) = len word + (4*2+2)=10 bytes → 3 payload
        // words = 4 words total.
        c1.extend_from_slice(&[4, 0, 0, 0]);
        c1.push(ATTR_PARCEL_IS_NULL_BUNDLE);
        c1.extend(vec![0; BINDER_WORDS]);
        c1.push(0); // sessionId
        c1.extend_from_slice(&[0, 2, 48_000]);
        words.extend(c1);
        words.extend(cfg(17727, 3, 1, 10195, 27644, PLAYER_STATE_STARTED, 1, 2));
        let mut c = I32Cursor::from_words(words);
        assert!(decode_audio_active(&mut c).unwrap());
        // Config 1 is 30 words (26 + tag words), config 2 early-returns.
        assert_eq!(c.pos, 2 + 30 + 1 + 7);
    }
}