cloudfox-coreshift-core 2.33.0

Low-level Linux and Android systems primitives for CoreShift (CloudFox)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
// 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)
}

/// Decode the `IPlaybackConfigDispatcher.dispatchPlaybackConfigChange` request
/// body: the leading typed-list count (Java `writeTypedList` → `writeInt(N)`).
///
/// This is the **live audio event source** for the utensil watcher: the audio
/// service calls this method on our dispatcher binder whenever playback
/// configuration changes, and the boolean decides whether audio became (or
/// ceased being) active since the last event.
///
/// The list delivered to non-privileged listeners is the **full set of
/// currently-active configs** — AOSP `PlaybackActivityMonitor.
/// anonymizeForPublicConsumption` filters `mPlayers.values()` down to
/// `isActive()` configs (STARTED and not muted) and dispatches the whole
/// filtered list on every change (never a diff). So `count > 0` ⟺ at least
/// one unmuted STARTED player ⟺ audio is playing; the config bodies are
/// deliberately never walked.
pub(crate) fn decode_playback_dispatch(r: &mut impl ProbeRead) -> Result<bool, CoreError> {
    let count = r
        .read_i32()
        .map_err(|_| CoreError::binder(-1, "playback:count"))?;
    // Empty (0) or null (-1) list: nothing active.
    Ok(count > 0)
}

#[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);
    }

    // ── dispatchPlaybackConfigChange decode (the live audio event source) ──

    #[test]
    fn dispatch_empty_list_is_not_active() {
        // No active configs delivered: the callback's list is empty.
        let mut c = I32Cursor::from_words(vec![0]);
        assert!(!decode_playback_dispatch(&mut c).unwrap());
        assert_eq!(c.pos, 1);
    }

    #[test]
    fn dispatch_null_list_is_not_active() {
        // A null list would carry a -1 count marker (writeInt(-1)).
        let mut c = I32Cursor::from_words(vec![-1]);
        assert!(!decode_playback_dispatch(&mut c).unwrap());
        assert_eq!(c.pos, 1);
    }

    #[test]
    fn dispatch_one_active_config_is_active() {
        // The non-privileged list holds only isActive() configs, so a count of
        // 1 (whatever the anonymized body carries) means audio is playing.
        let words =
            [1].into_iter()
                .chain(cfg(17727, 3, 1, 10195, 27644, PLAYER_STATE_STARTED, 1, 2));
        let mut c = I32Cursor::from_words(words.collect());
        assert!(decode_playback_dispatch(&mut c).unwrap());
        // Only the count word is consumed — bodies are never walked.
        assert_eq!(c.pos, 1);
    }

    #[test]
    fn dispatch_any_count_above_zero_is_active() {
        // Two active players (the music + another app): still active.
        let mut c = I32Cursor::from_words(vec![2]);
        assert!(decode_playback_dispatch(&mut c).unwrap());
        assert_eq!(c.pos, 1);
    }

    #[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);
    }
}