Add statusbar test harness app
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
/.gradle-tmp/
|
/.gradle-tmp/
|
||||||
/build/
|
/build/
|
||||||
/app/build/
|
/app/build/
|
||||||
|
/*/build/
|
||||||
/.externalNativeBuild/
|
/.externalNativeBuild/
|
||||||
/.cxx/
|
/.cxx/
|
||||||
/captures/
|
/captures/
|
||||||
|
|||||||
@@ -116,3 +116,40 @@ Release builds require local signing configuration:
|
|||||||
```bash
|
```bash
|
||||||
./gradlew assembleRelease
|
./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
|
||||||
|
```
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "StatusBarTweak"
|
rootProject.name = "StatusBarTweak"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":test-harness")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.microphone"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@drawable/ic_harness"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.StatusBarTweakHarness">
|
||||||
|
<service
|
||||||
|
android:name=".HarnessForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync|mediaPlayback" />
|
||||||
|
<activity
|
||||||
|
android:name=".ScenarioActivity"
|
||||||
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+143
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<path
|
||||||
|
android:fillColor="#202124"
|
||||||
|
android:pathData="M4,4h40v40h-40z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00E676"
|
||||||
|
android:pathData="M4,4h32v5h-32z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M8,16h32v4h-32zM8,24h24v4h-24zM8,32h16v4h-16z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M4,5h16v11h-12l-4,4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="harness_accent">#00E676</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">SBT Test Harness</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.StatusBarTweakHarness" parent="@android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:navigationBarColor">#111111</item>
|
||||||
|
<item name="android:colorAccent">@color/harness_accent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
Reference in New Issue
Block a user