From 7be5f6426270406b4f07c9736455998607003f3f Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Wed, 1 Jul 2026 19:39:25 +0000 Subject: [PATCH] Add statusbar test harness app --- .gitignore | 1 + README.md | 37 ++ settings.gradle.kts | 1 + test-harness/build.gradle.kts | 21 + test-harness/src/main/AndroidManifest.xml | 40 ++ .../harness/HarnessForegroundService.java | 143 ++++++ .../statusbartweak/harness/MainActivity.java | 374 ++++++++++++++ .../harness/ScenarioActivity.java | 469 ++++++++++++++++++ .../src/main/res/drawable/ic_harness.xml | 16 + .../src/main/res/drawable/ic_notify.xml | 10 + test-harness/src/main/res/values/colors.xml | 4 + test-harness/src/main/res/values/strings.xml | 4 + test-harness/src/main/res/values/themes.xml | 9 + 13 files changed, 1129 insertions(+) create mode 100644 test-harness/build.gradle.kts create mode 100644 test-harness/src/main/AndroidManifest.xml create mode 100644 test-harness/src/main/java/se/ajpanton/statusbartweak/harness/HarnessForegroundService.java create mode 100644 test-harness/src/main/java/se/ajpanton/statusbartweak/harness/MainActivity.java create mode 100644 test-harness/src/main/java/se/ajpanton/statusbartweak/harness/ScenarioActivity.java create mode 100644 test-harness/src/main/res/drawable/ic_harness.xml create mode 100644 test-harness/src/main/res/drawable/ic_notify.xml create mode 100644 test-harness/src/main/res/values/colors.xml create mode 100644 test-harness/src/main/res/values/strings.xml create mode 100644 test-harness/src/main/res/values/themes.xml 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 @@ + + + +