feat: move LogcatReader instance to service

log recordings can now continue when the debug activity is destroyed
This commit is contained in:
Benjamin Wiegand
2026-07-08 12:23:38 -07:00
parent 823566e330
commit 366e4cbc66
6 changed files with 200 additions and 34 deletions
+5
View File
@@ -161,6 +161,11 @@
android:exported="false"
/>
<service
android:name=".LogService"
android:exported="false"
/>
<service
android:name=".NotificationService"
android:exported="false"
@@ -20,6 +20,7 @@ import android.os.Looper;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
@@ -41,6 +42,7 @@ import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Supplier;
@@ -48,14 +50,16 @@ import java.util.function.Supplier;
import io.benwiegand.projection.geargrinder.exception.BluetoothConnectionException;
import io.benwiegand.projection.geargrinder.logs.LogUiAdapter;
import io.benwiegand.projection.geargrinder.logs.LogcatReader;
import io.benwiegand.projection.geargrinder.service.GeargrinderServiceConnector;
public class DebugActivity extends AppCompatActivity {
public class DebugActivity extends AppCompatActivity implements GeargrinderServiceConnector.ConnectionListener, LogcatReader.UiLogListener {
private static final String TAG = DebugActivity.class.getSimpleName();
// useful for debugging
private static final boolean AUTOSTART_TCP_SERVER = false;
private LogcatReader logcatReader;
private GeargrinderServiceConnector connector;
private final LogUiAdapter logUiAdapter = new LogUiAdapter();
private boolean autoScroll = true;
@Override
@@ -69,21 +73,12 @@ public class DebugActivity extends AppCompatActivity {
return insets;
});
findViewById(R.id.log_marker_button).setOnClickListener(v -> logcatReader.addMarker());
findViewById(R.id.log_marker_button).setOnClickListener(v -> getLogcatReader().ifPresent(LogcatReader::addMarker));
findViewById(R.id.toggle_recording_button).setOnClickListener(v -> {
findViewById(R.id.toggle_recording_button).setOnClickListener(v -> getLogcatReader().ifPresent(logcatReader -> {
if (logcatReader.isRecording()) {
Throwable error = logcatReader.stopRecording();
if (error == null) {
Toast.makeText(this, "recording stopped", Toast.LENGTH_SHORT).show();
return;
}
Log.e(TAG, "error during recording", error);
new AlertDialog.Builder(this)
.setTitle("Log recording error")
.setMessage("an error happened during the recording:\n\n" + error.getClass().getSimpleName() + ": " + error.getMessage())
.setPositiveButton("close", null)
.show();
if (error != null) onRecordingError(error);
return;
}
@@ -97,16 +92,15 @@ public class DebugActivity extends AppCompatActivity {
File logFile = getFilesDir().toPath().resolve(name).toFile();
try {
logcatReader.startRecording(logFile);
Toast.makeText(this, "recording started", Toast.LENGTH_SHORT).show();
} catch (IOException e) {
Log.e(TAG, "failed to start recording", e);
onRecordingError(e);
}
})
.setNegativeButton("cancel", null)
.setCancelable(false)
.show();
});
}));
if (getSupportActionBar() != null)
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
@@ -116,8 +110,6 @@ public class DebugActivity extends AppCompatActivity {
autoScrollSwitch.setOnCheckedChangeListener((v, checked) -> autoScroll = checked);
RecyclerView logRecyclerView = findViewById(R.id.log_recycler);
LogUiAdapter logUiAdapter = new LogUiAdapter();
logRecyclerView.setLayoutManager(new LinearLayoutManager(this));
logRecyclerView.setAdapter(logUiAdapter);
logRecyclerView.setItemAnimator(null); // does not work with fast-paced logs
@@ -130,8 +122,10 @@ public class DebugActivity extends AppCompatActivity {
}
});
logcatReader = new LogcatReader(logUiAdapter);
logcatReader.start();
logUiAdapter.onLog(null, "connecting to log service...");
connector = new GeargrinderServiceConnector(TAG, this, this);
connector.bindLogService(BIND_AUTO_CREATE | BIND_IMPORTANT);
if (AUTOSTART_TCP_SERVER)
startService(new Intent(this, ConnectionService.class)
@@ -141,7 +135,56 @@ public class DebugActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
logcatReader.destroy();
getLogcatReader().ifPresent(logcatReader -> logcatReader.unregisterUiListener(logUiAdapter));
connector.destroy();
}
private Optional<LogcatReader> getLogcatReader() {
return connector.getLogBinder()
.map(LogService.ServiceBinder::getLogcatReader);
}
public void updateRecordingStatus(boolean recording) {
Button toggleRecordingButton = findViewById(R.id.toggle_recording_button);
toggleRecordingButton.setText(recording ? "stop recording" : "start recording");
}
@Override
public void onLogServiceConnected(LogService.ServiceBinder binder) {
binder.getLogcatReader().registerUiListener(logUiAdapter);
binder.getLogcatReader().registerUiListener(this);
findViewById(R.id.log_marker_button).setEnabled(true);
findViewById(R.id.toggle_recording_button).setEnabled(true);
updateRecordingStatus(binder.getLogcatReader().isRecording());
}
@Override
public void onRecordingStart(File file) {
runOnUiThread(() -> {
updateRecordingStatus(true);
Toast.makeText(this, "recording started", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onRecordingError(Throwable t) {
runOnUiThread(() -> {
Log.e(TAG, "error during recording", t);
new AlertDialog.Builder(this)
.setTitle("Log recording error")
.setMessage("an error happened during the recording:\n\n" + t.getClass().getSimpleName() + ": " + t.getMessage())
.setPositiveButton("close", null)
.setCancelable(false)
.show();
});
}
@Override
public void onRecordingStop() {
runOnUiThread(() -> {
updateRecordingStatus(false);
Toast.makeText(this, "recording stopped", Toast.LENGTH_SHORT).show();
});
}
@Override
@@ -0,0 +1,57 @@
package io.benwiegand.projection.geargrinder;
import android.app.Service;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import androidx.annotation.Nullable;
import java.io.File;
import io.benwiegand.projection.geargrinder.logs.LogcatReader;
public class LogService extends Service implements LogcatReader.UiLogListener {
private final LogcatReader logcatReader = new LogcatReader();
private final ServiceBinder binder = new ServiceBinder();
@Override
public void onCreate() {
super.onCreate();
logcatReader.registerUiListener(this);
logcatReader.start();
}
@Override
public void onDestroy() {
super.onDestroy();
logcatReader.unregisterUiListener(this);
logcatReader.destroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return binder;
}
@Override
public void onRecordingStart(File file) {
startService(new Intent(this, LogService.class));
}
@Override
public void onRecordingStop() {
stopService(new Intent(this, LogService.class));
}
public class ServiceBinder extends Binder {
public LogcatReader getLogcatReader() {
return logcatReader;
}
}
}
@@ -8,6 +8,10 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.LinkedList;
import java.util.Queue;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -19,19 +23,18 @@ public class LogcatReader {
private static final Pattern LOGCAT_REGEX = Pattern.compile("^(?<date>[0-9]+-[0-9]+) +(?<time>[0-9]+:[0-9]+:[0-9]+.[0-9]+) +(?<pid>[0-9]+) +(?<tid>[0-9]+) +(?<level>[FEWIVD]) +(?<tag>[^:]+): ?(?<msg>.*)$");
public interface UiLogListener {
void onLog(String level, String text);
default void onLog(String level, String text) {}
default void onRecordingStart(File file) {}
default void onRecordingStop() {}
default void onRecordingError(Throwable t) {}
}
private final Thread readThread = new Thread(this::readLoop);
private boolean alive = true;
private final UiLogListener uiListener;
private final Queue<UiLogListener> uiListeners = new ArrayDeque<>();
private Recording activeRecording = null;
public LogcatReader(UiLogListener uiListener) {
this.uiListener = uiListener;
}
public void start() {
readThread.start();
}
@@ -41,6 +44,36 @@ public class LogcatReader {
stopRecording();
}
public void registerUiListener(UiLogListener listener) {
synchronized (uiListeners) {
uiListeners.add(listener);
}
}
public void unregisterUiListener(UiLogListener listener) {
synchronized (uiListeners) {
uiListeners.remove(listener);
}
}
private void callListeners(Consumer<UiLogListener> consumer) {
synchronized (uiListeners) {
LinkedList<UiLogListener> deadListeners = new LinkedList<>();
for (UiLogListener listener : uiListeners) {
try {
consumer.accept(listener);
} catch (Throwable t) {
Log.wtf(TAG, "ui listener threw", t);
deadListeners.add(listener); // avoid spamming the logs exponentially
}
}
if (deadListeners.isEmpty()) return;
Log.w(TAG, "removing " + deadListeners.size() + " dead listeners");
uiListeners.removeAll(deadListeners);
}
}
private void onLine(String rawText) {
Recording recording = activeRecording;
@@ -65,9 +98,10 @@ public class LogcatReader {
tag = String.valueOf(tagBuilder);
}
uiListener.onLog(level, time + " " + tag + " " + level + ": " + msg);
String text = time + " " + tag + " " + level + ": " + msg;
callListeners(l -> l.onLog(level, text));
} else {
uiListener.onLog(null, rawText);
callListeners(l -> l.onLog(null, rawText));
}
}
@@ -117,9 +151,11 @@ public class LogcatReader {
private static class Recording {
private final Writer writer;
private final Consumer<Throwable> onError;
private Throwable error = null;
private Recording(Writer writer) {
private Recording(Writer writer, Consumer<Throwable> onError) {
this.writer = writer;
this.onError = onError;
}
private void onLine(String line) {
@@ -131,6 +167,7 @@ public class LogcatReader {
} catch (Throwable t) {
Log.e(TAG, "failed to write line", t);
error = t;
onError.accept(t);
}
}
@@ -139,7 +176,14 @@ public class LogcatReader {
public void startRecording(File file) throws IOException {
if (activeRecording != null) throw new IllegalStateException("recording already active");
activeRecording = new Recording(new FileWriter(file));
Recording recording = new Recording(
new FileWriter(file),
t -> callListeners(l -> l.onRecordingError(t))
);
callListeners(l -> l.onRecordingStart(file));
activeRecording = recording;
}
public boolean isRecording() {
@@ -149,7 +193,7 @@ public class LogcatReader {
public Throwable stopRecording() {
if (activeRecording == null) return null;
Throwable error = activeRecording.error;
if (error != null) {
if (error == null) {
try {
activeRecording.writer.flush();
activeRecording.writer.close();
@@ -157,6 +201,7 @@ public class LogcatReader {
error = e;
}
}
callListeners(UiLogListener::onRecordingStop);
activeRecording = null;
return error;
}
@@ -13,6 +13,7 @@ import java.util.Optional;
import io.benwiegand.projection.geargrinder.AccessibilityInputService;
import io.benwiegand.projection.geargrinder.ConnectionService;
import io.benwiegand.projection.geargrinder.IShizukuUserService;
import io.benwiegand.projection.geargrinder.LogService;
import io.benwiegand.projection.geargrinder.NotificationService;
import io.benwiegand.projection.geargrinder.PackageService;
import io.benwiegand.projection.geargrinder.PrivdService;
@@ -31,6 +32,7 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
private static final ComponentName CONNECTION_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, ConnectionService.class.getName());
private static final ComponentName SHIZUKU_USER_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, ShizukuUserService.class.getName());
private static final ComponentName NOTIFICATION_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, NotificationService.class.getName());
private static final ComponentName LOG_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, LogService.class.getName());
private static final Shizuku.UserServiceArgs SHIZUKU_ARGS = new Shizuku.UserServiceArgs(SHIZUKU_USER_SERVICE_COMPONENT)
.tag("geargrinder-shizuku-service")
@@ -53,6 +55,7 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
default void onConnectionServiceConnected(ConnectionService.ServiceBinder binder) {}
default void onShizukuUserServiceConnected(IShizukuUserService service) {}
default void onNotificationServiceConnected(NotificationService.ServiceBinder binder) {}
default void onLogServiceConnected(LogService.ServiceBinder binder) {}
}
public GeargrinderServiceConnector(String tag, Context context, ConnectionListener listener) {
@@ -147,6 +150,15 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
.map(b -> (NotificationService.ServiceBinder) b);
}
public void bindLogService(int flags) {
realBind(LOG_SERVICE_COMPONENT, flags);
}
public Optional<LogService.ServiceBinder> getLogBinder() {
return getBinder(LOG_SERVICE_COMPONENT)
.map(b -> (LogService.ServiceBinder) b);
}
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
@@ -166,6 +178,8 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
listener.onShizukuUserServiceConnected(IShizukuUserService.Stub.asInterface(service));
} else if (name.equals(NOTIFICATION_SERVICE_COMPONENT)) {
listener.onNotificationServiceConnected((NotificationService.ServiceBinder) service);
} else if (name.equals(LOG_SERVICE_COMPONENT)) {
listener.onLogServiceConnected((LogService.ServiceBinder) service);
} else {
Log.wtf(tag, "unhandled component: " + name);
assert false;
+3 -1
View File
@@ -24,6 +24,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:enabled="false"
android:text="log marker"
/>
@@ -32,7 +33,8 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="log recording"
android:enabled="false"
android:text="start recording"
/>
<androidx.appcompat.widget.SwitchCompat