diff --git a/samples/powerplay/QuickStart_Automation.md b/samples/powerplay/QuickStart_Automation.md
new file mode 100644
index 000000000..ce3532c10
--- /dev/null
+++ b/samples/powerplay/QuickStart_Automation.md
@@ -0,0 +1,160 @@
+# PowerPlay Automation Guide
+
+This guide explains how to control the PowerPlay audio player via ADB commands for automated testing of power-efficient audio features (PCM Offload).
+
+## Quick Start
+
+```bash
+# Build and install
+cd samples/powerplay
+../../gradlew installDebug
+
+# Play with PCM Offload enabled
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play \
+ --es perf_mode offload
+
+# Monitor status
+adb logcat -s PowerPlay:V
+```
+
+## Supported ADB Parameters
+
+| Parameter | Type | Description | Example |
+| -------------------- | ------- | ---------------------------------------------------------- | ------------------------------ |
+| `command` | string | Action: `play`, `pause`, `stop` | `--es command play` |
+| `perf_mode` | string | Performance mode: `none`, `lowlat`, `powersave`, `offload` | `--es perf_mode offload` |
+| `song_index` | int | Track index (0-2) | `--ei song_index 1` |
+| `volume` | int | Volume percentage (0-100) | `--ei volume 50` |
+| `background` | boolean | Move app to background after starting | `--ez background true` |
+| `duration_ms` | int | Auto-stop after N milliseconds | `--ei duration_ms 10000` |
+| `use_mmap` | boolean | Enable/disable MMAP audio path | `--ez use_mmap false` |
+| `buffer_frames` | int | Buffer size in frames (offload only) | `--ei buffer_frames 4096` |
+| `toggle_offload` | boolean | Enable offload stress test | `--ez toggle_offload true` |
+| `toggle_interval_ms` | int | Toggle interval in ms | `--ei toggle_interval_ms 5000` |
+
+## Common Test Scenarios
+
+### Test PCM Offload Mode
+```bash
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play \
+ --es perf_mode offload
+```
+
+### Background Playback Test
+```bash
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play \
+ --es perf_mode offload \
+ --ez background true
+```
+
+### Timed Playback (10 seconds)
+```bash
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play \
+ --es perf_mode offload \
+ --ei duration_ms 10000
+```
+
+### Offload Toggle Stress Test
+```bash
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play \
+ --es perf_mode offload \
+ --ez toggle_offload true \
+ --ei toggle_interval_ms 3000
+```
+
+### Compare Performance Modes
+```bash
+# Low Latency
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play --es perf_mode lowlat
+
+# Power Saving (non-offload)
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play --es perf_mode powersave
+
+# PCM Offload
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play --es perf_mode offload
+```
+
+### Stop Playback
+```bash
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command stop
+```
+
+## Machine-Readable Status Logs
+
+The app outputs status to logcat with the `PowerPlay` tag in a parseable format:
+
+```
+POWERPLAY_STATUS: PLAYING | SONG=0 | OFFLOAD=true | MMAP=true
+POWERPLAY_STATUS: PAUSED
+POWERPLAY_STATUS: STOPPED | REASON=AUTO_STOP
+```
+
+### Status Values
+| Status | Meaning |
+| ----------------- | ------------------------------------- |
+| `PLAYING` | Audio playback is active |
+| `PAUSED` | Audio playback is paused |
+| `STOPPED` | Audio playback has stopped |
+| `ERROR` | An error occurred |
+| `OFFLOAD_REVOKED` | PCM Offload was revoked by the system |
+
+### Log Monitoring
+```bash
+# Real-time monitoring
+adb logcat -s PowerPlay:V
+
+# Filter for status only
+adb logcat -s PowerPlay:V | grep "POWERPLAY_STATUS"
+
+# Save to file
+adb logcat -s PowerPlay:V > powerplay_test.log
+```
+
+## Using the Test Action
+
+For automation scripts, you can also use the dedicated test action:
+
+```bash
+adb shell am start -a com.google.oboe.samples.powerplay.TEST \
+ --es command play \
+ --es perf_mode offload
+```
+
+## Battery Testing
+
+To measure power consumption with offload vs non-offload:
+
+```bash
+# Reset battery stats
+adb shell dumpsys batterystats --reset
+
+# Run offload test for 5 minutes
+adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ --es command play \
+ --es perf_mode offload \
+ --ez background true \
+ --ei duration_ms 300000
+
+# Wait for test to complete, then capture stats
+adb shell dumpsys batterystats > battery_offload.txt
+```
+
+## Troubleshooting
+
+### Offload Not Activating
+- Ensure device supports PCM Offload: check `AudioManager.isOffloadedPlaybackSupported()`
+- Some devices only support offload with specific sample rates (usually 48kHz)
+- Display must be off for some implementations
+
+### App Doesn't Respond to Commands
+- Make sure the app is installed: `adb shell pm list packages | grep powerplay`
+- Check if activity is exported: `adb shell dumpsys package com.google.oboe.samples.powerplay`
diff --git a/samples/powerplay/scripts/test_powerplay.sh b/samples/powerplay/scripts/test_powerplay.sh
new file mode 100755
index 000000000..bf1b8516f
--- /dev/null
+++ b/samples/powerplay/scripts/test_powerplay.sh
@@ -0,0 +1,88 @@
+#!/bin/bash
+# PowerPlay Automation Test Script
+# Demonstrates a full test cycle: Open -> Play Offload -> Wait -> Close
+
+set -e
+
+PACKAGE="com.google.oboe.samples.powerplay"
+ACTIVITY="${PACKAGE}/.MainActivity"
+LOG_TAG="PowerPlay"
+TEST_DURATION_MS=10000 # 10 seconds
+
+echo "=== PowerPlay Automation Test ==="
+echo ""
+
+# Check device connection
+if ! adb devices | grep -q "device$"; then
+ echo "ERROR: No Android device connected"
+ exit 1
+fi
+
+echo "[1/6] Checking if PowerPlay is installed..."
+if ! adb shell pm list packages | grep -q "$PACKAGE"; then
+ echo "ERROR: PowerPlay is not installed. Please run:"
+ echo " cd samples/powerplay && ../../gradlew installDebug"
+ exit 1
+fi
+echo " ✓ PowerPlay is installed"
+
+echo ""
+echo "[2/6] Force-stopping any existing instance..."
+adb shell am force-stop "$PACKAGE"
+sleep 1
+echo " ✓ App stopped"
+
+echo ""
+echo "[3/6] Starting playback with PCM Offload mode..."
+adb shell am start -n "$ACTIVITY" \
+ --es command play \
+ --es perf_mode offload \
+ --ei duration_ms "$TEST_DURATION_MS" \
+ --ei volume 75
+
+echo " ✓ Play command sent"
+
+echo ""
+echo "[4/6] Monitoring status (waiting for ${TEST_DURATION_MS}ms)..."
+echo "---"
+
+# Capture and display logs for the test duration
+timeout $((TEST_DURATION_MS / 1000 + 2)) adb logcat -s "$LOG_TAG:V" 2>/dev/null | while read -r line; do
+ if echo "$line" | grep -q "POWERPLAY_STATUS"; then
+ echo " LOG: $line"
+ fi
+done || true
+
+echo "---"
+echo " ✓ Test duration completed"
+
+echo ""
+echo "[5/6] Verifying playback stopped..."
+FINAL_STATUS=$(adb logcat -d -s "$LOG_TAG:V" | grep "POWERPLAY_STATUS" | tail -1)
+if echo "$FINAL_STATUS" | grep -q "STOPPED"; then
+ echo " ✓ Playback stopped correctly"
+else
+ echo " ⚠ Last status: $FINAL_STATUS"
+fi
+
+echo ""
+echo "[6/6] Checking offload status from logs..."
+if adb logcat -d -s "$LOG_TAG:V" | grep -q "OFFLOAD=true"; then
+ echo " ✓ PCM Offload was active during playback"
+else
+ echo " ⚠ PCM Offload may not have been active (device might not support it)"
+fi
+
+echo ""
+echo "=== Test Complete ==="
+echo ""
+echo "Full logs saved to: powerplay_test.log"
+adb logcat -d -s "$LOG_TAG:V" > powerplay_test.log
+
+echo ""
+echo "To run additional tests:"
+echo " # Toggle offload stress test"
+echo " adb shell am start -n $ACTIVITY --es command play --es perf_mode offload --ez toggle_offload true"
+echo ""
+echo " # Background playback"
+echo " adb shell am start -n $ACTIVITY --es command play --es perf_mode offload --ez background true"
diff --git a/samples/powerplay/src/main/AndroidManifest.xml b/samples/powerplay/src/main/AndroidManifest.xml
index 9f629864d..52d6cb64f 100644
--- a/samples/powerplay/src/main/AndroidManifest.xml
+++ b/samples/powerplay/src/main/AndroidManifest.xml
@@ -14,12 +14,19 @@
+
+
+
+
+
+
diff --git a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
index 8139d50c9..f02cd6338 100644
--- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
+++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
@@ -275,6 +275,47 @@ Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getBufferCapa
return player.getBufferCapacityInFrames();
}
+/**
+ * Native (JNI) implementation of PowerPlayAudioPlayer.setVolumeNative()
+ */
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_setVolumeNative(
+ JNIEnv *env,
+ jobject,
+ jfloat volume) {
+ int32_t currentIndex = player.getCurrentlyPlayingIndex();
+ if (currentIndex >= 0) {
+ player.setGain(currentIndex, volume);
+ return;
+ }
+
+ // Set gain for all tracks if nothing is playing.
+ for (int i = 0; i < 3; ++i) {
+ player.setGain(i, volume);
+ }
+}
+
+/**
+ * Native (JNI) implementation of PowerPlayAudioPlayer.isOffloadedNative()
+ */
+JNIEXPORT jboolean JNICALL
+Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_isOffloadedNative(
+ JNIEnv *env,
+ jobject) {
+ return player.isOffloaded();
+}
+
+/**
+ * Native (JNI) implementation of PowerPlayAudioPlayer.getCurrentlyPlayingIndexNative()
+ */
+JNIEXPORT jint JNICALL
+Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_getCurrentlyPlayingIndexNative(
+ JNIEnv *env,
+ jobject) {
+ return player.getCurrentlyPlayingIndex();
+}
+
#ifdef __cplusplus
}
#endif
+
diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
index 3cb50fa23..64f13643f 100644
--- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
+++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
@@ -239,3 +239,11 @@ int32_t PowerPlayMultiPlayer::getBufferCapacityInFrames() {
return mAudioStream->getBufferCapacityInFrames();
}
+
+bool PowerPlayMultiPlayer::isOffloaded() {
+ if (mAudioStream == nullptr) {
+ return false;
+ }
+
+ return mAudioStream->getPerformanceMode() == PerformanceMode::PowerSavingOffloaded;
+}
diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
index 319f71442..3b6cd3b46 100644
--- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
+++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
@@ -50,6 +50,8 @@ class PowerPlayMultiPlayer : public iolib::SimpleMultiPlayer {
int32_t getBufferCapacityInFrames();
+ bool isOffloaded();
+
private:
class MyPresentationCallback : public oboe::AudioStreamPresentationCallback {
public:
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt
index ce42f9dc2..31f74b8b2 100644
--- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt
@@ -100,7 +100,12 @@ import com.google.oboe.samples.powerplay.engine.OboePerformanceMode
import com.google.oboe.samples.powerplay.engine.PlayerState
import com.google.oboe.samples.powerplay.engine.PowerPlayAudioPlayer
import com.google.oboe.samples.powerplay.ui.theme.MusicPlayerTheme
+import com.google.oboe.samples.powerplay.automation.IntentBasedTestSupport
+import com.google.oboe.samples.powerplay.automation.IntentBasedTestSupport.LOG_TAG
import kotlinx.coroutines.flow.distinctUntilChanged
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
class MainActivity : ComponentActivity() {
@@ -108,7 +113,23 @@ class MainActivity : ComponentActivity() {
private lateinit var serviceIntent: Intent
private var isMMapSupported: Boolean = false
private var isOffloadSupported: Boolean = false
- private var sampleRate: Int = 48000;
+ private var sampleRate: Int = 48000
+
+ // Automation state
+ private val handler = Handler(Looper.getMainLooper())
+ private var autoStopRunnable: Runnable? = null
+ private var toggleOffloadRunnable: Runnable? = null
+ private var isAutomationMode = false
+ private var currentPerformanceMode: OboePerformanceMode = OboePerformanceMode.None
+ private var pendingAutomationIntent: Intent? = null
+ private var assetsLoaded = false
+
+ // Shared UI state (updated by automation, observed by Compose)
+ private val isPlayingState = mutableStateOf(false)
+ private val songIndexState = mutableIntStateOf(0)
+ private val performanceModeState = mutableIntStateOf(0)
+ private val volumeState = mutableFloatStateOf(1.0f)
+ private val isMMapEnabledState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -140,17 +161,220 @@ class MainActivity : ComponentActivity() {
}
}
}
+
+ // Process automation intent if provided
+ // Note: Intent processing is deferred until assets are loaded in SongScreen
+ pendingAutomationIntent = intent
}
override fun onDestroy() {
super.onDestroy()
- player.stopPlaying(0)
+ cancelScheduledTasks()
+ player.stopPlaying(player.getCurrentlyPlayingIndex().coerceAtLeast(0))
player.teardownAudioStream()
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ // If assets are loaded, process immediately; otherwise defer
+ if (assetsLoaded) {
+ processIntent(intent)
+ } else {
+ pendingAutomationIntent = intent
+ }
+ }
+
private fun setUpPowerPlayAudioPlayer() {
player = PowerPlayAudioPlayer()
player.setupAudioStream()
+ // Initialize shared UI state from player
+ isMMapEnabledState.value = player.isMMapEnabled()
+ }
+
+ /**
+ * Process Intent extras for automation commands.
+ * Supports ADB commands like:
+ * adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ * --es command play --es perf_mode offload --ez background true
+ */
+ private fun processIntent(intent: Intent?) {
+ val extras = intent?.extras ?: return
+ if (extras.isEmpty) return
+
+ isAutomationMode = true
+ Log.i(LOG_TAG, "Processing automation intent")
+
+ // Get playlist for song index validation
+ val playList = getPlayList()
+
+ // Parse configuration from Intent
+ val songIndex = IntentBasedTestSupport.getSongIndex(extras, playList.size)
+ val perfMode = IntentBasedTestSupport.getPerformanceMode(extras)
+ val volume = IntentBasedTestSupport.getNormalizedVolume(extras)
+ val command = IntentBasedTestSupport.getCommand(extras)
+ val goBackground = IntentBasedTestSupport.isBackgroundRequested(extras)
+ val durationMs = IntentBasedTestSupport.getDurationMs(extras)
+ val useMMap = IntentBasedTestSupport.getMMapEnabled(extras, player.isMMapEnabled())
+ val bufferFrames = IntentBasedTestSupport.getBufferFrames(extras)
+ val toggleOffload = IntentBasedTestSupport.isToggleOffloadRequested(extras)
+ val toggleIntervalMs = IntentBasedTestSupport.getToggleIntervalMs(extras)
+
+ // Apply MMAP setting (must be done before playing)
+ if (useMMap != player.isMMapEnabled()) {
+ player.setMMapEnabled(useMMap)
+ isMMapEnabledState.value = useMMap
+ Log.i(LOG_TAG, "MMAP set to: $useMMap")
+ }
+
+ // Store current performance mode
+ currentPerformanceMode = perfMode
+
+ // Execute command
+ when (command?.lowercase()) {
+ IntentBasedTestSupport.COMMAND_PLAY -> {
+ // Stop any currently playing track first
+ val currentIndex = player.getCurrentlyPlayingIndex()
+ if (currentIndex >= 0) {
+ player.stopPlaying(currentIndex)
+ }
+
+ player.startPlaying(songIndex, currentPerformanceMode)
+
+ // Apply volume after starting (sample sources must exist)
+ player.setVolume(volume)
+ Log.i(LOG_TAG, "Volume set to: ${(volume * 100).toInt()}%")
+
+ // Update shared UI state
+ isPlayingState.value = true
+ songIndexState.intValue = songIndex
+ performanceModeState.intValue = perfMode.value
+ volumeState.floatValue = volume
+
+ logStatus(
+ IntentBasedTestSupport.STATUS_PLAYING,
+ "SONG" to songIndex,
+ "OFFLOAD" to player.isOffloaded(),
+ "MMAP" to player.isMMapEnabled()
+ )
+
+ // Apply buffer size (only effective in offload mode)
+ if (bufferFrames > 0 && currentPerformanceMode == OboePerformanceMode.PowerSavingOffloaded) {
+ val actualFrames = player.setBufferSizeInFrames(bufferFrames)
+ Log.i(LOG_TAG, "Buffer size set to: $actualFrames frames")
+ }
+
+ // Schedule auto-stop if duration is set
+ if (durationMs > 0) {
+ scheduleAutoStop(songIndex, durationMs)
+ }
+
+ // Schedule toggle offload stress test if requested
+ if (toggleOffload) {
+ scheduleToggleOffload(songIndex, toggleIntervalMs)
+ }
+
+ // Move to background if requested
+ if (goBackground) {
+ moveTaskToBack(true)
+ Log.i(LOG_TAG, "Moved to background")
+ }
+ }
+
+ IntentBasedTestSupport.COMMAND_PAUSE -> {
+ val currentIndex = player.getCurrentlyPlayingIndex()
+ if (currentIndex >= 0) {
+ player.stopPlaying(currentIndex)
+ isPlayingState.value = false
+ logStatus(IntentBasedTestSupport.STATUS_PAUSED)
+ }
+ cancelScheduledTasks()
+ }
+
+ IntentBasedTestSupport.COMMAND_STOP -> {
+ val currentIndex = player.getCurrentlyPlayingIndex()
+ if (currentIndex >= 0) {
+ player.stopPlaying(currentIndex)
+ isPlayingState.value = false
+ logStatus(IntentBasedTestSupport.STATUS_STOPPED)
+ }
+ cancelScheduledTasks()
+ if (goBackground) {
+ finishAndRemoveTask()
+ }
+ }
+
+ else -> {
+ // No command specified, just apply settings
+ Log.i(LOG_TAG, "No command specified, settings applied")
+ }
+ }
+ }
+
+ private fun scheduleAutoStop(songIndex: Int, durationMs: Long) {
+ cancelAutoStop()
+ autoStopRunnable = Runnable {
+ player.stopPlaying(songIndex)
+ isPlayingState.value = false
+ logStatus(IntentBasedTestSupport.STATUS_STOPPED, "REASON" to "AUTO_STOP")
+ cancelScheduledTasks()
+ }
+ handler.postDelayed(autoStopRunnable!!, durationMs)
+ Log.i(LOG_TAG, "Auto-stop scheduled in ${durationMs}ms")
+ }
+
+ private fun scheduleToggleOffload(songIndex: Int, intervalMs: Long) {
+ cancelToggleOffload()
+ var useOffload = currentPerformanceMode == OboePerformanceMode.PowerSavingOffloaded
+
+ toggleOffloadRunnable = object : Runnable {
+ override fun run() {
+ useOffload = !useOffload
+ val newMode = if (useOffload) {
+ OboePerformanceMode.PowerSavingOffloaded
+ } else {
+ OboePerformanceMode.PowerSaving
+ }
+
+ // Stop and restart with new mode
+ player.stopPlaying(songIndex)
+ player.startPlaying(songIndex, newMode)
+
+ // Update UI state
+ performanceModeState.intValue = newMode.value
+
+ logStatus(
+ IntentBasedTestSupport.STATUS_PLAYING,
+ "TOGGLE" to "OFFLOAD",
+ "OFFLOAD" to player.isOffloaded()
+ )
+
+ // Schedule next toggle
+ handler.postDelayed(this, intervalMs)
+ }
+ }
+ handler.postDelayed(toggleOffloadRunnable!!, intervalMs)
+ Log.i(LOG_TAG, "Toggle offload stress test started (interval: ${intervalMs}ms)")
+ }
+
+ private fun cancelAutoStop() {
+ autoStopRunnable?.let { handler.removeCallbacks(it) }
+ autoStopRunnable = null
+ }
+
+ private fun cancelToggleOffload() {
+ toggleOffloadRunnable?.let { handler.removeCallbacks(it) }
+ toggleOffloadRunnable = null
+ }
+
+ private fun cancelScheduledTasks() {
+ cancelAutoStop()
+ cancelToggleOffload()
+ }
+
+ private fun logStatus(status: String, vararg extras: Pair) {
+ val message = IntentBasedTestSupport.formatStatusLog(status, *extras)
+ Log.i(LOG_TAG, message)
}
/***
@@ -162,19 +386,21 @@ class MainActivity : ComponentActivity() {
fun SongScreen() {
val playList = getPlayList()
val pagerState = rememberPagerState(pageCount = { playList.count() })
- val playingSongIndex = remember {
- mutableIntStateOf(0)
- }
- val offload = remember {
- mutableIntStateOf(0) // 0: None, 1: Low Latency, 2: Power Saving, 3: PCM Offload
- }
-
- val isMMapEnabled = remember { mutableStateOf(player.isMMapEnabled()) }
- val isPlaying = remember {
- mutableStateOf(false)
+
+ // Use shared state from class level (automation can update these)
+ val playingSongIndex = songIndexState
+ val offload = performanceModeState
+ val isPlaying = isPlayingState
+ val isMMapEnabled = isMMapEnabledState
+
+ var sliderPosition by remember { mutableFloatStateOf(volumeState.floatValue) }
+
+ // Sync pager with song index when automation changes it
+ LaunchedEffect(playingSongIndex.intValue) {
+ if (pagerState.currentPage != playingSongIndex.intValue) {
+ pagerState.animateScrollToPage(playingSongIndex.intValue)
+ }
}
- var sliderPosition by remember { mutableFloatStateOf(0f) }
-
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
@@ -195,6 +421,12 @@ class MainActivity : ComponentActivity() {
player.loadFile(assets, it.fileName, index)
player.setLooping(index, true)
}
+ // Assets are now loaded, process any pending automation intent
+ assetsLoaded = true
+ pendingAutomationIntent?.let {
+ processIntent(it)
+ pendingAutomationIntent = null
+ }
}
@@ -253,8 +485,10 @@ class MainActivity : ComponentActivity() {
val radioOptions = mutableListOf("None", "Low Latency", "Power Saving")
if (isOffloadSupported) radioOptions.add("PCM Offload")
- val (selectedOption, onOptionSelected) = remember {
- mutableStateOf(radioOptions[0])
+ // Derive selectedOption from shared offload state
+ val selectedOption = radioOptions.getOrElse(offload.intValue) { radioOptions[0] }
+ val onOptionSelected: (String) -> Unit = { selected ->
+ offload.intValue = radioOptions.indexOf(selected).coerceAtLeast(0)
}
val enabled = !isPlaying.value
radioOptions.forEachIndexed { index, text ->
@@ -267,7 +501,6 @@ class MainActivity : ComponentActivity() {
onClick = {
if (enabled) {
onOptionSelected(text)
- offload.intValue = index
}
},
role = Role.RadioButton
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/automation/IntentBasedTestSupport.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/automation/IntentBasedTestSupport.kt
new file mode 100644
index 000000000..a484c4de7
--- /dev/null
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/automation/IntentBasedTestSupport.kt
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.oboe.samples.powerplay.automation
+
+import android.os.Bundle
+import com.google.oboe.samples.powerplay.engine.OboePerformanceMode
+
+/**
+ * Constants and utilities for Intent-based automation of the PowerPlay sample app.
+ *
+ * This enables ADB command-line control for testing power-efficient audio features.
+ *
+ * Example usage:
+ * ```
+ * adb shell am start -n com.google.oboe.samples.powerplay/.MainActivity \
+ * --es command play \
+ * --es perf_mode offload \
+ * --ez background true
+ * ```
+ */
+object IntentBasedTestSupport {
+
+ // ============================================================================
+ // Command Keys - Intent extra names for ADB commands
+ // ============================================================================
+
+ /** Command to execute: "play", "pause", "stop" */
+ const val KEY_COMMAND = "command"
+
+ /** Song index to play (0-2 for built-in playlist) */
+ const val KEY_SONG_INDEX = "song_index"
+
+ /** Performance mode: "none", "lowlat", "powersave", "offload" */
+ const val KEY_PERF_MODE = "perf_mode"
+
+ /** Volume percentage (0-100) */
+ const val KEY_VOLUME = "volume"
+
+ /** Move activity to background after starting playback */
+ const val KEY_BACKGROUND = "background"
+
+ /** Duration in milliseconds - auto-stop after this time */
+ const val KEY_DURATION_MS = "duration_ms"
+
+ /** Enable/disable MMAP audio path */
+ const val KEY_USE_MMAP = "use_mmap"
+
+ /** Buffer size in frames (only applicable in PCM Offload mode) */
+ const val KEY_BUFFER_FRAMES = "buffer_frames"
+
+ /** Enable toggle offload stress test mode */
+ const val KEY_TOGGLE_OFFLOAD = "toggle_offload"
+
+ /** Toggle interval in milliseconds for stress test */
+ const val KEY_TOGGLE_INTERVAL_MS = "toggle_interval_ms"
+
+ // ============================================================================
+ // Command Values
+ // ============================================================================
+
+ const val COMMAND_PLAY = "play"
+ const val COMMAND_PAUSE = "pause"
+ const val COMMAND_STOP = "stop"
+
+ // ============================================================================
+ // Performance Mode Values
+ // ============================================================================
+
+ const val PERF_MODE_NONE = "none"
+ const val PERF_MODE_LOW_LATENCY = "lowlat"
+ const val PERF_MODE_POWER_SAVING = "powersave"
+ const val PERF_MODE_OFFLOAD = "offload"
+
+ // ============================================================================
+ // Default Values
+ // ============================================================================
+
+ const val DEFAULT_SONG_INDEX = 0
+ const val DEFAULT_VOLUME = 100
+ const val DEFAULT_DURATION_MS = -1 // No auto-stop
+ const val DEFAULT_TOGGLE_INTERVAL_MS = 5000L
+
+ // ============================================================================
+ // Status Log Tags for Machine Parsing
+ // ============================================================================
+
+ /** Log tag for PowerPlay automation */
+ const val LOG_TAG = "PowerPlay"
+
+ /** Prefix for machine-readable status logs */
+ const val STATUS_PREFIX = "POWERPLAY_STATUS:"
+
+ // Status values
+ const val STATUS_PLAYING = "PLAYING"
+ const val STATUS_PAUSED = "PAUSED"
+ const val STATUS_STOPPED = "STOPPED"
+ const val STATUS_ERROR = "ERROR"
+ const val STATUS_OFFLOAD_REVOKED = "OFFLOAD_REVOKED"
+
+ // ============================================================================
+ // Utility Functions
+ // ============================================================================
+
+ /**
+ * Parse performance mode string from Intent extra to OboePerformanceMode enum.
+ *
+ * @param text Performance mode string ("none", "lowlat", "powersave", "offload")
+ * @return Corresponding OboePerformanceMode, defaults to None if invalid
+ */
+ fun getPerformanceModeFromText(text: String?): OboePerformanceMode {
+ return when (text?.lowercase()) {
+ PERF_MODE_NONE -> OboePerformanceMode.None
+ PERF_MODE_LOW_LATENCY -> OboePerformanceMode.LowLatency
+ PERF_MODE_POWER_SAVING -> OboePerformanceMode.PowerSaving
+ PERF_MODE_OFFLOAD -> OboePerformanceMode.PowerSavingOffloaded
+ else -> OboePerformanceMode.None
+ }
+ }
+
+ /**
+ * Get song index from bundle with bounds checking.
+ *
+ * @param bundle Intent extras bundle
+ * @param maxIndex Maximum valid index (exclusive)
+ * @return Valid song index, clamped to valid range
+ */
+ fun getSongIndex(bundle: Bundle, maxIndex: Int): Int {
+ val index = bundle.getInt(KEY_SONG_INDEX, DEFAULT_SONG_INDEX)
+ return index.coerceIn(0, maxIndex - 1)
+ }
+
+ /**
+ * Get volume from bundle, normalized to 0.0-1.0 range.
+ *
+ * @param bundle Intent extras bundle
+ * @return Volume as float (0.0 to 1.0)
+ */
+ fun getNormalizedVolume(bundle: Bundle): Float {
+ val volume = bundle.getInt(KEY_VOLUME, DEFAULT_VOLUME)
+ return (volume.coerceIn(0, 100) / 100.0f)
+ }
+
+ /**
+ * Get command from bundle.
+ *
+ * @param bundle Intent extras bundle
+ * @return Command string or null if not present
+ */
+ fun getCommand(bundle: Bundle): String? {
+ return bundle.getString(KEY_COMMAND)
+ }
+
+ /**
+ * Get performance mode from bundle.
+ *
+ * @param bundle Intent extras bundle
+ * @return OboePerformanceMode from bundle, defaults to None
+ */
+ fun getPerformanceMode(bundle: Bundle): OboePerformanceMode {
+ return getPerformanceModeFromText(bundle.getString(KEY_PERF_MODE))
+ }
+
+ /**
+ * Check if background mode is requested.
+ *
+ * @param bundle Intent extras bundle
+ * @return true if app should move to background after starting
+ */
+ fun isBackgroundRequested(bundle: Bundle): Boolean {
+ return bundle.getBoolean(KEY_BACKGROUND, false)
+ }
+
+ /**
+ * Get duration in milliseconds for auto-stop.
+ *
+ * @param bundle Intent extras bundle
+ * @return Duration in ms, or DEFAULT_DURATION_MS (-1) if not set
+ */
+ fun getDurationMs(bundle: Bundle): Long {
+ return bundle.getInt(KEY_DURATION_MS, DEFAULT_DURATION_MS).toLong()
+ }
+
+ /**
+ * Get MMAP preference from bundle.
+ *
+ * @param bundle Intent extras bundle
+ * @param currentValue Current MMAP enabled state
+ * @return MMAP preference, or current value if not specified
+ */
+ fun getMMapEnabled(bundle: Bundle, currentValue: Boolean): Boolean {
+ return if (bundle.containsKey(KEY_USE_MMAP)) {
+ bundle.getBoolean(KEY_USE_MMAP)
+ } else {
+ currentValue
+ }
+ }
+
+ /**
+ * Get buffer size in frames from bundle.
+ *
+ * @param bundle Intent extras bundle
+ * @return Buffer size in frames, or 0 if not specified
+ */
+ fun getBufferFrames(bundle: Bundle): Int {
+ return bundle.getInt(KEY_BUFFER_FRAMES, 0)
+ }
+
+ /**
+ * Check if toggle offload stress test is requested.
+ *
+ * @param bundle Intent extras bundle
+ * @return true if toggle offload mode should be enabled
+ */
+ fun isToggleOffloadRequested(bundle: Bundle): Boolean {
+ return bundle.getBoolean(KEY_TOGGLE_OFFLOAD, false)
+ }
+
+ /**
+ * Get toggle interval for stress test.
+ *
+ * @param bundle Intent extras bundle
+ * @return Toggle interval in milliseconds
+ */
+ fun getToggleIntervalMs(bundle: Bundle): Long {
+ return bundle.getInt(KEY_TOGGLE_INTERVAL_MS, DEFAULT_TOGGLE_INTERVAL_MS.toInt()).toLong()
+ }
+
+ /**
+ * Format a machine-readable status log message.
+ *
+ * @param status Status value (e.g., STATUS_PLAYING)
+ * @param extras Additional key=value pairs to include
+ * @return Formatted log message
+ */
+ fun formatStatusLog(status: String, vararg extras: Pair): String {
+ val extraStr = if (extras.isNotEmpty()) {
+ " | " + extras.joinToString(" | ") { "${it.first}=${it.second}" }
+ } else {
+ ""
+ }
+ return "$STATUS_PREFIX $status$extraStr"
+ }
+}
diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt
index 329e6cdbf..bd9398e66 100644
--- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt
+++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt
@@ -93,6 +93,27 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
fun getBufferCapacityInFrames(): Int = getBufferCapacityInFramesNative()
+ /**
+ * Sets the playback volume (gain) for the audio stream.
+ *
+ * @param volume Volume level from 0.0 (mute) to 1.0 (full volume)
+ */
+ fun setVolume(volume: Float) = setVolumeNative(volume)
+
+ /**
+ * Checks if the current audio stream is using PCM Offload.
+ *
+ * @return true if offload is active, false otherwise
+ */
+ fun isOffloaded(): Boolean = isOffloadedNative()
+
+ /**
+ * Gets the index of the currently playing track.
+ *
+ * @return Track index (0-based) or -1 if nothing is playing
+ */
+ fun getCurrentlyPlayingIndex(): Int = getCurrentlyPlayingIndexNative()
+
/**
* Native functions.
* Load the library containing the native code including the JNI functions.
@@ -116,6 +137,9 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver {
private external fun isMMapSupportedNative(): Boolean
private external fun setBufferSizeInFramesNative(bufferSizeInFrames: Int): Int
private external fun getBufferCapacityInFramesNative(): Int
+ private external fun setVolumeNative(volume: Float)
+ private external fun isOffloadedNative(): Boolean
+ private external fun getCurrentlyPlayingIndexNative(): Int
/**
* Companion