diff --git a/.gitignore b/.gitignore
index 5483bbc..6ceaccb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
/.gradle-tmp/
/build/
/app/build/
+/*/build/
/.externalNativeBuild/
/.cxx/
/captures/
diff --git a/README.md b/README.md
index 5fc1dc6..3c6a80b 100644
--- a/README.md
+++ b/README.md
@@ -116,3 +116,40 @@ Release builds require local signing configuration:
```bash
./gradlew assembleRelease
```
+
+## Test Harness
+
+The repository also includes a small companion app for compatibility testing.
+It is not required for normal use of the module. It creates repeatable
+statusbar scenarios such as opaque, transparent, fullscreen, privacy-indicator,
+and notification states:
+
+```bash
+./gradlew :test-harness:assembleDebug
+adb install -r test-harness/build/outputs/apk/debug/test-harness-debug.apk
+adb shell am start -n se.ajpanton.statusbartweak.harness/.MainActivity
+```
+
+Automation can launch scenarios directly:
+
+```bash
+adb shell am start -n se.ajpanton.statusbartweak.harness/.ScenarioActivity --es scenario fullscreen_landscape
+adb shell am start -n se.ajpanton.statusbartweak.harness/.MainActivity --es command post_normal
+```
+
+Useful scenario values include `opaque_light`, `opaque_dark`, `transparent`,
+`fullscreen_portrait`, `fullscreen_landscape`, `camera`, `microphone`, and
+`camera_microphone`.
+
+Useful command values include `post_normal`, `post_ongoing`, `post_grouped`,
+`update`, `start_chronometer`, `start_churn`, `stop_churn`, `start_media`,
+`start_foreground`, `stop_service`, and `clear`.
+
+Battery charging state is controlled by Android system commands rather than by
+the harness APK:
+
+```bash
+adb shell cmd battery set status 2
+adb shell cmd battery set level 80
+adb shell cmd battery reset
+```
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e2c2b2e..f4029ed 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,3 +21,4 @@ dependencyResolutionManagement {
rootProject.name = "StatusBarTweak"
include(":app")
+include(":test-harness")
diff --git a/test-harness/build.gradle.kts b/test-harness/build.gradle.kts
new file mode 100644
index 0000000..bf21ff7
--- /dev/null
+++ b/test-harness/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "se.ajpanton.statusbartweak.harness"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "se.ajpanton.statusbartweak.harness"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/test-harness/src/main/AndroidManifest.xml b/test-harness/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d29f03e
--- /dev/null
+++ b/test-harness/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/HarnessForegroundService.java b/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/HarnessForegroundService.java
new file mode 100644
index 0000000..6d9d28b
--- /dev/null
+++ b/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/HarnessForegroundService.java
@@ -0,0 +1,143 @@
+package se.ajpanton.statusbartweak.harness;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.media.MediaMetadata;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import android.os.Build;
+import android.os.IBinder;
+
+public final class HarnessForegroundService extends Service {
+ public static final String ACTION_START_MEDIA = "se.ajpanton.statusbartweak.harness.START_MEDIA";
+ public static final String ACTION_START_FOREGROUND =
+ "se.ajpanton.statusbartweak.harness.START_FOREGROUND";
+ public static final String ACTION_STOP = "se.ajpanton.statusbartweak.harness.STOP";
+
+ private static final String CHANNEL_ID = "sbt_harness_foreground";
+ private static final int NOTIFICATION_ID = 42_000;
+
+ private MediaSession mediaSession;
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ String action = intent != null ? intent.getAction() : null;
+ if (ACTION_STOP.equals(action)) {
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+ if (ACTION_START_MEDIA.equals(action)) {
+ startMediaState();
+ return START_STICKY;
+ }
+ startForegroundState();
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mediaSession != null) {
+ mediaSession.setActive(false);
+ mediaSession.release();
+ mediaSession = null;
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ private void startMediaState() {
+ ensureChannel();
+ if (mediaSession == null) {
+ mediaSession = new MediaSession(this, "StatusBarTweakHarness");
+ }
+ mediaSession.setMetadata(new MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_TITLE, "Harness media")
+ .putString(MediaMetadata.METADATA_KEY_ARTIST, "StatusBarTweak")
+ .build());
+ mediaSession.setPlaybackState(new PlaybackState.Builder()
+ .setState(PlaybackState.STATE_PLAYING, 0, 1f)
+ .setActions(PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_STOP)
+ .build());
+ mediaSession.setActive(true);
+
+ Notification.Builder builder = baseBuilder("Harness media", "MediaSession playback active")
+ .setCategory(Notification.CATEGORY_TRANSPORT)
+ .setOngoing(true)
+ .setStyle(new Notification.MediaStyle()
+ .setMediaSession(mediaSession.getSessionToken())
+ .setShowActionsInCompactView(0))
+ .addAction(R.drawable.ic_notify, "Stop", stopIntent());
+ startForegroundCompat(builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
+ }
+
+ private void startForegroundState() {
+ ensureChannel();
+ Notification notification = baseBuilder(
+ "Harness foreground service",
+ "Generic foreground service notification active")
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setOngoing(true)
+ .addAction(R.drawable.ic_notify, "Stop", stopIntent())
+ .build();
+ startForegroundCompat(notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ }
+
+ private Notification.Builder baseBuilder(String title, String text) {
+ Intent launch = new Intent(this, MainActivity.class);
+ PendingIntent launchIntent = PendingIntent.getActivity(
+ this,
+ 1,
+ launch,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ return new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setContentIntent(launchIntent)
+ .setShowWhen(true);
+ }
+
+ private PendingIntent stopIntent() {
+ Intent stop = new Intent(this, HarnessForegroundService.class);
+ stop.setAction(ACTION_STOP);
+ return PendingIntent.getService(
+ this,
+ 2,
+ stop,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ private void startForegroundCompat(Notification notification, int type) {
+ if (Build.VERSION.SDK_INT >= 29) {
+ startForeground(NOTIFICATION_ID, notification, type);
+ } else {
+ startForeground(NOTIFICATION_ID, notification);
+ }
+ }
+
+ private void ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm == null) {
+ return;
+ }
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ "StatusBarTweak harness foreground",
+ NotificationManager.IMPORTANCE_DEFAULT);
+ channel.setDescription("Foreground and media scenarios for StatusBarTweak testing");
+ channel.setShowBadge(false);
+ nm.createNotificationChannel(channel);
+ }
+}
diff --git a/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/MainActivity.java b/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/MainActivity.java
new file mode 100644
index 0000000..1687ccd
--- /dev/null
+++ b/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/MainActivity.java
@@ -0,0 +1,374 @@
+package se.ajpanton.statusbartweak.harness;
+
+import android.Manifest;
+import android.app.Activity;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+public final class MainActivity extends Activity {
+ public static final String EXTRA_COMMAND = "command";
+ public static final String COMMAND_POST_NORMAL = "post_normal";
+ public static final String COMMAND_POST_ONGOING = "post_ongoing";
+ public static final String COMMAND_POST_GROUPED = "post_grouped";
+ public static final String COMMAND_UPDATE = "update";
+ public static final String COMMAND_START_CHRONOMETER = "start_chronometer";
+ public static final String COMMAND_START_CHURN = "start_churn";
+ public static final String COMMAND_STOP_CHURN = "stop_churn";
+ public static final String COMMAND_START_MEDIA = "start_media";
+ public static final String COMMAND_START_FOREGROUND = "start_foreground";
+ public static final String COMMAND_STOP_SERVICE = "stop_service";
+ public static final String COMMAND_CLEAR = "clear";
+
+ private static final String CHANNEL_ID = "sbt_harness";
+ private static final int NOTIFICATION_BASE_ID = 41_000;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private int updatingCounter = 0;
+ private int churnCounter = 0;
+ private Runnable churnRunnable;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestNotificationPermissionIfNeeded();
+ setContentView(createContent());
+ handleCommand(getIntent());
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ handleCommand(intent);
+ }
+
+ private View createContent() {
+ ScrollView scroll = new ScrollView(this);
+ scroll.setBackgroundColor(0xfff7efe5);
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setPadding(dp(20), dp(20), dp(20), dp(28));
+ scroll.addView(root, new ScrollView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ TextView title = new TextView(this);
+ title.setText("StatusBarTweak Test Harness");
+ title.setTextColor(Color.BLACK);
+ title.setTextSize(24);
+ title.setGravity(Gravity.START);
+ root.addView(title, matchWrap());
+
+ TextView description = new TextView(this);
+ description.setText(
+ "Open a scenario, then observe StatusBarTweak behavior. "
+ + "The same APK can be used on emulators and physical devices.");
+ description.setTextColor(0xff333333);
+ description.setTextSize(15);
+ description.setPadding(0, dp(8), 0, dp(12));
+ root.addView(description, matchWrap());
+
+ addSection(root, "Statusbar surfaces");
+ addScenario(root, "Opaque light statusbar", ScenarioActivity.SCENARIO_OPAQUE_LIGHT);
+ addScenario(root, "Opaque dark statusbar", ScenarioActivity.SCENARIO_OPAQUE_DARK);
+ addScenario(root, "Transparent statusbar", ScenarioActivity.SCENARIO_TRANSPARENT);
+ addScenario(root, "Fullscreen portrait", ScenarioActivity.SCENARIO_FULLSCREEN_PORTRAIT);
+ addScenario(root, "Fullscreen landscape", ScenarioActivity.SCENARIO_FULLSCREEN_LANDSCAPE);
+
+ addSection(root, "Privacy indicators");
+ addScenario(root, "Camera privacy indicator", ScenarioActivity.SCENARIO_CAMERA);
+ addScenario(root, "Microphone privacy indicator", ScenarioActivity.SCENARIO_MICROPHONE);
+ addScenario(root, "Camera + microphone indicators", ScenarioActivity.SCENARIO_CAMERA_MICROPHONE);
+
+ addSection(root, "Chip-like public states");
+ addButton(root, "Start media session", v -> startHarnessService(
+ HarnessForegroundService.ACTION_START_MEDIA));
+ addButton(root, "Start foreground service", v -> startHarnessService(
+ HarnessForegroundService.ACTION_START_FOREGROUND));
+ addButton(root, "Post stopwatch/chronometer", v -> postChronometerNotification());
+ addButton(root, "Stop media/foreground service", v -> stopHarnessService());
+
+ addSection(root, "Notifications");
+ addButton(root, "Post normal notification", v -> postNotification(1, false, false, "Normal"));
+ addButton(root, "Post ongoing notification", v -> postNotification(2, true, false, "Ongoing"));
+ addButton(root, "Post grouped notifications", v -> postGroupedNotifications());
+ addButton(root, "Update notification text", v -> {
+ updatingCounter++;
+ postNotification(3, false, true, "Updated " + updatingCounter);
+ });
+ addButton(root, "Start notification churn", v -> startNotificationChurn());
+ addButton(root, "Stop notification churn", v -> stopNotificationChurn());
+ addButton(root, "Clear harness notifications", v -> {
+ stopNotificationChurn();
+ clearNotifications();
+ });
+
+ return scroll;
+ }
+
+ private void handleCommand(Intent intent) {
+ if (intent == null) {
+ return;
+ }
+ String command = intent.getStringExtra(EXTRA_COMMAND);
+ if (command == null) {
+ return;
+ }
+ switch (command) {
+ case COMMAND_POST_NORMAL:
+ postNotification(1, false, false, "Normal");
+ break;
+ case COMMAND_POST_ONGOING:
+ postNotification(2, true, false, "Ongoing");
+ break;
+ case COMMAND_POST_GROUPED:
+ postGroupedNotifications();
+ break;
+ case COMMAND_UPDATE:
+ updatingCounter++;
+ postNotification(3, false, true, "Updated " + updatingCounter);
+ break;
+ case COMMAND_START_CHRONOMETER:
+ postChronometerNotification();
+ break;
+ case COMMAND_START_CHURN:
+ startNotificationChurn();
+ break;
+ case COMMAND_STOP_CHURN:
+ stopNotificationChurn();
+ break;
+ case COMMAND_START_MEDIA:
+ startHarnessService(HarnessForegroundService.ACTION_START_MEDIA);
+ break;
+ case COMMAND_START_FOREGROUND:
+ startHarnessService(HarnessForegroundService.ACTION_START_FOREGROUND);
+ break;
+ case COMMAND_STOP_SERVICE:
+ stopHarnessService();
+ break;
+ case COMMAND_CLEAR:
+ stopNotificationChurn();
+ clearNotifications();
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void addScenario(LinearLayout root, String label, String scenario) {
+ addButton(root, label, v -> {
+ Intent intent = new Intent(this, ScenarioActivity.class);
+ intent.putExtra(ScenarioActivity.EXTRA_SCENARIO, scenario);
+ startActivity(intent);
+ });
+ }
+
+ private void addSection(LinearLayout root, String label) {
+ TextView view = new TextView(this);
+ view.setText(label);
+ view.setTextColor(0xff444444);
+ view.setTextSize(17);
+ view.setPadding(0, dp(18), 0, dp(6));
+ root.addView(view, matchWrap());
+ }
+
+ private void addButton(LinearLayout root, String label, View.OnClickListener listener) {
+ Button button = new Button(this);
+ button.setText(label);
+ button.setAllCaps(false);
+ button.setOnClickListener(listener);
+ LinearLayout.LayoutParams params = matchWrap();
+ params.setMargins(0, dp(4), 0, dp(4));
+ root.addView(button, params);
+ }
+
+ private void postNotification(int slot, boolean ongoing, boolean alertOnlyOnce, String label) {
+ requestNotificationPermissionIfNeeded();
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm == null || !canPostNotifications()) {
+ return;
+ }
+ ensureChannel(nm);
+ Intent launch = new Intent(this, MainActivity.class);
+ PendingIntent pendingIntent = PendingIntent.getActivity(
+ this,
+ slot,
+ launch,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("Harness " + label)
+ .setContentText("Package: " + getPackageName())
+ .setContentIntent(pendingIntent)
+ .setShowWhen(true)
+ .setOnlyAlertOnce(alertOnlyOnce)
+ .setCategory(Notification.CATEGORY_STATUS);
+ if (ongoing) {
+ builder.setOngoing(true);
+ }
+ nm.notify(NOTIFICATION_BASE_ID + slot, builder.build());
+ }
+
+ private void postGroupedNotifications() {
+ requestNotificationPermissionIfNeeded();
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm == null || !canPostNotifications()) {
+ return;
+ }
+ ensureChannel(nm);
+ String group = "sbt_harness_group";
+ for (int i = 0; i < 3; i++) {
+ Notification notification = new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("Harness grouped " + (i + 1))
+ .setContentText("Grouped notification")
+ .setGroup(group)
+ .setShowWhen(false)
+ .build();
+ nm.notify(NOTIFICATION_BASE_ID + 10 + i, notification);
+ }
+ Notification summary = new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("Harness group")
+ .setContentText("Group summary")
+ .setGroup(group)
+ .setGroupSummary(true)
+ .build();
+ nm.notify(NOTIFICATION_BASE_ID + 20, summary);
+ }
+
+ private void postChronometerNotification() {
+ requestNotificationPermissionIfNeeded();
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm == null || !canPostNotifications()) {
+ return;
+ }
+ ensureChannel(nm);
+ Notification notification = new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("Harness stopwatch")
+ .setContentText("Chronometer notification active")
+ .setWhen(System.currentTimeMillis())
+ .setUsesChronometer(true)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setCategory(Notification.CATEGORY_STATUS)
+ .build();
+ nm.notify(NOTIFICATION_BASE_ID + 30, notification);
+ }
+
+ private void startNotificationChurn() {
+ requestNotificationPermissionIfNeeded();
+ if (!canPostNotifications()) {
+ return;
+ }
+ if (churnRunnable != null) {
+ return;
+ }
+ churnRunnable = new Runnable() {
+ @Override
+ public void run() {
+ churnCounter++;
+ postNotification(40, false, true, "Churn " + churnCounter);
+ postNotification(41, true, true, "Ongoing churn " + churnCounter);
+ if (churnCounter % 2 == 0) {
+ postNotification(42, false, true, "Intermittent " + churnCounter);
+ } else {
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm != null) {
+ nm.cancel(NOTIFICATION_BASE_ID + 42);
+ }
+ }
+ handler.postDelayed(this, 1000);
+ }
+ };
+ churnRunnable.run();
+ }
+
+ private void stopNotificationChurn() {
+ if (churnRunnable != null) {
+ handler.removeCallbacks(churnRunnable);
+ churnRunnable = null;
+ }
+ }
+
+ private void startHarnessService(String action) {
+ requestNotificationPermissionIfNeeded();
+ if (!canPostNotifications()) {
+ return;
+ }
+ Intent intent = new Intent(this, HarnessForegroundService.class);
+ intent.setAction(action);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent);
+ } else {
+ startService(intent);
+ }
+ }
+
+ private void stopHarnessService() {
+ Intent intent = new Intent(this, HarnessForegroundService.class);
+ intent.setAction(HarnessForegroundService.ACTION_STOP);
+ startService(intent);
+ }
+
+ private void clearNotifications() {
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm != null) {
+ nm.cancelAll();
+ }
+ stopHarnessService();
+ }
+
+ private void ensureChannel(NotificationManager nm) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ "StatusBarTweak harness",
+ NotificationManager.IMPORTANCE_DEFAULT);
+ channel.setDescription("Scenario notifications for StatusBarTweak compatibility testing");
+ channel.setShowBadge(false);
+ nm.createNotificationChannel(channel);
+ }
+
+ private void requestNotificationPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT < 33 || canPostNotifications()) {
+ return;
+ }
+ requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 200);
+ }
+
+ private boolean canPostNotifications() {
+ return Build.VERSION.SDK_INT < 33
+ || checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private LinearLayout.LayoutParams matchWrap() {
+ return new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+
+ private int dp(int value) {
+ return Math.round(value * getResources().getDisplayMetrics().density);
+ }
+}
diff --git a/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/ScenarioActivity.java b/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/ScenarioActivity.java
new file mode 100644
index 0000000..79b8399
--- /dev/null
+++ b/test-harness/src/main/java/se/ajpanton/statusbartweak/harness/ScenarioActivity.java
@@ -0,0 +1,469 @@
+package se.ajpanton.statusbartweak.harness;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.graphics.Color;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.media.AudioFormat;
+import android.media.AudioRecord;
+import android.media.MediaRecorder;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowInsetsController;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+public final class ScenarioActivity extends Activity {
+ public static final String EXTRA_SCENARIO = "scenario";
+ public static final String SCENARIO_OPAQUE_LIGHT = "opaque_light";
+ public static final String SCENARIO_OPAQUE_DARK = "opaque_dark";
+ public static final String SCENARIO_TRANSPARENT = "transparent";
+ public static final String SCENARIO_FULLSCREEN_PORTRAIT = "fullscreen_portrait";
+ public static final String SCENARIO_FULLSCREEN_LANDSCAPE = "fullscreen_landscape";
+ public static final String SCENARIO_CAMERA = "camera";
+ public static final String SCENARIO_MICROPHONE = "microphone";
+ public static final String SCENARIO_CAMERA_MICROPHONE = "camera_microphone";
+
+ private static final int REQUEST_CAMERA = 301;
+ private static final int REQUEST_MICROPHONE = 302;
+ private static final int REQUEST_CAMERA_MICROPHONE = 303;
+
+ private HandlerThread cameraThread;
+ private Handler cameraHandler;
+ private CameraDevice cameraDevice;
+ private AudioRecord audioRecord;
+ private Thread audioThread;
+ private volatile boolean audioRunning;
+ private TextView statusText;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ String scenario = getIntent().getStringExtra(EXTRA_SCENARIO);
+ if (scenario == null) {
+ scenario = SCENARIO_OPAQUE_LIGHT;
+ }
+ final String activeScenario = scenario;
+ boolean lightBars = configureWindow(activeScenario);
+ View content = createScenarioView(activeScenario);
+ setContentView(content);
+ content.post(() -> {
+ setLightBars(lightBars);
+ if (isFullscreen(activeScenario)) {
+ hideSystemBars();
+ }
+ });
+ startScenarioIfNeeded(activeScenario);
+ }
+
+ @Override
+ protected void onDestroy() {
+ stopCamera();
+ stopMicrophone();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ boolean granted = true;
+ for (int result : grantResults) {
+ granted &= result == PackageManager.PERMISSION_GRANTED;
+ }
+ granted &= grantResults.length > 0;
+ if (!granted) {
+ setStatus("Permission denied.");
+ return;
+ }
+ if (requestCode == REQUEST_CAMERA) {
+ startCamera();
+ } else if (requestCode == REQUEST_MICROPHONE) {
+ startMicrophone();
+ } else if (requestCode == REQUEST_CAMERA_MICROPHONE) {
+ startCameraAndMicrophone();
+ }
+ }
+
+ private boolean configureWindow(String scenario) {
+ Window window = getWindow();
+ if (Build.VERSION.SDK_INT >= 28) {
+ window.getAttributes().layoutInDisplayCutoutMode =
+ android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ }
+
+ if (SCENARIO_FULLSCREEN_LANDSCAPE.equals(scenario)) {
+ setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
+ } else if (SCENARIO_FULLSCREEN_PORTRAIT.equals(scenario)) {
+ setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+ }
+
+ if (SCENARIO_OPAQUE_LIGHT.equals(scenario)) {
+ window.setStatusBarColor(Color.WHITE);
+ window.setNavigationBarColor(Color.WHITE);
+ return true;
+ } else if (SCENARIO_OPAQUE_DARK.equals(scenario)) {
+ window.setStatusBarColor(Color.BLACK);
+ window.setNavigationBarColor(Color.BLACK);
+ return false;
+ } else if (SCENARIO_TRANSPARENT.equals(scenario)) {
+ window.setStatusBarColor(Color.TRANSPARENT);
+ window.setNavigationBarColor(Color.TRANSPARENT);
+ if (Build.VERSION.SDK_INT >= 30) {
+ window.setDecorFitsSystemWindows(false);
+ }
+ return false;
+ } else if (isFullscreen(scenario)) {
+ window.setStatusBarColor(Color.TRANSPARENT);
+ window.setNavigationBarColor(Color.BLACK);
+ if (Build.VERSION.SDK_INT >= 30) {
+ window.setDecorFitsSystemWindows(false);
+ }
+ return false;
+ } else {
+ window.setStatusBarColor(0xff202124);
+ window.setNavigationBarColor(0xff202124);
+ return false;
+ }
+ }
+
+ private View createScenarioView(String scenario) {
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setGravity(Gravity.CENTER);
+ root.setPadding(dp(24), dp(60), dp(24), dp(60));
+ root.setBackgroundColor(backgroundColor(scenario));
+
+ TextView title = new TextView(this);
+ title.setText(titleFor(scenario));
+ title.setTextColor(textColor(scenario));
+ title.setTextSize(28);
+ title.setGravity(Gravity.CENTER);
+ root.addView(title, matchWrap());
+
+ statusText = new TextView(this);
+ statusText.setText(statusFor(scenario));
+ statusText.setTextColor(textColor(scenario));
+ statusText.setAlpha(0.85f);
+ statusText.setTextSize(16);
+ statusText.setGravity(Gravity.CENTER);
+ statusText.setPadding(0, dp(12), 0, dp(20));
+ root.addView(statusText, matchWrap());
+
+ if (isFullscreen(scenario)) {
+ addButton(root, "Temporarily show system bars", v -> showSystemBarsBriefly());
+ }
+ if (SCENARIO_CAMERA.equals(scenario)) {
+ addButton(root, "Start camera privacy indicator", v -> startCamera());
+ addButton(root, "Stop camera", v -> stopCamera());
+ }
+ if (SCENARIO_MICROPHONE.equals(scenario)) {
+ addButton(root, "Start microphone privacy indicator", v -> startMicrophone());
+ addButton(root, "Stop microphone", v -> stopMicrophone());
+ }
+ if (SCENARIO_CAMERA_MICROPHONE.equals(scenario)) {
+ addButton(root, "Start both indicators", v -> startCameraAndMicrophone());
+ addButton(root, "Stop both", v -> {
+ stopCamera();
+ stopMicrophone();
+ });
+ }
+ addButton(root, "Finish scenario", v -> finish());
+ return root;
+ }
+
+ private void startScenarioIfNeeded(String scenario) {
+ if (SCENARIO_CAMERA.equals(scenario)) {
+ startCamera();
+ } else if (SCENARIO_MICROPHONE.equals(scenario)) {
+ startMicrophone();
+ } else if (SCENARIO_CAMERA_MICROPHONE.equals(scenario)) {
+ startCameraAndMicrophone();
+ }
+ }
+
+ private int backgroundColor(String scenario) {
+ if (SCENARIO_OPAQUE_LIGHT.equals(scenario)) {
+ return Color.WHITE;
+ }
+ if (SCENARIO_TRANSPARENT.equals(scenario)) {
+ return 0xff0d47a1;
+ }
+ if (isFullscreen(scenario)) {
+ return 0xff1b5e20;
+ }
+ return 0xff101214;
+ }
+
+ private int textColor(String scenario) {
+ return SCENARIO_OPAQUE_LIGHT.equals(scenario) ? Color.BLACK : Color.WHITE;
+ }
+
+ private String titleFor(String scenario) {
+ switch (scenario) {
+ case SCENARIO_OPAQUE_LIGHT:
+ return "Opaque light statusbar";
+ case SCENARIO_OPAQUE_DARK:
+ return "Opaque dark statusbar";
+ case SCENARIO_TRANSPARENT:
+ return "Transparent statusbar";
+ case SCENARIO_FULLSCREEN_PORTRAIT:
+ return "Fullscreen portrait";
+ case SCENARIO_FULLSCREEN_LANDSCAPE:
+ return "Fullscreen landscape";
+ case SCENARIO_CAMERA:
+ return "Camera privacy indicator";
+ case SCENARIO_MICROPHONE:
+ return "Microphone privacy indicator";
+ case SCENARIO_CAMERA_MICROPHONE:
+ return "Camera + microphone indicators";
+ default:
+ return "Scenario";
+ }
+ }
+
+ private String statusFor(String scenario) {
+ if (isFullscreen(scenario)) {
+ return "System bars are hidden. Swipe from an edge or use the button below to reveal them.";
+ }
+ if (SCENARIO_TRANSPARENT.equals(scenario)) {
+ return "Content is laid out behind a transparent statusbar.";
+ }
+ if (SCENARIO_CAMERA.equals(scenario)) {
+ return "Camera should stay open until stopped or this screen is closed.";
+ }
+ if (SCENARIO_MICROPHONE.equals(scenario)) {
+ return "AudioRecord should stay active until stopped or this screen is closed.";
+ }
+ if (SCENARIO_CAMERA_MICROPHONE.equals(scenario)) {
+ return "Camera and AudioRecord should stay active until stopped or this screen is closed.";
+ }
+ return "This screen uses a normal app statusbar.";
+ }
+
+ private void startCamera() {
+ if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA);
+ return;
+ }
+ if (cameraDevice != null) {
+ setStatus("Camera already active.");
+ return;
+ }
+ CameraManager manager = getSystemService(CameraManager.class);
+ if (manager == null) {
+ setStatus("CameraManager unavailable.");
+ return;
+ }
+ try {
+ String[] ids = manager.getCameraIdList();
+ if (ids.length == 0) {
+ setStatus("No camera reported by this device.");
+ return;
+ }
+ cameraThread = new HandlerThread("SbtHarnessCamera");
+ cameraThread.start();
+ cameraHandler = new Handler(cameraThread.getLooper());
+ manager.openCamera(ids[0], new CameraDevice.StateCallback() {
+ @Override
+ public void onOpened(CameraDevice camera) {
+ cameraDevice = camera;
+ setStatus("Camera active: " + ids[0]);
+ }
+
+ @Override
+ public void onDisconnected(CameraDevice camera) {
+ stopCamera();
+ setStatus("Camera disconnected.");
+ }
+
+ @Override
+ public void onError(CameraDevice camera, int error) {
+ stopCamera();
+ setStatus("Camera error: " + error);
+ }
+ }, cameraHandler);
+ } catch (CameraAccessException | SecurityException | IllegalArgumentException e) {
+ stopCamera();
+ setStatus("Camera failed: " + e.getClass().getSimpleName());
+ }
+ }
+
+ private void startCameraAndMicrophone() {
+ boolean hasCamera = checkSelfPermission(Manifest.permission.CAMERA)
+ == PackageManager.PERMISSION_GRANTED;
+ boolean hasMic = checkSelfPermission(Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED;
+ if (!hasCamera || !hasMic) {
+ requestPermissions(
+ new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO},
+ REQUEST_CAMERA_MICROPHONE);
+ return;
+ }
+ startCamera();
+ startMicrophone();
+ }
+
+ private void stopCamera() {
+ if (cameraDevice != null) {
+ cameraDevice.close();
+ cameraDevice = null;
+ }
+ if (cameraThread != null) {
+ cameraThread.quitSafely();
+ cameraThread = null;
+ cameraHandler = null;
+ }
+ setStatus("Camera stopped.");
+ }
+
+ private void startMicrophone() {
+ if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, REQUEST_MICROPHONE);
+ return;
+ }
+ if (audioRecord != null) {
+ setStatus("Microphone already active.");
+ return;
+ }
+ int sampleRate = 8000;
+ int minBuffer = AudioRecord.getMinBufferSize(
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT);
+ int bufferSize = Math.max(minBuffer, 2048);
+ try {
+ audioRecord = new AudioRecord(
+ MediaRecorder.AudioSource.MIC,
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufferSize);
+ audioRecord.startRecording();
+ audioRunning = true;
+ audioThread = new Thread(() -> {
+ byte[] buffer = new byte[bufferSize];
+ while (audioRunning && audioRecord != null) {
+ audioRecord.read(buffer, 0, buffer.length);
+ }
+ }, "SbtHarnessMic");
+ audioThread.start();
+ setStatus("Microphone active.");
+ } catch (SecurityException | IllegalStateException | IllegalArgumentException e) {
+ stopMicrophone();
+ setStatus("Microphone failed: " + e.getClass().getSimpleName());
+ }
+ }
+
+ private void stopMicrophone() {
+ audioRunning = false;
+ if (audioRecord != null) {
+ try {
+ audioRecord.stop();
+ } catch (IllegalStateException ignored) {
+ }
+ audioRecord.release();
+ audioRecord = null;
+ }
+ audioThread = null;
+ setStatus("Microphone stopped.");
+ }
+
+ private void setStatus(String status) {
+ if (statusText != null) {
+ runOnUiThread(() -> statusText.setText(status));
+ }
+ }
+
+ private boolean isFullscreen(String scenario) {
+ return SCENARIO_FULLSCREEN_PORTRAIT.equals(scenario)
+ || SCENARIO_FULLSCREEN_LANDSCAPE.equals(scenario);
+ }
+
+ private void hideSystemBars() {
+ if (Build.VERSION.SDK_INT >= 30) {
+ WindowInsetsController controller = getWindow().getInsetsController();
+ if (controller != null) {
+ controller.setSystemBarsBehavior(
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ controller.hide(WindowInsets.Type.systemBars());
+ }
+ } else {
+ getWindow().getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
+ }
+ }
+
+ private void showSystemBarsBriefly() {
+ if (Build.VERSION.SDK_INT >= 30) {
+ WindowInsetsController controller = getWindow().getInsetsController();
+ if (controller != null) {
+ controller.show(WindowInsets.Type.systemBars());
+ }
+ } else {
+ getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
+ }
+ getWindow().getDecorView().postDelayed(this::hideSystemBars, 2500);
+ }
+
+ private void setLightBars(boolean lightBars) {
+ if (Build.VERSION.SDK_INT >= 30) {
+ WindowInsetsController controller = getWindow().getInsetsController();
+ if (controller != null) {
+ int mask = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
+ | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
+ controller.setSystemBarsAppearance(lightBars ? mask : 0, mask);
+ }
+ } else if (Build.VERSION.SDK_INT >= 23) {
+ int flags = getWindow().getDecorView().getSystemUiVisibility();
+ if (lightBars) {
+ flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
+ if (Build.VERSION.SDK_INT >= 26) {
+ flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
+ }
+ } else {
+ flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
+ if (Build.VERSION.SDK_INT >= 26) {
+ flags &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
+ }
+ }
+ getWindow().getDecorView().setSystemUiVisibility(flags);
+ }
+ }
+
+ private void addButton(LinearLayout root, String label, View.OnClickListener listener) {
+ Button button = new Button(this);
+ button.setText(label);
+ button.setAllCaps(false);
+ button.setOnClickListener(listener);
+ LinearLayout.LayoutParams params = matchWrap();
+ params.setMargins(0, dp(8), 0, 0);
+ root.addView(button, params);
+ }
+
+ private LinearLayout.LayoutParams matchWrap() {
+ return new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+
+ private int dp(int value) {
+ return Math.round(value * getResources().getDisplayMetrics().density);
+ }
+}
diff --git a/test-harness/src/main/res/drawable/ic_harness.xml b/test-harness/src/main/res/drawable/ic_harness.xml
new file mode 100644
index 0000000..b8f7418
--- /dev/null
+++ b/test-harness/src/main/res/drawable/ic_harness.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/test-harness/src/main/res/drawable/ic_notify.xml b/test-harness/src/main/res/drawable/ic_notify.xml
new file mode 100644
index 0000000..af5f746
--- /dev/null
+++ b/test-harness/src/main/res/drawable/ic_notify.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/test-harness/src/main/res/values/colors.xml b/test-harness/src/main/res/values/colors.xml
new file mode 100644
index 0000000..64ef330
--- /dev/null
+++ b/test-harness/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #00E676
+
diff --git a/test-harness/src/main/res/values/strings.xml b/test-harness/src/main/res/values/strings.xml
new file mode 100644
index 0000000..e5f64a9
--- /dev/null
+++ b/test-harness/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ SBT Test Harness
+
diff --git a/test-harness/src/main/res/values/themes.xml b/test-harness/src/main/res/values/themes.xml
new file mode 100644
index 0000000..177c7c0
--- /dev/null
+++ b/test-harness/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+