diff --git a/server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl b/server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl new file mode 100644 index 0000000..46d7f7c --- /dev/null +++ b/server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2008, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.content; + +/** + * {@hide} + */ +oneway interface IOnPrimaryClipChangedListener { + void dispatchPrimaryClipChanged(); +} diff --git a/server/src/main/aidl/android/view/IDisplayFoldListener.aidl b/server/src/main/aidl/android/view/IDisplayFoldListener.aidl new file mode 100644 index 0000000..2c91149 --- /dev/null +++ b/server/src/main/aidl/android/view/IDisplayFoldListener.aidl @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.view; + +/** + * {@hide} + */ +oneway interface IDisplayFoldListener +{ + /** Called when the foldedness of a display changes */ + void onDisplayFoldChanged(int displayId, boolean folded); +} diff --git a/server/src/main/java/org/cagnulein/android_remote/AsyncProcessor.java b/server/src/main/java/org/cagnulein/android_remote/AsyncProcessor.java new file mode 100644 index 0000000..dc9607c --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AsyncProcessor.java @@ -0,0 +1,18 @@ +package org.cagnulein.android_remote; + +public interface AsyncProcessor { + interface TerminationListener { + /** + * Notify processor termination + * + * @param fatalError {@code true} if this must cause the termination of the whole scrcpy-server. + */ + void onTerminated(boolean fatalError); + } + + void start(TerminationListener listener); + + void stop(); + + void join() throws InterruptedException; +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/AudioCapture.java b/server/src/main/java/org/cagnulein/android_remote/AudioCapture.java new file mode 100644 index 0000000..069ce80 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AudioCapture.java @@ -0,0 +1,179 @@ +package org.cagnulein.android_remote; + +import org.cagnulein.android_remote.wrappers.ServiceManager; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.Intent; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.AudioTimestamp; +import android.media.MediaCodec; +import android.os.Build; +import android.os.SystemClock; + +import java.nio.ByteBuffer; + +public final class AudioCapture { + + public static final int SAMPLE_RATE = 48000; + public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO; + public static final int CHANNELS = 2; + public static final int CHANNEL_MASK = AudioFormat.CHANNEL_IN_LEFT | AudioFormat.CHANNEL_IN_RIGHT; + public static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; + public static final int BYTES_PER_SAMPLE = 2; + + // Never read more than 1024 samples, even if the buffer is bigger (that would increase latency). + // A lower value is useless, since the system captures audio samples by blocks of 1024 (so for example if we read by blocks of 256 samples, we + // receive 4 successive blocks without waiting, then we wait for the 4 next ones). + public static final int MAX_READ_SIZE = 1024 * CHANNELS * BYTES_PER_SAMPLE; + + private static final long ONE_SAMPLE_US = (1000000 + SAMPLE_RATE - 1) / SAMPLE_RATE; // 1 sample in microseconds (used for fixing PTS) + + private final int audioSource; + + private AudioRecord recorder; + + private final AudioTimestamp timestamp = new AudioTimestamp(); + private long previousRecorderTimestamp = -1; + private long previousPts = 0; + private long nextPts = 0; + + public AudioCapture(AudioSource audioSource) { + this.audioSource = audioSource.value(); + } + + private static AudioFormat createAudioFormat() { + AudioFormat.Builder builder = new AudioFormat.Builder(); + builder.setEncoding(ENCODING); + builder.setSampleRate(SAMPLE_RATE); + builder.setChannelMask(CHANNEL_CONFIG); + return builder.build(); + } + + @TargetApi(Build.VERSION_CODES.M) + @SuppressLint({"WrongConstant", "MissingPermission"}) + private static AudioRecord createAudioRecord(int audioSource) { + AudioRecord.Builder builder = new AudioRecord.Builder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // On older APIs, Workarounds.fillAppInfo() must be called beforehand + builder.setContext(FakeContext.get()); + } + builder.setAudioSource(audioSource); + builder.setAudioFormat(createAudioFormat()); + int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, ENCODING); + // This buffer size does not impact latency + builder.setBufferSizeInBytes(8 * minBufferSize); + return builder.build(); + } + + private static void startWorkaroundAndroid11() { + // Android 11 requires Apps to be at foreground to record audio. + // Normally, each App has its own user ID, so Android checks whether the requesting App has the user ID that's at the foreground. + // But scrcpy server is NOT an App, it's a Java application started from Android shell, so it has the same user ID (2000) with Android + // shell ("com.android.shell"). + // If there is an Activity from Android shell running at foreground, then the permission system will believe scrcpy is also in the + // foreground. + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + intent.setComponent(new ComponentName(FakeContext.PACKAGE_NAME, "com.android.shell.HeapDumpActivity")); + ServiceManager.getActivityManager().startActivity(intent); + } + + private static void stopWorkaroundAndroid11() { + ServiceManager.getActivityManager().forceStopPackage(FakeContext.PACKAGE_NAME); + } + + private void tryStartRecording(int attempts, int delayMs) throws AudioCaptureForegroundException { + while (attempts-- > 0) { + // Wait for activity to start + SystemClock.sleep(delayMs); + try { + startRecording(); + return; // it worked + } catch (UnsupportedOperationException e) { + if (attempts == 0) { + Ln.e("Failed to start audio capture"); + Ln.e("On Android 11, audio capture must be started in the foreground, make sure that the device is unlocked when starting " + + "scrcpy."); + throw new AudioCaptureForegroundException(); + } else { + Ln.d("Failed to start audio capture, retrying..."); + } + } + } + } + + private void startRecording() { + try { + recorder = createAudioRecord(audioSource); + } catch (NullPointerException e) { + // Creating an AudioRecord using an AudioRecord.Builder does not work on Vivo phones: + // - + // - + recorder = Workarounds.createAudioRecord(audioSource, SAMPLE_RATE, CHANNEL_CONFIG, CHANNELS, CHANNEL_MASK, ENCODING); + } + recorder.startRecording(); + } + + public void start() throws AudioCaptureForegroundException { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + startWorkaroundAndroid11(); + try { + tryStartRecording(5, 100); + } finally { + stopWorkaroundAndroid11(); + } + } else { + startRecording(); + } + } + + public void stop() { + if (recorder != null) { + // Will call .stop() if necessary, without throwing an IllegalStateException + recorder.release(); + } + } + + @TargetApi(Build.VERSION_CODES.N) + public int read(ByteBuffer directBuffer, MediaCodec.BufferInfo outBufferInfo) { + int r = recorder.read(directBuffer, MAX_READ_SIZE); + if (r <= 0) { + return r; + } + + long pts; + + int ret = recorder.getTimestamp(timestamp, AudioTimestamp.TIMEBASE_MONOTONIC); + if (ret == AudioRecord.SUCCESS && timestamp.nanoTime != previousRecorderTimestamp) { + pts = timestamp.nanoTime / 1000; + previousRecorderTimestamp = timestamp.nanoTime; + } else { + if (nextPts == 0) { + Ln.w("Could not get initial audio timestamp"); + nextPts = System.nanoTime() / 1000; + } + // compute from previous timestamp and packet size + pts = nextPts; + } + + long durationUs = r * 1000000L / (CHANNELS * BYTES_PER_SAMPLE * SAMPLE_RATE); + nextPts = pts + durationUs; + + if (previousPts != 0 && pts < previousPts + ONE_SAMPLE_US) { + // Audio PTS may come from two sources: + // - recorder.getTimestamp() if the call works; + // - an estimation from the previous PTS and the packet size as a fallback. + // + // Therefore, the property that PTS are monotonically increasing is no guaranteed in corner cases, so enforce it. + pts = previousPts + ONE_SAMPLE_US; + } + previousPts = pts; + + outBufferInfo.set(0, r, pts, 0); + return r; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/AudioCaptureForegroundException.java b/server/src/main/java/org/cagnulein/android_remote/AudioCaptureForegroundException.java new file mode 100644 index 0000000..cd9e64b --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AudioCaptureForegroundException.java @@ -0,0 +1,7 @@ +package org.cagnulein.android_remote; + +/** + * Exception thrown if audio capture failed on Android 11 specifically because the running App (shell) was not in foreground. + */ +public class AudioCaptureForegroundException extends Exception { +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/AudioCodec.java b/server/src/main/java/org/cagnulein/android_remote/AudioCodec.java new file mode 100644 index 0000000..0eac5fa --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AudioCodec.java @@ -0,0 +1,49 @@ +package org.cagnulein.android_remote; + +import android.media.MediaFormat; + +public enum AudioCodec implements Codec { + OPUS(0x6f_70_75_73, "opus", MediaFormat.MIMETYPE_AUDIO_OPUS), + AAC(0x00_61_61_63, "aac", MediaFormat.MIMETYPE_AUDIO_AAC), + FLAC(0x66_6c_61_63, "flac", MediaFormat.MIMETYPE_AUDIO_FLAC), + RAW(0x00_72_61_77, "raw", MediaFormat.MIMETYPE_AUDIO_RAW); + + private final int id; // 4-byte ASCII representation of the name + private final String name; + private final String mimeType; + + AudioCodec(int id, String name, String mimeType) { + this.id = id; + this.name = name; + this.mimeType = mimeType; + } + + @Override + public Type getType() { + return Type.AUDIO; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getMimeType() { + return mimeType; + } + + public static AudioCodec findByName(String name) { + for (AudioCodec codec : values()) { + if (codec.name.equals(name)) { + return codec; + } + } + return null; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/AudioEncoder.java b/server/src/main/java/org/cagnulein/android_remote/AudioEncoder.java new file mode 100644 index 0000000..3963cf1 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AudioEncoder.java @@ -0,0 +1,329 @@ +package org.cagnulein.android_remote; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +public final class AudioEncoder implements AsyncProcessor { + + private static class InputTask { + private final int index; + + InputTask(int index) { + this.index = index; + } + } + + private static class OutputTask { + private final int index; + private final MediaCodec.BufferInfo bufferInfo; + + OutputTask(int index, MediaCodec.BufferInfo bufferInfo) { + this.index = index; + this.bufferInfo = bufferInfo; + } + } + + private static final int SAMPLE_RATE = AudioCapture.SAMPLE_RATE; + private static final int CHANNELS = AudioCapture.CHANNELS; + + private final AudioCapture capture; + private final Streamer streamer; + private final int bitRate; + private final List codecOptions; + private final String encoderName; + + // Capacity of 64 is in practice "infinite" (it is limited by the number of available MediaCodec buffers, typically 4). + // So many pending tasks would lead to an unacceptable delay anyway. + private final BlockingQueue inputTasks = new ArrayBlockingQueue<>(64); + private final BlockingQueue outputTasks = new ArrayBlockingQueue<>(64); + + private Thread thread; + private HandlerThread mediaCodecThread; + + private Thread inputThread; + private Thread outputThread; + + private boolean ended; + + public AudioEncoder(AudioCapture capture, Streamer streamer, int bitRate, List codecOptions, String encoderName) { + this.capture = capture; + this.streamer = streamer; + this.bitRate = bitRate; + this.codecOptions = codecOptions; + this.encoderName = encoderName; + } + + private static MediaFormat createFormat(String mimeType, int bitRate, List codecOptions) { + MediaFormat format = new MediaFormat(); + format.setString(MediaFormat.KEY_MIME, mimeType); + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); + format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); + format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE); + + if (codecOptions != null) { + for (CodecOption option : codecOptions) { + String key = option.getKey(); + Object value = option.getValue(); + CodecUtils.setCodecOption(format, key, value); + Ln.d("Audio codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value); + } + } + + return format; + } + + @TargetApi(Build.VERSION_CODES.N) + private void inputThread(MediaCodec mediaCodec, AudioCapture capture) throws IOException, InterruptedException { + final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + while (!Thread.currentThread().isInterrupted()) { + InputTask task = inputTasks.take(); + ByteBuffer buffer = mediaCodec.getInputBuffer(task.index); + int r = capture.read(buffer, bufferInfo); + if (r <= 0) { + throw new IOException("Could not read audio: " + r); + } + + mediaCodec.queueInputBuffer(task.index, bufferInfo.offset, bufferInfo.size, bufferInfo.presentationTimeUs, bufferInfo.flags); + } + } + + private void outputThread(MediaCodec mediaCodec) throws IOException, InterruptedException { + streamer.writeAudioHeader(); + + while (!Thread.currentThread().isInterrupted()) { + OutputTask task = outputTasks.take(); + ByteBuffer buffer = mediaCodec.getOutputBuffer(task.index); + try { + streamer.writePacket(buffer, task.bufferInfo); + } finally { + mediaCodec.releaseOutputBuffer(task.index, false); + } + } + } + + @Override + public void start(TerminationListener listener) { + thread = new Thread(() -> { + boolean fatalError = false; + try { + encode(); + } catch (ConfigurationException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + fatalError = true; + } catch (AudioCaptureForegroundException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + } catch (IOException e) { + Ln.e("Audio encoding error", e); + fatalError = true; + } finally { + Ln.d("Audio encoder stopped"); + listener.onTerminated(fatalError); + } + }, "audio-encoder"); + thread.start(); + } + + @Override + public void stop() { + if (thread != null) { + // Just wake up the blocking wait from the thread, so that it properly releases all its resources and terminates + end(); + } + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } + + private synchronized void end() { + ended = true; + notify(); + } + + private synchronized void waitEnded() { + try { + while (!ended) { + wait(); + } + } catch (InterruptedException e) { + // ignore + } + } + + @TargetApi(Build.VERSION_CODES.M) + public void encode() throws IOException, ConfigurationException, AudioCaptureForegroundException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Ln.w("Audio disabled: it is not supported before Android 11"); + streamer.writeDisableStream(false); + return; + } + + MediaCodec mediaCodec = null; + + boolean mediaCodecStarted = false; + try { + Codec codec = streamer.getCodec(); + mediaCodec = createMediaCodec(codec, encoderName); + + mediaCodecThread = new HandlerThread("media-codec"); + mediaCodecThread.start(); + + MediaFormat format = createFormat(codec.getMimeType(), bitRate, codecOptions); + mediaCodec.setCallback(new EncoderCallback(), new Handler(mediaCodecThread.getLooper())); + mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + + capture.start(); + + final MediaCodec mediaCodecRef = mediaCodec; + inputThread = new Thread(() -> { + try { + inputThread(mediaCodecRef, capture); + } catch (IOException | InterruptedException e) { + Ln.e("Audio capture error", e); + } finally { + end(); + } + }, "audio-in"); + + outputThread = new Thread(() -> { + try { + outputThread(mediaCodecRef); + } catch (InterruptedException e) { + // this is expected on close + } catch (IOException e) { + // Broken pipe is expected on close, because the socket is closed by the client + if (!IO.isBrokenPipe(e)) { + Ln.e("Audio encoding error", e); + } + } finally { + end(); + } + }, "audio-out"); + + mediaCodec.start(); + mediaCodecStarted = true; + inputThread.start(); + outputThread.start(); + + waitEnded(); + } catch (ConfigurationException e) { + // Notify the error to make scrcpy exit + streamer.writeDisableStream(true); + throw e; + } catch (Throwable e) { + // Notify the client that the audio could not be captured + streamer.writeDisableStream(false); + throw e; + } finally { + // Cleanup everything (either at the end or on error at any step of the initialization) + if (mediaCodecThread != null) { + Looper looper = mediaCodecThread.getLooper(); + if (looper != null) { + looper.quitSafely(); + } + } + if (inputThread != null) { + inputThread.interrupt(); + } + if (outputThread != null) { + outputThread.interrupt(); + } + + try { + if (mediaCodecThread != null) { + mediaCodecThread.join(); + } + if (inputThread != null) { + inputThread.join(); + } + if (outputThread != null) { + outputThread.join(); + } + } catch (InterruptedException e) { + // Should never happen + throw new AssertionError(e); + } + + if (mediaCodec != null) { + if (mediaCodecStarted) { + mediaCodec.stop(); + } + mediaCodec.release(); + } + if (capture != null) { + capture.stop(); + } + } + } + + private static MediaCodec createMediaCodec(Codec codec, String encoderName) throws IOException, ConfigurationException { + if (encoderName != null) { + Ln.d("Creating audio encoder by name: '" + encoderName + "'"); + try { + return MediaCodec.createByCodecName(encoderName); + } catch (IllegalArgumentException e) { + Ln.e("Audio encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildAudioEncoderListMessage()); + throw new ConfigurationException("Unknown encoder: " + encoderName); + } catch (IOException e) { + Ln.e("Could not create audio encoder '" + encoderName + "' for " + codec.getName() + "\n" + LogUtils.buildAudioEncoderListMessage()); + throw e; + } + } + + try { + MediaCodec mediaCodec = MediaCodec.createEncoderByType(codec.getMimeType()); + Ln.d("Using audio encoder: '" + mediaCodec.getName() + "'"); + return mediaCodec; + } catch (IOException | IllegalArgumentException e) { + Ln.e("Could not create default audio encoder for " + codec.getName() + "\n" + LogUtils.buildAudioEncoderListMessage()); + throw e; + } + } + + private final class EncoderCallback extends MediaCodec.Callback { + @TargetApi(Build.VERSION_CODES.N) + @Override + public void onInputBufferAvailable(MediaCodec codec, int index) { + try { + inputTasks.put(new InputTask(index)); + } catch (InterruptedException e) { + end(); + } + } + + @Override + public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo bufferInfo) { + try { + outputTasks.put(new OutputTask(index, bufferInfo)); + } catch (InterruptedException e) { + end(); + } + } + + @Override + public void onError(MediaCodec codec, MediaCodec.CodecException e) { + Ln.e("MediaCodec error", e); + end(); + } + + @Override + public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { + // ignore + } + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/AudioRawRecorder.java b/server/src/main/java/org/cagnulein/android_remote/AudioRawRecorder.java new file mode 100644 index 0000000..1f573a1 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AudioRawRecorder.java @@ -0,0 +1,93 @@ +package org.cagnulein.android_remote; + +import android.media.MediaCodec; +import android.os.Build; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public final class AudioRawRecorder implements AsyncProcessor { + + private final AudioCapture capture; + private final Streamer streamer; + + private Thread thread; + + public AudioRawRecorder(AudioCapture capture, Streamer streamer) { + this.capture = capture; + this.streamer = streamer; + } + + private void record() throws IOException, AudioCaptureForegroundException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Ln.w("Audio disabled: it is not supported before Android 11"); + streamer.writeDisableStream(false); + return; + } + + final ByteBuffer buffer = ByteBuffer.allocateDirect(AudioCapture.MAX_READ_SIZE); + final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + try { + try { + capture.start(); + } catch (Throwable t) { + // Notify the client that the audio could not be captured + streamer.writeDisableStream(false); + throw t; + } + + streamer.writeAudioHeader(); + while (!Thread.currentThread().isInterrupted()) { + buffer.position(0); + int r = capture.read(buffer, bufferInfo); + if (r < 0) { + throw new IOException("Could not read audio: " + r); + } + buffer.limit(r); + + streamer.writePacket(buffer, bufferInfo); + } + } catch (IOException e) { + // Broken pipe is expected on close, because the socket is closed by the client + if (!IO.isBrokenPipe(e)) { + Ln.e("Audio capture error", e); + } + } finally { + capture.stop(); + } + } + + @Override + public void start(TerminationListener listener) { + thread = new Thread(() -> { + boolean fatalError = false; + try { + record(); + } catch (AudioCaptureForegroundException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + } catch (Throwable t) { + Ln.e("Audio recording error", t); + fatalError = true; + } finally { + Ln.d("Audio recorder stopped"); + listener.onTerminated(fatalError); + } + }, "audio-raw"); + thread.start(); + } + + @Override + public void stop() { + if (thread != null) { + thread.interrupt(); + } + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/AudioSource.java b/server/src/main/java/org/cagnulein/android_remote/AudioSource.java new file mode 100644 index 0000000..f094032 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/AudioSource.java @@ -0,0 +1,30 @@ +package org.cagnulein.android_remote; + +import android.media.MediaRecorder; + +public enum AudioSource { + OUTPUT("output", MediaRecorder.AudioSource.REMOTE_SUBMIX), + MIC("mic", MediaRecorder.AudioSource.MIC); + + private final String name; + private final int value; + + AudioSource(String name, int value) { + this.name = name; + this.value = value; + } + + int value() { + return value; + } + + static AudioSource findByName(String name) { + for (AudioSource audioSource : AudioSource.values()) { + if (name.equals(audioSource.name)) { + return audioSource; + } + } + + return null; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/Codec.java b/server/src/main/java/org/cagnulein/android_remote/Codec.java new file mode 100644 index 0000000..d73cb52 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/Codec.java @@ -0,0 +1,17 @@ +package org.cagnulein.android_remote; + +public interface Codec { + + enum Type { + VIDEO, + AUDIO, + } + + Type getType(); + + int getId(); + + String getName(); + + String getMimeType(); +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/CodecOption.java b/server/src/main/java/org/cagnulein/android_remote/CodecOption.java new file mode 100644 index 0000000..9ad153d --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/CodecOption.java @@ -0,0 +1,112 @@ +package org.cagnulein.android_remote; + +import java.util.ArrayList; +import java.util.List; + +public class CodecOption { + private final String key; + private final Object value; + + public CodecOption(String key, Object value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public Object getValue() { + return value; + } + + public static List parse(String codecOptions) { + if (codecOptions.isEmpty()) { + return null; + } + + List result = new ArrayList<>(); + + boolean escape = false; + StringBuilder buf = new StringBuilder(); + + for (char c : codecOptions.toCharArray()) { + switch (c) { + case '\\': + if (escape) { + buf.append('\\'); + escape = false; + } else { + escape = true; + } + break; + case ',': + if (escape) { + buf.append(','); + escape = false; + } else { + // This comma is a separator between codec options + String codecOption = buf.toString(); + result.add(parseOption(codecOption)); + // Clear buf + buf.setLength(0); + } + break; + default: + buf.append(c); + break; + } + } + + if (buf.length() > 0) { + String codecOption = buf.toString(); + result.add(parseOption(codecOption)); + } + + return result; + } + + private static CodecOption parseOption(String option) { + int equalSignIndex = option.indexOf('='); + if (equalSignIndex == -1) { + throw new IllegalArgumentException("'=' expected"); + } + String keyAndType = option.substring(0, equalSignIndex); + if (keyAndType.length() == 0) { + throw new IllegalArgumentException("Key may not be null"); + } + + String key; + String type; + + int colonIndex = keyAndType.indexOf(':'); + if (colonIndex != -1) { + key = keyAndType.substring(0, colonIndex); + type = keyAndType.substring(colonIndex + 1); + } else { + key = keyAndType; + type = "int"; // assume int by default + } + + Object value; + String valueString = option.substring(equalSignIndex + 1); + switch (type) { + case "int": + value = Integer.parseInt(valueString); + break; + case "long": + value = Long.parseLong(valueString); + break; + case "float": + value = Float.parseFloat(valueString); + break; + case "string": + value = valueString; + break; + default: + throw new IllegalArgumentException("Invalid codec option type (int, long, float, str): " + type); + } + + return new CodecOption(key, value); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/CodecUtils.java b/server/src/main/java/org/cagnulein/android_remote/CodecUtils.java new file mode 100644 index 0000000..839d875 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/CodecUtils.java @@ -0,0 +1,78 @@ +package org.cagnulein.android_remote; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class CodecUtils { + + public static final class DeviceEncoder { + private final Codec codec; + private final MediaCodecInfo info; + + DeviceEncoder(Codec codec, MediaCodecInfo info) { + this.codec = codec; + this.info = info; + } + + public Codec getCodec() { + return codec; + } + + public MediaCodecInfo getInfo() { + return info; + } + } + + private CodecUtils() { + // not instantiable + } + + public static void setCodecOption(MediaFormat format, String key, Object value) { + if (value instanceof Integer) { + format.setInteger(key, (Integer) value); + } else if (value instanceof Long) { + format.setLong(key, (Long) value); + } else if (value instanceof Float) { + format.setFloat(key, (Float) value); + } else if (value instanceof String) { + format.setString(key, (String) value); + } + } + + private static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) { + List result = new ArrayList<>(); + for (MediaCodecInfo codecInfo : codecs.getCodecInfos()) { + if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(mimeType)) { + result.add(codecInfo); + } + } + return result.toArray(new MediaCodecInfo[result.size()]); + } + + public static List listVideoEncoders() { + List encoders = new ArrayList<>(); + MediaCodecList codecs = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (VideoCodec codec : VideoCodec.values()) { + for (MediaCodecInfo info : getEncoders(codecs, codec.getMimeType())) { + encoders.add(new DeviceEncoder(codec, info)); + } + } + return encoders; + } + + public static List listAudioEncoders() { + List encoders = new ArrayList<>(); + MediaCodecList codecs = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (AudioCodec codec : AudioCodec.values()) { + for (MediaCodecInfo info : getEncoders(codecs, codec.getMimeType())) { + encoders.add(new DeviceEncoder(codec, info)); + } + } + return encoders; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/Command.java b/server/src/main/java/org/cagnulein/android_remote/Command.java new file mode 100644 index 0000000..531670b --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/Command.java @@ -0,0 +1,43 @@ +package org.cagnulein.android_remote; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Scanner; + +public final class Command { + private Command() { + // not instantiable + } + + public static void exec(String... cmd) throws IOException, InterruptedException { + Process process = Runtime.getRuntime().exec(cmd); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode); + } + } + + public static String execReadLine(String... cmd) throws IOException, InterruptedException { + String result = null; + Process process = Runtime.getRuntime().exec(cmd); + Scanner scanner = new Scanner(process.getInputStream()); + if (scanner.hasNextLine()) { + result = scanner.nextLine(); + } + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode); + } + return result; + } + + public static String execReadOutput(String... cmd) throws IOException, InterruptedException { + Process process = Runtime.getRuntime().exec(cmd); + String output = IO.toString(process.getInputStream()); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode); + } + return output; + } +} diff --git a/server/src/main/java/org/cagnulein/android_remote/ConfigurationException.java b/server/src/main/java/org/cagnulein/android_remote/ConfigurationException.java new file mode 100644 index 0000000..33d2603 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/ConfigurationException.java @@ -0,0 +1,7 @@ +package org.cagnulein.android_remote; + +public class ConfigurationException extends Exception { + public ConfigurationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/Device.java b/server/src/main/java/org/cagnulein/android_remote/Device.java index a9f5ba9..1a71e31 100644 --- a/server/src/main/java/org/cagnulein/android_remote/Device.java +++ b/server/src/main/java/org/cagnulein/android_remote/Device.java @@ -14,10 +14,11 @@ public final class Device { - private final ServiceManager serviceManager = new ServiceManager(); private ScreenInfo screenInfo; private RotationListener rotationListener; + private final int displayId; + public Device(Options options) { screenInfo = computeScreenInfo(options.getMaxSize()); setScreenPowerMode(POWER_MODE_OFF); @@ -51,7 +52,8 @@ private ScreenInfo computeScreenInfo(int maxSize) { // - scale down the great side of the screen to maxSize (if necessary); // - scale down the other side so that the aspect ratio is preserved; // - round this value to the nearest multiple of 8 (H.264 only accepts multiples of 8) - DisplayInfo displayInfo = serviceManager.getDisplayManager().getDisplayInfo(); + //displayId = options.getDisplayId(); + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(/*displayId*/0); boolean rotated = (displayInfo.getRotation() & 1) != 0; Size deviceSize = displayInfo.getSize(); int w = deviceSize.getWidth() & ~7; // in case it's not a multiple of 8 @@ -91,15 +93,15 @@ public Point getPhysicalPoint(Position position) { } public boolean injectInputEvent(InputEvent inputEvent, int mode) { - return serviceManager.getInputManager().injectInputEvent(inputEvent, mode); + return ServiceManager.getInputManager().injectInputEvent(inputEvent, mode); } public boolean isScreenOn() { - return serviceManager.getPowerManager().isScreenOn(); + return ServiceManager.getPowerManager().isScreenOn(); } public void registerRotationWatcher(IRotationWatcher rotationWatcher) { - serviceManager.getWindowManager().registerRotationWatcher(rotationWatcher); + ServiceManager.getWindowManager().registerRotationWatcher(rotationWatcher); } public synchronized void setRotationListener(RotationListener rotationListener) { diff --git a/server/src/main/java/org/cagnulein/android_remote/DisplayInfo.java b/server/src/main/java/org/cagnulein/android_remote/DisplayInfo.java index e7b1dbb..3762aa6 100644 --- a/server/src/main/java/org/cagnulein/android_remote/DisplayInfo.java +++ b/server/src/main/java/org/cagnulein/android_remote/DisplayInfo.java @@ -1,12 +1,24 @@ package org.cagnulein.android_remote; public final class DisplayInfo { + private final int displayId; private final Size size; private final int rotation; + private final int layerStack; + private final int flags; - public DisplayInfo(Size size, int rotation) { + public static final int FLAG_SUPPORTS_PROTECTED_BUFFERS = 0x00000001; + + public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags) { + this.displayId = displayId; this.size = size; this.rotation = rotation; + this.layerStack = layerStack; + this.flags = flags; + } + + public int getDisplayId() { + return displayId; } public Size getSize() { @@ -16,5 +28,13 @@ public Size getSize() { public int getRotation() { return rotation; } + + public int getLayerStack() { + return layerStack; + } + + public int getFlags() { + return flags; + } } diff --git a/server/src/main/java/org/cagnulein/android_remote/FakeContext.java b/server/src/main/java/org/cagnulein/android_remote/FakeContext.java new file mode 100644 index 0000000..16e0bda --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/FakeContext.java @@ -0,0 +1,53 @@ +package org.cagnulein.android_remote; + +import android.annotation.TargetApi; +import android.content.AttributionSource; +import android.content.Context; +import android.content.ContextWrapper; +import android.os.Build; +import android.os.Process; + +public final class FakeContext extends ContextWrapper { + + public static final String PACKAGE_NAME = "com.android.shell"; + public static final int ROOT_UID = 0; // Like android.os.Process.ROOT_UID, but before API 29 + + private static final FakeContext INSTANCE = new FakeContext(); + + public static FakeContext get() { + return INSTANCE; + } + + private FakeContext() { + super(Workarounds.getSystemContext()); + } + + @Override + public String getPackageName() { + return PACKAGE_NAME; + } + + @Override + public String getOpPackageName() { + return PACKAGE_NAME; + } + + @TargetApi(Build.VERSION_CODES.S) + @Override + public AttributionSource getAttributionSource() { + AttributionSource.Builder builder = new AttributionSource.Builder(Process.SHELL_UID); + builder.setPackageName(PACKAGE_NAME); + return builder.build(); + } + + // @Override to be added on SDK upgrade for Android 14 + @SuppressWarnings("unused") + public int getDeviceId() { + return 0; + } + + @Override + public Context getApplicationContext() { + return this; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/IO.java b/server/src/main/java/org/cagnulein/android_remote/IO.java new file mode 100644 index 0000000..4c51226 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/IO.java @@ -0,0 +1,56 @@ +package org.cagnulein.android_remote; + +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Scanner; + +public final class IO { + private IO() { + // not instantiable + } + + public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException { + // ByteBuffer position is not updated as expected by Os.write() on old Android versions, so + // count the remaining bytes manually. + // See . + int remaining = from.remaining(); + while (remaining > 0) { + try { + int w = Os.write(fd, from); + if (BuildConfig.DEBUG && w < 0) { + // w should not be negative, since an exception is thrown on error + throw new AssertionError("Os.write() returned a negative value (" + w + ")"); + } + remaining -= w; + } catch (ErrnoException e) { + if (e.errno != OsConstants.EINTR) { + throw new IOException(e); + } + } + } + } + + public static void writeFully(FileDescriptor fd, byte[] buffer, int offset, int len) throws IOException { + writeFully(fd, ByteBuffer.wrap(buffer, offset, len)); + } + + public static String toString(InputStream inputStream) { + StringBuilder builder = new StringBuilder(); + Scanner scanner = new Scanner(inputStream); + while (scanner.hasNextLine()) { + builder.append(scanner.nextLine()).append('\n'); + } + return builder.toString(); + } + + public static boolean isBrokenPipe(IOException e) { + Throwable cause = e.getCause(); + return cause instanceof ErrnoException && ((ErrnoException) cause).errno == OsConstants.EPIPE; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/Ln.java b/server/src/main/java/org/cagnulein/android_remote/Ln.java index 8e77d2e..80915d2 100644 --- a/server/src/main/java/org/cagnulein/android_remote/Ln.java +++ b/server/src/main/java/org/cagnulein/android_remote/Ln.java @@ -2,7 +2,10 @@ import android.util.Log; -import com.genymobile.scrcpy.BuildConfig; +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; /** * Log both to Android logger (so that logs are visible in "adb logcat") and standard output/error (so that they are visible in the terminal @@ -11,49 +14,105 @@ public final class Ln { private static final String TAG = "scrcpy"; - private static final Level THRESHOLD = BuildConfig.DEBUG ? Level.DEBUG : Level.INFO; + private static final String PREFIX = "[server] "; + + private static final PrintStream CONSOLE_OUT = new PrintStream(new FileOutputStream(FileDescriptor.out)); + private static final PrintStream CONSOLE_ERR = new PrintStream(new FileOutputStream(FileDescriptor.err)); + + enum Level { + VERBOSE, DEBUG, INFO, WARN, ERROR + } + + private static Level threshold = Level.INFO; private Ln() { // not instantiable } + public static void disableSystemStreams() { + PrintStream nullStream = new PrintStream(new NullOutputStream()); + System.setOut(nullStream); + System.setErr(nullStream); + } + + /** + * Initialize the log level. + *

+ * Must be called before starting any new thread. + * + * @param level the log level + */ + public static void initLogLevel(Level level) { + threshold = level; + } + public static boolean isEnabled(Level level) { - return level.ordinal() >= THRESHOLD.ordinal(); + return level.ordinal() >= threshold.ordinal(); + } + + public static void v(String message) { + if (isEnabled(Level.VERBOSE)) { + Log.v(TAG, message); + CONSOLE_OUT.print(PREFIX + "VERBOSE: " + message + '\n'); + } } public static void d(String message) { if (isEnabled(Level.DEBUG)) { Log.d(TAG, message); - System.out.println("DEBUG: " + message); + CONSOLE_OUT.print(PREFIX + "DEBUG: " + message + '\n'); } } public static void i(String message) { if (isEnabled(Level.INFO)) { Log.i(TAG, message); - System.out.println("INFO: " + message); + CONSOLE_OUT.print(PREFIX + "INFO: " + message + '\n'); } } - public static void w(String message) { + public static void w(String message, Throwable throwable) { if (isEnabled(Level.WARN)) { - Log.w(TAG, message); - System.out.println("WARN: " + message); + Log.w(TAG, message, throwable); + CONSOLE_ERR.print(PREFIX + "WARN: " + message + '\n'); + if (throwable != null) { + throwable.printStackTrace(CONSOLE_ERR); + } } } + public static void w(String message) { + w(message, null); + } + public static void e(String message, Throwable throwable) { if (isEnabled(Level.ERROR)) { Log.e(TAG, message, throwable); - System.out.println("ERROR: " + message); - throwable.printStackTrace(); + CONSOLE_ERR.print(PREFIX + "ERROR: " + message + '\n'); + if (throwable != null) { + throwable.printStackTrace(CONSOLE_ERR); + } } } - enum Level { - DEBUG, - INFO, - WARN, - ERROR; + public static void e(String message) { + e(message, null); + } + + static class NullOutputStream extends OutputStream { + @Override + public void write(byte[] b) { + // ignore + } + + @Override + public void write(byte[] b, int off, int len) { + // ignore + } + + @Override + public void write(int b) { + // ignore + } } -} +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/LogUtils.java b/server/src/main/java/org/cagnulein/android_remote/LogUtils.java new file mode 100644 index 0000000..1333628 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/LogUtils.java @@ -0,0 +1,151 @@ +package org.cagnulein.android_remote; + +import org.cagnulein.android_remote.wrappers.DisplayManager; +import org.cagnulein.android_remote.wrappers.ServiceManager; + +import android.graphics.Rect; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.MediaCodec; +import android.util.Range; + +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; + +public final class LogUtils { + + private LogUtils() { + // not instantiable + } + + public static String buildVideoEncoderListMessage() { + StringBuilder builder = new StringBuilder("List of video encoders:"); + List videoEncoders = CodecUtils.listVideoEncoders(); + if (videoEncoders.isEmpty()) { + builder.append("\n (none)"); + } else { + for (CodecUtils.DeviceEncoder encoder : videoEncoders) { + builder.append("\n --video-codec=").append(encoder.getCodec().getName()); + builder.append(" --video-encoder='").append(encoder.getInfo().getName()).append("'"); + } + } + return builder.toString(); + } + + public static String buildAudioEncoderListMessage() { + StringBuilder builder = new StringBuilder("List of audio encoders:"); + List audioEncoders = CodecUtils.listAudioEncoders(); + if (audioEncoders.isEmpty()) { + builder.append("\n (none)"); + } else { + for (CodecUtils.DeviceEncoder encoder : audioEncoders) { + builder.append("\n --audio-codec=").append(encoder.getCodec().getName()); + builder.append(" --audio-encoder='").append(encoder.getInfo().getName()).append("'"); + } + } + return builder.toString(); + } + + public static String buildDisplayListMessage() { + StringBuilder builder = new StringBuilder("List of displays:"); + DisplayManager displayManager = ServiceManager.getDisplayManager(); + int[] displayIds = displayManager.getDisplayIds(); + if (displayIds == null || displayIds.length == 0) { + builder.append("\n (none)"); + } else { + for (int id : displayIds) { + builder.append("\n --display-id=").append(id).append(" ("); + DisplayInfo displayInfo = displayManager.getDisplayInfo(id); + if (displayInfo != null) { + Size size = displayInfo.getSize(); + builder.append(size.getWidth()).append("x").append(size.getHeight()); + } else { + builder.append("size unknown"); + } + builder.append(")"); + } + } + return builder.toString(); + } + + private static String getCameraFacingName(int facing) { + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + return "front"; + case CameraCharacteristics.LENS_FACING_BACK: + return "back"; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + return "external"; + default: + return "unknown"; + } + } + + public static String buildCameraListMessage(boolean includeSizes) { + StringBuilder builder = new StringBuilder("List of cameras:"); + CameraManager cameraManager = ServiceManager.getCameraManager(); + try { + String[] cameraIds = cameraManager.getCameraIdList(); + if (cameraIds == null || cameraIds.length == 0) { + builder.append("\n (none)"); + } else { + for (String id : cameraIds) { + builder.append("\n --camera-id=").append(id); + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); + + int facing = characteristics.get(CameraCharacteristics.LENS_FACING); + builder.append(" (").append(getCameraFacingName(facing)).append(", "); + + Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + builder.append(activeSize.width()).append("x").append(activeSize.height()); + + try { + // Capture frame rates for low-FPS mode are the same for every resolution + Range[] lowFpsRanges = characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES); + SortedSet uniqueLowFps = getUniqueSet(lowFpsRanges); + builder.append(", fps=").append(uniqueLowFps); + } catch (Exception e) { + // Some devices may provide invalid ranges, causing an IllegalArgumentException "lower must be less than or equal to upper" + Ln.w("Could not get available frame rates for camera " + id, e); + } + + builder.append(')'); + + if (includeSizes) { + StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + + android.util.Size[] sizes = configs.getOutputSizes(MediaCodec.class); + for (android.util.Size size : sizes) { + builder.append("\n - ").append(size.getWidth()).append('x').append(size.getHeight()); + } + + android.util.Size[] highSpeedSizes = configs.getHighSpeedVideoSizes(); + if (highSpeedSizes.length > 0) { + builder.append("\n High speed capture (--camera-high-speed):"); + for (android.util.Size size : highSpeedSizes) { + Range[] highFpsRanges = configs.getHighSpeedVideoFpsRanges(); + SortedSet uniqueHighFps = getUniqueSet(highFpsRanges); + builder.append("\n - ").append(size.getWidth()).append("x").append(size.getHeight()); + builder.append(" (fps=").append(uniqueHighFps).append(')'); + } + } + } + } + } + } catch (CameraAccessException e) { + builder.append("\n (access denied)"); + } + return builder.toString(); + } + + private static SortedSet getUniqueSet(Range[] ranges) { + SortedSet set = new TreeSet<>(); + for (Range range : ranges) { + set.add(range.getUpper()); + } + return set; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/SettingsException.java b/server/src/main/java/org/cagnulein/android_remote/SettingsException.java new file mode 100644 index 0000000..dabc888 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/SettingsException.java @@ -0,0 +1,11 @@ +package org.cagnulein.android_remote; + +public class SettingsException extends Exception { + private static String createMessage(String method, String table, String key, String value) { + return "Could not access settings: " + method + " " + table + " " + key + (value != null ? " " + value : ""); + } + + public SettingsException(String method, String table, String key, String value, Throwable cause) { + super(createMessage(method, table, key, value), cause); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/Streamer.java b/server/src/main/java/org/cagnulein/android_remote/Streamer.java new file mode 100644 index 0000000..ff87481 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/Streamer.java @@ -0,0 +1,186 @@ +package org.cagnulein.android_remote; + +import android.media.MediaCodec; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +public final class Streamer { + + private static final long PACKET_FLAG_CONFIG = 1L << 63; + private static final long PACKET_FLAG_KEY_FRAME = 1L << 62; + + private final FileDescriptor fd; + private final Codec codec; + private final boolean sendCodecMeta; + private final boolean sendFrameMeta; + + private final ByteBuffer headerBuffer = ByteBuffer.allocate(12); + + public Streamer(FileDescriptor fd, Codec codec, boolean sendCodecMeta, boolean sendFrameMeta) { + this.fd = fd; + this.codec = codec; + this.sendCodecMeta = sendCodecMeta; + this.sendFrameMeta = sendFrameMeta; + } + + public Codec getCodec() { + return codec; + } + + public void writeAudioHeader() throws IOException { + if (sendCodecMeta) { + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.putInt(codec.getId()); + buffer.flip(); + IO.writeFully(fd, buffer); + } + } + + public void writeVideoHeader(Size videoSize) throws IOException { + if (sendCodecMeta) { + ByteBuffer buffer = ByteBuffer.allocate(12); + buffer.putInt(codec.getId()); + buffer.putInt(videoSize.getWidth()); + buffer.putInt(videoSize.getHeight()); + buffer.flip(); + IO.writeFully(fd, buffer); + } + } + + public void writeDisableStream(boolean error) throws IOException { + // Writing a specific code as codec-id means that the device disables the stream + // code 0: it explicitly disables the stream (because it could not capture audio), scrcpy should continue mirroring video only + // code 1: a configuration error occurred, scrcpy must be stopped + byte[] code = new byte[4]; + if (error) { + code[3] = 1; + } + IO.writeFully(fd, code, 0, code.length); + } + + public void writePacket(ByteBuffer buffer, long pts, boolean config, boolean keyFrame) throws IOException { + if (config) { + if (codec == AudioCodec.OPUS) { + fixOpusConfigPacket(buffer); + } else if (codec == AudioCodec.FLAC) { + fixFlacConfigPacket(buffer); + } + } + + if (sendFrameMeta) { + writeFrameMeta(fd, buffer.remaining(), pts, config, keyFrame); + } + + IO.writeFully(fd, buffer); + } + + public void writePacket(ByteBuffer codecBuffer, MediaCodec.BufferInfo bufferInfo) throws IOException { + long pts = bufferInfo.presentationTimeUs; + boolean config = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0; + boolean keyFrame = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0; + writePacket(codecBuffer, pts, config, keyFrame); + } + + private void writeFrameMeta(FileDescriptor fd, int packetSize, long pts, boolean config, boolean keyFrame) throws IOException { + headerBuffer.clear(); + + long ptsAndFlags; + if (config) { + ptsAndFlags = PACKET_FLAG_CONFIG; // non-media data packet + } else { + ptsAndFlags = pts; + if (keyFrame) { + ptsAndFlags |= PACKET_FLAG_KEY_FRAME; + } + } + + headerBuffer.putLong(ptsAndFlags); + headerBuffer.putInt(packetSize); + headerBuffer.flip(); + IO.writeFully(fd, headerBuffer); + } + + private static void fixOpusConfigPacket(ByteBuffer buffer) throws IOException { + // Here is an example of the config packet received for an OPUS stream: + // + // 00000000 41 4f 50 55 53 48 44 52 13 00 00 00 00 00 00 00 |AOPUSHDR........| + // -------------- BELOW IS THE PART WE MUST PUT AS EXTRADATA ------------------- + // 00000010 4f 70 75 73 48 65 61 64 01 01 38 01 80 bb 00 00 |OpusHead..8.....| + // 00000020 00 00 00 |... | + // ------------------------------------------------------------------------------ + // 00000020 41 4f 50 55 53 44 4c 59 08 00 00 00 00 | AOPUSDLY.....| + // 00000030 00 00 00 a0 2e 63 00 00 00 00 00 41 4f 50 55 53 |.....c.....AOPUS| + // 00000040 50 52 4c 08 00 00 00 00 00 00 00 00 b4 c4 04 00 |PRL.............| + // 00000050 00 00 00 |...| + // + // Each "section" is prefixed by a 64-bit ID and a 64-bit length. + // + // + + if (buffer.remaining() < 16) { + throw new IOException("Not enough data in OPUS config packet"); + } + + final byte[] opusHeaderId = {'A', 'O', 'P', 'U', 'S', 'H', 'D', 'R'}; + byte[] idBuffer = new byte[8]; + buffer.get(idBuffer); + if (!Arrays.equals(idBuffer, opusHeaderId)) { + throw new IOException("OPUS header not found"); + } + + // The size is in native byte-order + long sizeLong = buffer.getLong(); + if (sizeLong < 0 || sizeLong >= 0x7FFFFFFF) { + throw new IOException("Invalid block size in OPUS header: " + sizeLong); + } + + int size = (int) sizeLong; + if (buffer.remaining() < size) { + throw new IOException("Not enough data in OPUS header (invalid size: " + size + ")"); + } + + // Set the buffer to point to the OPUS header slice + buffer.limit(buffer.position() + size); + } + + private static void fixFlacConfigPacket(ByteBuffer buffer) throws IOException { + // 00000000 66 4c 61 43 00 00 00 22 |fLaC..." | + // -------------- BELOW IS THE PART WE MUST PUT AS EXTRADATA ------------------- + // 00000000 10 00 10 00 00 00 00 00 | ........| + // 00000010 00 00 0b b8 02 f0 00 00 00 00 00 00 00 00 00 00 |................| + // 00000020 00 00 00 00 00 00 00 00 00 00 |.......... | + // ------------------------------------------------------------------------------ + // 00000020 84 00 00 28 20 00 | ...( .| + // 00000030 00 00 72 65 66 65 72 65 6e 63 65 20 6c 69 62 46 |..reference libF| + // 00000040 4c 41 43 20 31 2e 33 2e 32 20 32 30 32 32 31 30 |LAC 1.3.2 202210| + // 00000050 32 32 00 00 00 00 |22....| + // + // + + if (buffer.remaining() < 8) { + throw new IOException("Not enough data in FLAC config packet"); + } + + final byte[] flacHeaderId = {'f', 'L', 'a', 'C'}; + byte[] idBuffer = new byte[4]; + buffer.get(idBuffer); + if (!Arrays.equals(idBuffer, flacHeaderId)) { + throw new IOException("FLAC header not found"); + } + + // The size is in big-endian + buffer.order(ByteOrder.BIG_ENDIAN); + + int size = buffer.getInt(); + if (buffer.remaining() < size) { + throw new IOException("Not enough data in FLAC header (invalid size: " + size + ")"); + } + + // Set the buffer to point to the FLAC header slice + buffer.limit(buffer.position() + size); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/VideoCodec.java b/server/src/main/java/org/cagnulein/android_remote/VideoCodec.java new file mode 100644 index 0000000..0d8f642 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/VideoCodec.java @@ -0,0 +1,50 @@ +package org.cagnulein.android_remote; + +import android.annotation.SuppressLint; +import android.media.MediaFormat; + +public enum VideoCodec implements Codec { + H264(0x68_32_36_34, "h264", MediaFormat.MIMETYPE_VIDEO_AVC), + H265(0x68_32_36_35, "h265", MediaFormat.MIMETYPE_VIDEO_HEVC), + @SuppressLint("InlinedApi") // introduced in API 29 + AV1(0x00_61_76_31, "av1", MediaFormat.MIMETYPE_VIDEO_AV1); + + private final int id; // 4-byte ASCII representation of the name + private final String name; + private final String mimeType; + + VideoCodec(int id, String name, String mimeType) { + this.id = id; + this.name = name; + this.mimeType = mimeType; + } + + @Override + public Type getType() { + return Type.VIDEO; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getMimeType() { + return mimeType; + } + + public static VideoCodec findByName(String name) { + for (VideoCodec codec : values()) { + if (codec.name.equals(name)) { + return codec; + } + } + return null; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/Workarounds.java b/server/src/main/java/org/cagnulein/android_remote/Workarounds.java new file mode 100644 index 0000000..6c28adc --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/Workarounds.java @@ -0,0 +1,339 @@ +package org.cagnulein.android_remote; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Application; +import android.content.AttributionSource; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.pm.ApplicationInfo; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.os.Build; +import android.os.Looper; +import android.os.Parcel; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi,BlockedPrivateApi,SoonBlockedPrivateApi,DiscouragedPrivateApi") +public final class Workarounds { + + private static final Class ACTIVITY_THREAD_CLASS; + private static final Object ACTIVITY_THREAD; + + static { + prepareMainLooper(); + + try { + // ActivityThread activityThread = new ActivityThread(); + ACTIVITY_THREAD_CLASS = Class.forName("android.app.ActivityThread"); + Constructor activityThreadConstructor = ACTIVITY_THREAD_CLASS.getDeclaredConstructor(); + activityThreadConstructor.setAccessible(true); + ACTIVITY_THREAD = activityThreadConstructor.newInstance(); + + // ActivityThread.sCurrentActivityThread = activityThread; + Field sCurrentActivityThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("sCurrentActivityThread"); + sCurrentActivityThreadField.setAccessible(true); + sCurrentActivityThreadField.set(null, ACTIVITY_THREAD); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private Workarounds() { + // not instantiable + } + + public static void apply(boolean audio, boolean camera) { + boolean mustFillConfigurationController = false; + boolean mustFillAppInfo = false; + boolean mustFillAppContext = false; + + if (Build.BRAND.equalsIgnoreCase("meizu")) { + // Workarounds must be applied for Meizu phones: + // - + // - + // - + // + // But only apply when strictly necessary, since workarounds can cause other issues: + // - + // - + mustFillAppInfo = true; + } else if (Build.BRAND.equalsIgnoreCase("honor")) { + // More workarounds must be applied for Honor devices: + // - + // + // The system context must not be set for all devices, because it would cause other problems: + // - + // - + mustFillAppInfo = true; + mustFillAppContext = true; + } + + if (audio && Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + // Before Android 11, audio is not supported. + // Since Android 12, we can properly set a context on the AudioRecord. + // Only on Android 11 we must fill the application context for the AudioRecord to work. + mustFillAppContext = true; + } + + if (camera) { + mustFillAppInfo = true; + mustFillAppContext = true; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // On some Samsung devices, DisplayManagerGlobal.getDisplayInfoLocked() calls ActivityThread.currentActivityThread().getConfiguration(), + // which requires a non-null ConfigurationController. + // ConfigurationController was introduced in Android 12, so do not attempt to set it on lower versions. + // + mustFillConfigurationController = true; + } + + if (mustFillConfigurationController) { + // Must be call before fillAppContext() because it is necessary to get a valid system context + fillConfigurationController(); + } + if (mustFillAppInfo) { + fillAppInfo(); + } + if (mustFillAppContext) { + fillAppContext(); + } + } + + @SuppressWarnings("deprecation") + private static void prepareMainLooper() { + // Some devices internally create a Handler when creating an input Surface, causing an exception: + // "Can't create handler inside thread that has not called Looper.prepare()" + // + // + // Use Looper.prepareMainLooper() instead of Looper.prepare() to avoid a NullPointerException: + // "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue' + // on a null object reference" + // + Looper.prepareMainLooper(); + } + + private static void fillAppInfo() { + try { + // ActivityThread.AppBindData appBindData = new ActivityThread.AppBindData(); + Class appBindDataClass = Class.forName("android.app.ActivityThread$AppBindData"); + Constructor appBindDataConstructor = appBindDataClass.getDeclaredConstructor(); + appBindDataConstructor.setAccessible(true); + Object appBindData = appBindDataConstructor.newInstance(); + + ApplicationInfo applicationInfo = new ApplicationInfo(); + applicationInfo.packageName = FakeContext.PACKAGE_NAME; + + // appBindData.appInfo = applicationInfo; + Field appInfoField = appBindDataClass.getDeclaredField("appInfo"); + appInfoField.setAccessible(true); + appInfoField.set(appBindData, applicationInfo); + + // activityThread.mBoundApplication = appBindData; + Field mBoundApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mBoundApplication"); + mBoundApplicationField.setAccessible(true); + mBoundApplicationField.set(ACTIVITY_THREAD, appBindData); + } catch (Throwable throwable) { + // this is a workaround, so failing is not an error + Ln.d("Could not fill app info: " + throwable.getMessage()); + } + } + + private static void fillAppContext() { + try { + Application app = new Application(); + Field baseField = ContextWrapper.class.getDeclaredField("mBase"); + baseField.setAccessible(true); + baseField.set(app, FakeContext.get()); + + // activityThread.mInitialApplication = app; + Field mInitialApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mInitialApplication"); + mInitialApplicationField.setAccessible(true); + mInitialApplicationField.set(ACTIVITY_THREAD, app); + } catch (Throwable throwable) { + // this is a workaround, so failing is not an error + Ln.d("Could not fill app context: " + throwable.getMessage()); + } + } + + private static void fillConfigurationController() { + try { + Class configurationControllerClass = Class.forName("android.app.ConfigurationController"); + Class activityThreadInternalClass = Class.forName("android.app.ActivityThreadInternal"); + Constructor configurationControllerConstructor = configurationControllerClass.getDeclaredConstructor(activityThreadInternalClass); + configurationControllerConstructor.setAccessible(true); + Object configurationController = configurationControllerConstructor.newInstance(ACTIVITY_THREAD); + + Field configurationControllerField = ACTIVITY_THREAD_CLASS.getDeclaredField("mConfigurationController"); + configurationControllerField.setAccessible(true); + configurationControllerField.set(ACTIVITY_THREAD, configurationController); + } catch (Throwable throwable) { + Ln.d("Could not fill configuration: " + throwable.getMessage()); + } + } + + static Context getSystemContext() { + try { + Method getSystemContextMethod = ACTIVITY_THREAD_CLASS.getDeclaredMethod("getSystemContext"); + return (Context) getSystemContextMethod.invoke(ACTIVITY_THREAD); + } catch (Throwable throwable) { + // this is a workaround, so failing is not an error + Ln.d("Could not get system context: " + throwable.getMessage()); + return null; + } + } + + @TargetApi(Build.VERSION_CODES.R) + @SuppressLint("WrongConstant,MissingPermission") + public static AudioRecord createAudioRecord(int source, int sampleRate, int channelConfig, int channels, int channelMask, int encoding) { + // Vivo (and maybe some other third-party ROMs) modified `AudioRecord`'s constructor, requiring `Context`s from real App environment. + // + // This method invokes the `AudioRecord(long nativeRecordInJavaObj)` constructor to create an empty `AudioRecord` instance, then uses + // reflections to initialize it like the normal constructor do (or the `AudioRecord.Builder.build()` method do). + // As a result, the modified code was not executed. + try { + // AudioRecord audioRecord = new AudioRecord(0L); + Constructor audioRecordConstructor = AudioRecord.class.getDeclaredConstructor(long.class); + audioRecordConstructor.setAccessible(true); + AudioRecord audioRecord = audioRecordConstructor.newInstance(0L); + + // audioRecord.mRecordingState = RECORDSTATE_STOPPED; + Field mRecordingStateField = AudioRecord.class.getDeclaredField("mRecordingState"); + mRecordingStateField.setAccessible(true); + mRecordingStateField.set(audioRecord, AudioRecord.RECORDSTATE_STOPPED); + + Looper looper = Looper.myLooper(); + if (looper == null) { + looper = Looper.getMainLooper(); + } + + // audioRecord.mInitializationLooper = looper; + Field mInitializationLooperField = AudioRecord.class.getDeclaredField("mInitializationLooper"); + mInitializationLooperField.setAccessible(true); + mInitializationLooperField.set(audioRecord, looper); + + // Create `AudioAttributes` with fixed capture preset + int capturePreset = source; + AudioAttributes.Builder audioAttributesBuilder = new AudioAttributes.Builder(); + Method setInternalCapturePresetMethod = AudioAttributes.Builder.class.getMethod("setInternalCapturePreset", int.class); + setInternalCapturePresetMethod.invoke(audioAttributesBuilder, capturePreset); + AudioAttributes attributes = audioAttributesBuilder.build(); + + // audioRecord.mAudioAttributes = attributes; + Field mAudioAttributesField = AudioRecord.class.getDeclaredField("mAudioAttributes"); + mAudioAttributesField.setAccessible(true); + mAudioAttributesField.set(audioRecord, attributes); + + // audioRecord.audioParamCheck(capturePreset, sampleRate, encoding); + Method audioParamCheckMethod = AudioRecord.class.getDeclaredMethod("audioParamCheck", int.class, int.class, int.class); + audioParamCheckMethod.setAccessible(true); + audioParamCheckMethod.invoke(audioRecord, capturePreset, sampleRate, encoding); + + // audioRecord.mChannelCount = channels + Field mChannelCountField = AudioRecord.class.getDeclaredField("mChannelCount"); + mChannelCountField.setAccessible(true); + mChannelCountField.set(audioRecord, channels); + + // audioRecord.mChannelMask = channelMask + Field mChannelMaskField = AudioRecord.class.getDeclaredField("mChannelMask"); + mChannelMaskField.setAccessible(true); + mChannelMaskField.set(audioRecord, channelMask); + + int minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding); + int bufferSizeInBytes = minBufferSize * 8; + + // audioRecord.audioBuffSizeCheck(bufferSizeInBytes) + Method audioBuffSizeCheckMethod = AudioRecord.class.getDeclaredMethod("audioBuffSizeCheck", int.class); + audioBuffSizeCheckMethod.setAccessible(true); + audioBuffSizeCheckMethod.invoke(audioRecord, bufferSizeInBytes); + + final int channelIndexMask = 0; + + int[] sampleRateArray = new int[]{sampleRate}; + int[] session = new int[]{AudioManager.AUDIO_SESSION_ID_GENERATE}; + + int initResult; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + // private native final int native_setup(Object audiorecord_this, + // Object /*AudioAttributes*/ attributes, + // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, + // int buffSizeInBytes, int[] sessionId, String opPackageName, + // long nativeRecordInJavaObj); + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, int.class, + int.class, int.class, int.class, int[].class, String.class, long.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, sampleRateArray, + channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, FakeContext.get().getOpPackageName(), + 0L); + } else { + // Assume `context` is never `null` + AttributionSource attributionSource = FakeContext.get().getAttributionSource(); + + // Assume `attributionSource.getPackageName()` is never null + + // ScopedParcelState attributionSourceState = attributionSource.asScopedParcelState() + Method asScopedParcelStateMethod = AttributionSource.class.getDeclaredMethod("asScopedParcelState"); + asScopedParcelStateMethod.setAccessible(true); + + try (AutoCloseable attributionSourceState = (AutoCloseable) asScopedParcelStateMethod.invoke(attributionSource)) { + Method getParcelMethod = attributionSourceState.getClass().getDeclaredMethod("getParcel"); + Parcel attributionSourceParcel = (Parcel) getParcelMethod.invoke(attributionSourceState); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // private native int native_setup(Object audiorecordThis, + // Object /*AudioAttributes*/ attributes, + // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, + // int buffSizeInBytes, int[] sessionId, @NonNull Parcel attributionSource, + // long nativeRecordInJavaObj, int maxSharedAudioHistoryMs); + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, + int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, + sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, + attributionSourceParcel, 0L, 0); + } else { + // Android 14 added a new int parameter "halInputFlags" + // + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, + int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class, int.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, + sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, + attributionSourceParcel, 0L, 0, 0); + } + } + } + + if (initResult != AudioRecord.SUCCESS) { + Ln.e("Error code " + initResult + " when initializing native AudioRecord object."); + throw new RuntimeException("Cannot create AudioRecord"); + } + + // mSampleRate = sampleRate[0] + Field mSampleRateField = AudioRecord.class.getDeclaredField("mSampleRate"); + mSampleRateField.setAccessible(true); + mSampleRateField.set(audioRecord, sampleRateArray[0]); + + // audioRecord.mSessionId = session[0] + Field mSessionIdField = AudioRecord.class.getDeclaredField("mSessionId"); + mSessionIdField.setAccessible(true); + mSessionIdField.set(audioRecord, session[0]); + + // audioRecord.mState = AudioRecord.STATE_INITIALIZED + Field mStateField = AudioRecord.class.getDeclaredField("mState"); + mStateField.setAccessible(true); + mStateField.set(audioRecord, AudioRecord.STATE_INITIALIZED); + + return audioRecord; + } catch (Exception e) { + Ln.e("Failed to invoke AudioRecord..", e); + throw new RuntimeException("Cannot create AudioRecord"); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ActivityManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ActivityManager.java new file mode 100644 index 0000000..fefb91c --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ActivityManager.java @@ -0,0 +1,159 @@ +package org.cagnulein.android_remote.wrappers; + +import org.cagnulein.android_remote.FakeContext; +import org.cagnulein.android_remote.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi,DiscouragedPrivateApi") +public final class ActivityManager { + + private final IInterface manager; + private Method getContentProviderExternalMethod; + private boolean getContentProviderExternalMethodNewVersion = true; + private Method removeContentProviderExternalMethod; + private Method startActivityAsUserMethod; + private Method forceStopPackageMethod; + + static ActivityManager create() { + try { + // On old Android versions, the ActivityManager is not exposed via AIDL, + // so use ActivityManagerNative.getDefault() + Class cls = Class.forName("android.app.ActivityManagerNative"); + Method getDefaultMethod = cls.getDeclaredMethod("getDefault"); + IInterface am = (IInterface) getDefaultMethod.invoke(null); + return new ActivityManager(am); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private ActivityManager(IInterface manager) { + this.manager = manager; + } + + private Method getGetContentProviderExternalMethod() throws NoSuchMethodException { + if (getContentProviderExternalMethod == null) { + try { + getContentProviderExternalMethod = manager.getClass() + .getMethod("getContentProviderExternal", String.class, int.class, IBinder.class, String.class); + } catch (NoSuchMethodException e) { + // old version + getContentProviderExternalMethod = manager.getClass().getMethod("getContentProviderExternal", String.class, int.class, IBinder.class); + getContentProviderExternalMethodNewVersion = false; + } + } + return getContentProviderExternalMethod; + } + + private Method getRemoveContentProviderExternalMethod() throws NoSuchMethodException { + if (removeContentProviderExternalMethod == null) { + removeContentProviderExternalMethod = manager.getClass().getMethod("removeContentProviderExternal", String.class, IBinder.class); + } + return removeContentProviderExternalMethod; + } + + @TargetApi(Build.VERSION_CODES.Q) + private ContentProvider getContentProviderExternal(String name, IBinder token) { + try { + Method method = getGetContentProviderExternalMethod(); + Object[] args; + if (getContentProviderExternalMethodNewVersion) { + // new version + args = new Object[]{name, FakeContext.ROOT_UID, token, null}; + } else { + // old version + args = new Object[]{name, FakeContext.ROOT_UID, token}; + } + // ContentProviderHolder providerHolder = getContentProviderExternal(...); + Object providerHolder = method.invoke(manager, args); + if (providerHolder == null) { + return null; + } + // IContentProvider provider = providerHolder.provider; + Field providerField = providerHolder.getClass().getDeclaredField("provider"); + providerField.setAccessible(true); + Object provider = providerField.get(providerHolder); + if (provider == null) { + return null; + } + return new ContentProvider(this, provider, name, token); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + void removeContentProviderExternal(String name, IBinder token) { + try { + Method method = getRemoveContentProviderExternalMethod(); + method.invoke(manager, name, token); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public ContentProvider createSettingsProvider() { + return getContentProviderExternal("settings", new Binder()); + } + + private Method getStartActivityAsUserMethod() throws NoSuchMethodException, ClassNotFoundException { + if (startActivityAsUserMethod == null) { + Class iApplicationThreadClass = Class.forName("android.app.IApplicationThread"); + Class profilerInfo = Class.forName("android.app.ProfilerInfo"); + startActivityAsUserMethod = manager.getClass() + .getMethod("startActivityAsUser", iApplicationThreadClass, String.class, Intent.class, String.class, IBinder.class, String.class, + int.class, int.class, profilerInfo, Bundle.class, int.class); + } + return startActivityAsUserMethod; + } + + @SuppressWarnings("ConstantConditions") + public int startActivity(Intent intent) { + try { + Method method = getStartActivityAsUserMethod(); + return (int) method.invoke( + /* this */ manager, + /* caller */ null, + /* callingPackage */ FakeContext.PACKAGE_NAME, + /* intent */ intent, + /* resolvedType */ null, + /* resultTo */ null, + /* resultWho */ null, + /* requestCode */ 0, + /* startFlags */ 0, + /* profilerInfo */ null, + /* bOptions */ null, + /* userId */ /* UserHandle.USER_CURRENT */ -2); + } catch (Throwable e) { + Ln.e("Could not invoke method", e); + return 0; + } + } + + private Method getForceStopPackageMethod() throws NoSuchMethodException { + if (forceStopPackageMethod == null) { + forceStopPackageMethod = manager.getClass().getMethod("forceStopPackage", String.class, int.class); + } + return forceStopPackageMethod; + } + + public void forceStopPackage(String packageName) { + try { + Method method = getForceStopPackageMethod(); + method.invoke(manager, packageName, /* userId */ /* UserHandle.USER_CURRENT */ -2); + } catch (Throwable e) { + Ln.e("Could not invoke method", e); + } + } +} diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ClipboardManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ClipboardManager.java new file mode 100644 index 0000000..57a6225 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ClipboardManager.java @@ -0,0 +1,234 @@ +package org.cagnulein.android_remote.wrappers; + +import org.cagnulein.android_remote.FakeContext; +import org.cagnulein.android_remote.Ln; + +import android.content.ClipData; +import android.content.IOnPrimaryClipChangedListener; +import android.os.Build; +import android.os.IInterface; + +import java.lang.reflect.Method; + +public final class ClipboardManager { + private final IInterface manager; + private Method getPrimaryClipMethod; + private Method setPrimaryClipMethod; + private Method addPrimaryClipChangedListener; + private int getMethodVersion; + private int setMethodVersion; + private int addListenerMethodVersion; + + static ClipboardManager create() { + IInterface clipboard = ServiceManager.getService("clipboard", "android.content.IClipboard"); + if (clipboard == null) { + // Some devices have no clipboard manager + // + // + return null; + } + return new ClipboardManager(clipboard); + } + + private ClipboardManager(IInterface manager) { + this.manager = manager; + } + + private Method getGetPrimaryClipMethod() throws NoSuchMethodException { + if (getPrimaryClipMethod == null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class); + } else { + try { + getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, int.class); + getMethodVersion = 0; + } catch (NoSuchMethodException e1) { + try { + getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class); + getMethodVersion = 1; + } catch (NoSuchMethodException e2) { + try { + getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class, int.class); + getMethodVersion = 2; + } catch (NoSuchMethodException e3) { + try { + getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, int.class, String.class); + getMethodVersion = 3; + } catch (NoSuchMethodException e4) { + try { + getPrimaryClipMethod = manager.getClass() + .getMethod("getPrimaryClip", String.class, String.class, int.class, int.class, boolean.class); + getMethodVersion = 4; + } catch (NoSuchMethodException e5) { + getPrimaryClipMethod = manager.getClass() + .getMethod("getPrimaryClip", String.class, String.class, String.class, String.class, int.class, int.class, + boolean.class); + getMethodVersion = 5; + } + } + } + } + } + } + } + return getPrimaryClipMethod; + } + + private Method getSetPrimaryClipMethod() throws NoSuchMethodException { + if (setPrimaryClipMethod == null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class); + } else { + try { + setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class, int.class); + setMethodVersion = 0; + } catch (NoSuchMethodException e1) { + try { + setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class); + setMethodVersion = 1; + } catch (NoSuchMethodException e2) { + try { + setPrimaryClipMethod = manager.getClass() + .getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class, int.class); + setMethodVersion = 2; + } catch (NoSuchMethodException e3) { + setPrimaryClipMethod = manager.getClass() + .getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class, int.class, boolean.class); + setMethodVersion = 3; + } + } + } + } + } + return setPrimaryClipMethod; + } + + private static ClipData getPrimaryClip(Method method, int methodVersion, IInterface manager) throws ReflectiveOperationException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME); + } + + switch (methodVersion) { + case 0: + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID); + case 1: + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID); + case 2: + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0); + case 3: + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID, null); + case 4: + // The last boolean parameter is "userOperate" + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0, true); + default: + return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, null, null, FakeContext.ROOT_UID, 0, true); + } + } + + private static void setPrimaryClip(Method method, int methodVersion, IInterface manager, ClipData clipData) throws ReflectiveOperationException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + method.invoke(manager, clipData, FakeContext.PACKAGE_NAME); + return; + } + + switch (methodVersion) { + case 0: + method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID); + break; + case 1: + method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID); + break; + case 2: + method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0); + break; + default: + // The last boolean parameter is "userOperate" + method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0, true); + } + } + + public CharSequence getText() { + try { + Method method = getGetPrimaryClipMethod(); + ClipData clipData = getPrimaryClip(method, getMethodVersion, manager); + if (clipData == null || clipData.getItemCount() == 0) { + return null; + } + return clipData.getItemAt(0).getText(); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + public boolean setText(CharSequence text) { + try { + Method method = getSetPrimaryClipMethod(); + ClipData clipData = ClipData.newPlainText(null, text); + setPrimaryClip(method, setMethodVersion, manager, clipData); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + private static void addPrimaryClipChangedListener(Method method, int methodVersion, IInterface manager, IOnPrimaryClipChangedListener listener) + throws ReflectiveOperationException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + method.invoke(manager, listener, FakeContext.PACKAGE_NAME); + return; + } + + switch (methodVersion) { + case 0: + method.invoke(manager, listener, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID); + break; + case 1: + method.invoke(manager, listener, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID); + break; + default: + method.invoke(manager, listener, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0); + break; + } + } + + private Method getAddPrimaryClipChangedListener() throws NoSuchMethodException { + if (addPrimaryClipChangedListener == null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + addPrimaryClipChangedListener = manager.getClass() + .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class); + } else { + try { + addPrimaryClipChangedListener = manager.getClass() + .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class); + addListenerMethodVersion = 0; + } catch (NoSuchMethodException e1) { + try { + addPrimaryClipChangedListener = manager.getClass() + .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, String.class, + int.class); + addListenerMethodVersion = 1; + } catch (NoSuchMethodException e2) { + addPrimaryClipChangedListener = manager.getClass() + .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, String.class, + int.class, int.class); + addListenerMethodVersion = 2; + } + } + } + } + return addPrimaryClipChangedListener; + } + + public boolean addPrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) { + try { + Method method = getAddPrimaryClipChangedListener(); + addPrimaryClipChangedListener(method, addListenerMethodVersion, manager, listener); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } +} diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ContentProvider.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ContentProvider.java new file mode 100644 index 0000000..df36ce2 --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ContentProvider.java @@ -0,0 +1,161 @@ +package org.cagnulein.android_remote.wrappers; + +import org.cagnulein.android_remote.FakeContext; +import org.cagnulein.android_remote.Ln; +import org.cagnulein.android_remote.SettingsException; + +import android.annotation.SuppressLint; +import android.content.AttributionSource; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; + +import java.io.Closeable; +import java.lang.reflect.Method; + +public final class ContentProvider implements Closeable { + + public static final String TABLE_SYSTEM = "system"; + public static final String TABLE_SECURE = "secure"; + public static final String TABLE_GLOBAL = "global"; + + // See android/providerHolder/Settings.java + private static final String CALL_METHOD_GET_SYSTEM = "GET_system"; + private static final String CALL_METHOD_GET_SECURE = "GET_secure"; + private static final String CALL_METHOD_GET_GLOBAL = "GET_global"; + + private static final String CALL_METHOD_PUT_SYSTEM = "PUT_system"; + private static final String CALL_METHOD_PUT_SECURE = "PUT_secure"; + private static final String CALL_METHOD_PUT_GLOBAL = "PUT_global"; + + private static final String CALL_METHOD_USER_KEY = "_user"; + + private static final String NAME_VALUE_TABLE_VALUE = "value"; + + private final ActivityManager manager; + // android.content.IContentProvider + private final Object provider; + private final String name; + private final IBinder token; + + private Method callMethod; + private int callMethodVersion; + + ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) { + this.manager = manager; + this.provider = provider; + this.name = name; + this.token = token; + } + + @SuppressLint("PrivateApi") + private Method getCallMethod() throws NoSuchMethodException { + if (callMethod == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + callMethod = provider.getClass().getMethod("call", AttributionSource.class, String.class, String.class, String.class, Bundle.class); + callMethodVersion = 0; + } else { + // old versions + try { + callMethod = provider.getClass() + .getMethod("call", String.class, String.class, String.class, String.class, String.class, Bundle.class); + callMethodVersion = 1; + } catch (NoSuchMethodException e1) { + try { + callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, String.class, Bundle.class); + callMethodVersion = 2; + } catch (NoSuchMethodException e2) { + callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, Bundle.class); + callMethodVersion = 3; + } + } + } + } + return callMethod; + } + + private Bundle call(String callMethod, String arg, Bundle extras) throws ReflectiveOperationException { + try { + Method method = getCallMethod(); + Object[] args; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && callMethodVersion == 0) { + args = new Object[]{FakeContext.get().getAttributionSource(), "settings", callMethod, arg, extras}; + } else { + switch (callMethodVersion) { + case 1: + args = new Object[]{FakeContext.PACKAGE_NAME, null, "settings", callMethod, arg, extras}; + break; + case 2: + args = new Object[]{FakeContext.PACKAGE_NAME, "settings", callMethod, arg, extras}; + break; + default: + args = new Object[]{FakeContext.PACKAGE_NAME, callMethod, arg, extras}; + break; + } + } + return (Bundle) method.invoke(provider, args); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + throw e; + } + } + + public void close() { + manager.removeContentProviderExternal(name, token); + } + + private static String getGetMethod(String table) { + switch (table) { + case TABLE_SECURE: + return CALL_METHOD_GET_SECURE; + case TABLE_SYSTEM: + return CALL_METHOD_GET_SYSTEM; + case TABLE_GLOBAL: + return CALL_METHOD_GET_GLOBAL; + default: + throw new IllegalArgumentException("Invalid table: " + table); + } + } + + private static String getPutMethod(String table) { + switch (table) { + case TABLE_SECURE: + return CALL_METHOD_PUT_SECURE; + case TABLE_SYSTEM: + return CALL_METHOD_PUT_SYSTEM; + case TABLE_GLOBAL: + return CALL_METHOD_PUT_GLOBAL; + default: + throw new IllegalArgumentException("Invalid table: " + table); + } + } + + public String getValue(String table, String key) throws SettingsException { + String method = getGetMethod(table); + Bundle arg = new Bundle(); + arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID); + try { + Bundle bundle = call(method, key, arg); + if (bundle == null) { + return null; + } + return bundle.getString("value"); + } catch (Exception e) { + throw new SettingsException(table, "get", key, null, e); + } + + } + + public void putValue(String table, String key, String value) throws SettingsException { + String method = getPutMethod(table); + Bundle arg = new Bundle(); + arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID); + arg.putString(NAME_VALUE_TABLE_VALUE, value); + try { + call(method, key, arg); + } catch (Exception e) { + throw new SettingsException(table, "put", key, value, e); + } + } +} diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayControl.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayControl.java index 6dad4cd..438fd0a 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayControl.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayControl.java @@ -1,10 +1,11 @@ package org.cagnulein.android_remote.wrappers; +import org.cagnulein.android_remote.Ln; + import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.os.Build; import android.os.IBinder; -import android.util.Log; import java.lang.reflect.Method; @@ -29,7 +30,7 @@ public final class DisplayControl { loadMethod.setAccessible(true); loadMethod.invoke(Runtime.getRuntime(), displayControlClass, "android_servers"); } catch (Throwable e) { - Log.e("scrcpy", "Could not initialize DisplayControl" + e); + Ln.e("Could not initialize DisplayControl", e); // Do not throw an exception here, the methods will fail when they are called } CLASS = displayControlClass; @@ -54,7 +55,7 @@ public static IBinder getPhysicalDisplayToken(long physicalDisplayId) { Method method = getGetPhysicalDisplayTokenMethod(); return (IBinder) method.invoke(null, physicalDisplayId); } catch (ReflectiveOperationException e) { - Log.e("scrcpy", "Could not invoke method" + e); + Ln.e("Could not invoke method", e); return null; } } @@ -71,8 +72,8 @@ public static long[] getPhysicalDisplayIds() { Method method = getGetPhysicalDisplayIdsMethod(); return (long[]) method.invoke(null); } catch (ReflectiveOperationException e) { - Log.e("scrcpy", "Could not invoke method" + e); + Ln.e("Could not invoke method", e); return null; } } -} \ No newline at end of file +} diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayManager.java index 09922fc..577e062 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayManager.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/DisplayManager.java @@ -1,42 +1,127 @@ package org.cagnulein.android_remote.wrappers; -import android.hardware.display.VirtualDisplay; -import android.os.IInterface; -import android.view.Surface; - +import org.cagnulein.android_remote.Command; import org.cagnulein.android_remote.DisplayInfo; +import org.cagnulein.android_remote.Ln; import org.cagnulein.android_remote.Size; -import java.lang.reflect.InvocationTargetException; +import android.annotation.SuppressLint; +import android.hardware.display.VirtualDisplay; +import android.view.Display; +import android.view.Surface; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@SuppressLint("PrivateApi,DiscouragedPrivateApi") public final class DisplayManager { - private final IInterface manager; + private final Object manager; // instance of hidden class android.hardware.display.DisplayManagerGlobal + private Method createVirtualDisplayMethod; - public DisplayManager(IInterface manager) { + static DisplayManager create() { + try { + Class clazz = Class.forName("android.hardware.display.DisplayManagerGlobal"); + Method getInstanceMethod = clazz.getDeclaredMethod("getInstance"); + Object dmg = getInstanceMethod.invoke(null); + return new DisplayManager(dmg); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private DisplayManager(Object manager) { this.manager = manager; } - public static VirtualDisplay createVirtualDisplay(String name, int width, int height, - int displayIdToMirror, Surface surface) - throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException { - java.lang.Class displayManagerClass = - java.lang.Class.forName("android.hardware.display.DisplayManager"); - return (VirtualDisplay) displayManagerClass.getMethod("createVirtualDisplay", - String.class, int.class, int.class, int.class, Surface.class) - .invoke(null, name, width, height, displayIdToMirror, surface); + // public to call it from unit tests + public static DisplayInfo parseDisplayInfo(String dumpsysDisplayOutput, int displayId) { + Pattern regex = Pattern.compile( + "^ mOverrideDisplayInfo=DisplayInfo\\{\".*?, displayId " + displayId + ".*?(, FLAG_.*)?, real ([0-9]+) x ([0-9]+).*?, " + + "rotation ([0-9]+).*?, layerStack ([0-9]+)", + Pattern.MULTILINE); + Matcher m = regex.matcher(dumpsysDisplayOutput); + if (!m.find()) { + return null; + } + int flags = parseDisplayFlags(m.group(1)); + int width = Integer.parseInt(m.group(2)); + int height = Integer.parseInt(m.group(3)); + int rotation = Integer.parseInt(m.group(4)); + int layerStack = Integer.parseInt(m.group(5)); + + return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags); + } + + private static DisplayInfo getDisplayInfoFromDumpsysDisplay(int displayId) { + try { + String dumpsysDisplayOutput = Command.execReadOutput("dumpsys", "display"); + return parseDisplayInfo(dumpsysDisplayOutput, displayId); + } catch (Exception e) { + Ln.e("Could not get display info from \"dumpsys display\" output", e); + return null; + } + } + + private static int parseDisplayFlags(String text) { + Pattern regex = Pattern.compile("FLAG_[A-Z_]+"); + if (text == null) { + return 0; + } + + int flags = 0; + Matcher m = regex.matcher(text); + while (m.find()) { + String flagString = m.group(); + try { + Field filed = Display.class.getDeclaredField(flagString); + flags |= filed.getInt(null); + } catch (ReflectiveOperationException e) { + // Silently ignore, some flags reported by "dumpsys display" are @TestApi + } + } + return flags; } - public DisplayInfo getDisplayInfo() { + public DisplayInfo getDisplayInfo(int displayId) { try { - Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, 0); + Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, displayId); + if (displayInfo == null) { + // fallback when displayInfo is null + return getDisplayInfoFromDumpsysDisplay(displayId); + } Class cls = displayInfo.getClass(); // width and height already take the rotation into account int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo); int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo); int rotation = cls.getDeclaredField("rotation").getInt(displayInfo); - return new DisplayInfo(new Size(width, height), rotation); - } catch (Exception e) { + int layerStack = cls.getDeclaredField("layerStack").getInt(displayInfo); + int flags = cls.getDeclaredField("flags").getInt(displayInfo); + return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags); + } catch (ReflectiveOperationException e) { throw new AssertionError(e); } } + + public int[] getDisplayIds() { + try { + return (int[]) manager.getClass().getMethod("getDisplayIds").invoke(manager); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private Method getCreateVirtualDisplayMethod() throws NoSuchMethodException { + if (createVirtualDisplayMethod == null) { + createVirtualDisplayMethod = android.hardware.display.DisplayManager.class + .getMethod("createVirtualDisplay", String.class, int.class, int.class, int.class, Surface.class); + } + return createVirtualDisplayMethod; + } + + public VirtualDisplay createVirtualDisplay(String name, int width, int height, int displayIdToMirror, Surface surface) throws Exception { + Method method = getCreateVirtualDisplayMethod(); + return (VirtualDisplay) method.invoke(null, name, width, height, displayIdToMirror, surface); + } } diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/InputManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/InputManager.java index 71d0763..ca34b17 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/InputManager.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/InputManager.java @@ -1,34 +1,100 @@ package org.cagnulein.android_remote.wrappers; -import android.os.IInterface; +import org.cagnulein.android_remote.Ln; + +import android.annotation.SuppressLint; import android.view.InputEvent; +import android.view.MotionEvent; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +@SuppressLint("PrivateApi,DiscouragedPrivateApi") public final class InputManager { public static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0; public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT = 1; public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2; - private final IInterface manager; - private final Method injectInputEventMethod; + private final Object manager; + private Method injectInputEventMethod; - public InputManager(IInterface manager) { - this.manager = manager; + private static Method setDisplayIdMethod; + private static Method setActionButtonMethod; + + static InputManager create() { try { - injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class); - } catch (NoSuchMethodException e) { + Class inputManagerClass = getInputManagerClass(); + Method getInstanceMethod = inputManagerClass.getDeclaredMethod("getInstance"); + Object im = getInstanceMethod.invoke(null); + return new InputManager(im); + } catch (ReflectiveOperationException e) { throw new AssertionError(e); } } + private static Class getInputManagerClass() { + try { + // Parts of the InputManager class have been moved to a new InputManagerGlobal class in Android 14 preview + return Class.forName("android.hardware.input.InputManagerGlobal"); + } catch (ClassNotFoundException e) { + return android.hardware.input.InputManager.class; + } + } + + private InputManager(Object manager) { + this.manager = manager; + } + + private Method getInjectInputEventMethod() throws NoSuchMethodException { + if (injectInputEventMethod == null) { + injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class); + } + return injectInputEventMethod; + } + public boolean injectInputEvent(InputEvent inputEvent, int mode) { try { - return (Boolean) injectInputEventMethod.invoke(manager, inputEvent, mode); - } catch (InvocationTargetException | IllegalAccessException e) { - throw new AssertionError(e); + Method method = getInjectInputEventMethod(); + return (boolean) method.invoke(manager, inputEvent, mode); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + private static Method getSetDisplayIdMethod() throws NoSuchMethodException { + if (setDisplayIdMethod == null) { + setDisplayIdMethod = InputEvent.class.getMethod("setDisplayId", int.class); + } + return setDisplayIdMethod; + } + + public static boolean setDisplayId(InputEvent inputEvent, int displayId) { + try { + Method method = getSetDisplayIdMethod(); + method.invoke(inputEvent, displayId); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Cannot associate a display id to the input event", e); + return false; + } + } + + private static Method getSetActionButtonMethod() throws NoSuchMethodException { + if (setActionButtonMethod == null) { + setActionButtonMethod = MotionEvent.class.getMethod("setActionButton", int.class); + } + return setActionButtonMethod; + } + + public static boolean setActionButton(MotionEvent motionEvent, int actionButton) { + try { + Method method = getSetActionButtonMethod(); + method.invoke(motionEvent, actionButton); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Cannot set action button on MotionEvent", e); + return false; } } } diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/PowerManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/PowerManager.java index a3ff5eb..5e1260d 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/PowerManager.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/PowerManager.java @@ -1,32 +1,42 @@ package org.cagnulein.android_remote.wrappers; +import org.cagnulein.android_remote.Ln; + import android.annotation.SuppressLint; import android.os.Build; import android.os.IInterface; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public final class PowerManager { private final IInterface manager; - private final Method isScreenOnMethod; + private Method isScreenOnMethod; + + static PowerManager create() { + IInterface manager = ServiceManager.getService("power", "android.os.IPowerManager"); + return new PowerManager(manager); + } - public PowerManager(IInterface manager) { + private PowerManager(IInterface manager) { this.manager = manager; - try { + } + + private Method getIsScreenOnMethod() throws NoSuchMethodException { + if (isScreenOnMethod == null) { @SuppressLint("ObsoleteSdkInt") // we may lower minSdkVersion in the future - String methodName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH ? "isInteractive" : "isScreenOn"; + String methodName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH ? "isInteractive" : "isScreenOn"; isScreenOnMethod = manager.getClass().getMethod(methodName); - } catch (NoSuchMethodException e) { - throw new AssertionError(e); } + return isScreenOnMethod; } public boolean isScreenOn() { try { - return (Boolean) isScreenOnMethod.invoke(manager); - } catch (InvocationTargetException | IllegalAccessException e) { - throw new AssertionError(e); + Method method = getIsScreenOnMethod(); + return (boolean) method.invoke(manager); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; } } } diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java index 2b21647..3560678 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java @@ -1,31 +1,45 @@ package org.cagnulein.android_remote.wrappers; +import org.cagnulein.android_remote.FakeContext; + import android.annotation.SuppressLint; +import android.content.Context; +import android.hardware.camera2.CameraManager; import android.os.IBinder; import android.os.IInterface; +import java.lang.reflect.Constructor; import java.lang.reflect.Method; -@SuppressLint("PrivateApi") +@SuppressLint("PrivateApi,DiscouragedPrivateApi") public final class ServiceManager { - private final Method getServiceMethod; - private WindowManager windowManager; - private DisplayManager displayManager; - private InputManager inputManager; - private PowerManager powerManager; + private static final Method GET_SERVICE_METHOD; - public ServiceManager() { + static { try { - getServiceMethod = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String.class); + GET_SERVICE_METHOD = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String.class); } catch (Exception e) { throw new AssertionError(e); } } - private IInterface getService(String service, String type) { + private static WindowManager windowManager; + private static DisplayManager displayManager; + private static InputManager inputManager; + private static PowerManager powerManager; + private static StatusBarManager statusBarManager; + private static ClipboardManager clipboardManager; + private static ActivityManager activityManager; + private static CameraManager cameraManager; + + private ServiceManager() { + /* not instantiable */ + } + + static IInterface getService(String service, String type) { try { - IBinder binder = (IBinder) getServiceMethod.invoke(null, service); + IBinder binder = (IBinder) GET_SERVICE_METHOD.invoke(null, service); Method asInterfaceMethod = Class.forName(type + "$Stub").getMethod("asInterface", IBinder.class); return (IInterface) asInterfaceMethod.invoke(null, binder); } catch (Exception e) { @@ -33,31 +47,65 @@ private IInterface getService(String service, String type) { } } - public WindowManager getWindowManager() { + public static WindowManager getWindowManager() { if (windowManager == null) { - windowManager = new WindowManager(getService("window", "android.view.IWindowManager")); + windowManager = WindowManager.create(); } return windowManager; } - public DisplayManager getDisplayManager() { + public static DisplayManager getDisplayManager() { if (displayManager == null) { - displayManager = new DisplayManager(getService("display", "android.hardware.display.IDisplayManager")); + displayManager = DisplayManager.create(); } return displayManager; } - public InputManager getInputManager() { + public static InputManager getInputManager() { if (inputManager == null) { - inputManager = new InputManager(getService("input", "android.hardware.input.IInputManager")); + inputManager = InputManager.create(); } return inputManager; } - public PowerManager getPowerManager() { + public static PowerManager getPowerManager() { if (powerManager == null) { - powerManager = new PowerManager(getService("power", "android.os.IPowerManager")); + powerManager = PowerManager.create(); } return powerManager; } + + public static StatusBarManager getStatusBarManager() { + if (statusBarManager == null) { + statusBarManager = StatusBarManager.create(); + } + return statusBarManager; + } + + public static ClipboardManager getClipboardManager() { + if (clipboardManager == null) { + // May be null, some devices have no clipboard manager + clipboardManager = ClipboardManager.create(); + } + return clipboardManager; + } + + public static ActivityManager getActivityManager() { + if (activityManager == null) { + activityManager = ActivityManager.create(); + } + return activityManager; + } + + public static CameraManager getCameraManager() { + if (cameraManager == null) { + try { + Constructor ctor = CameraManager.class.getDeclaredConstructor(Context.class); + cameraManager = ctor.newInstance(FakeContext.get()); + } catch (Exception e) { + throw new AssertionError(e); + } + } + return cameraManager; + } } diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/StatusBarManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/StatusBarManager.java new file mode 100644 index 0000000..7428e3d --- /dev/null +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/StatusBarManager.java @@ -0,0 +1,97 @@ +package org.cagnulein.android_remote.wrappers; + +import org.cagnulein.android_remote.Ln; + +import android.os.IInterface; + +import java.lang.reflect.Method; + +public final class StatusBarManager { + + private final IInterface manager; + private Method expandNotificationsPanelMethod; + private boolean expandNotificationPanelMethodCustomVersion; + private Method expandSettingsPanelMethod; + private boolean expandSettingsPanelMethodNewVersion = true; + private Method collapsePanelsMethod; + + static StatusBarManager create() { + IInterface manager = ServiceManager.getService("statusbar", "com.android.internal.statusbar.IStatusBarService"); + return new StatusBarManager(manager); + } + + private StatusBarManager(IInterface manager) { + this.manager = manager; + } + + private Method getExpandNotificationsPanelMethod() throws NoSuchMethodException { + if (expandNotificationsPanelMethod == null) { + try { + expandNotificationsPanelMethod = manager.getClass().getMethod("expandNotificationsPanel"); + } catch (NoSuchMethodException e) { + // Custom version for custom vendor ROM: + expandNotificationsPanelMethod = manager.getClass().getMethod("expandNotificationsPanel", int.class); + expandNotificationPanelMethodCustomVersion = true; + } + } + return expandNotificationsPanelMethod; + } + + private Method getExpandSettingsPanel() throws NoSuchMethodException { + if (expandSettingsPanelMethod == null) { + try { + // Since Android 7: https://android.googlesource.com/platform/frameworks/base.git/+/a9927325eda025504d59bb6594fee8e240d95b01%5E%21/ + expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel", String.class); + } catch (NoSuchMethodException e) { + // old version + expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel"); + expandSettingsPanelMethodNewVersion = false; + } + } + return expandSettingsPanelMethod; + } + + private Method getCollapsePanelsMethod() throws NoSuchMethodException { + if (collapsePanelsMethod == null) { + collapsePanelsMethod = manager.getClass().getMethod("collapsePanels"); + } + return collapsePanelsMethod; + } + + public void expandNotificationsPanel() { + try { + Method method = getExpandNotificationsPanelMethod(); + if (expandNotificationPanelMethodCustomVersion) { + method.invoke(manager, 0); + } else { + method.invoke(manager); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public void expandSettingsPanel() { + try { + Method method = getExpandSettingsPanel(); + if (expandSettingsPanelMethodNewVersion) { + // new version + method.invoke(manager, (Object) null); + } else { + // old version + method.invoke(manager); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public void collapsePanels() { + try { + Method method = getCollapsePanelsMethod(); + method.invoke(manager); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } +} diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/SurfaceControl.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/SurfaceControl.java index a904a6d..e34401a 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/SurfaceControl.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/SurfaceControl.java @@ -1,10 +1,11 @@ package org.cagnulein.android_remote.wrappers; +import org.cagnulein.android_remote.Ln; + import android.annotation.SuppressLint; import android.graphics.Rect; import android.os.Build; import android.os.IBinder; -import android.util.Log; import android.view.Surface; import java.lang.reflect.Method; @@ -104,7 +105,7 @@ public static IBinder getBuiltInDisplay() { // call getInternalDisplayToken() return (IBinder) method.invoke(null); } catch (ReflectiveOperationException e) { - Log.e("scrcpy", "Could not invoke method" + e); + Ln.e("Could not invoke method", e); return null; } } @@ -121,7 +122,7 @@ public static IBinder getPhysicalDisplayToken(long physicalDisplayId) { Method method = getGetPhysicalDisplayTokenMethod(); return (IBinder) method.invoke(null, physicalDisplayId); } catch (ReflectiveOperationException e) { - Log.e("scrcpy", "Could not invoke method" + e); + Ln.e("Could not invoke method", e); return null; } } @@ -147,7 +148,7 @@ public static long[] getPhysicalDisplayIds() { Method method = getGetPhysicalDisplayIdsMethod(); return (long[]) method.invoke(null); } catch (ReflectiveOperationException e) { - Log.e("scrcpy", "Could not invoke method" + e); + Ln.e("Could not invoke method", e); return null; } } @@ -165,7 +166,7 @@ public static boolean setDisplayPowerMode(IBinder displayToken, int mode) { method.invoke(null, displayToken, mode); return true; } catch (ReflectiveOperationException e) { - Log.e("scrcpy", "Could not invoke method" + e); + Ln.e("Could not invoke method", e); return false; } } @@ -177,4 +178,4 @@ public static void destroyDisplay(IBinder displayToken) { throw new AssertionError(e); } } -} \ No newline at end of file +} diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/WindowManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/WindowManager.java index d8e1e9a..994cb25 100644 --- a/server/src/main/java/org/cagnulein/android_remote/wrappers/WindowManager.java +++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/WindowManager.java @@ -1,42 +1,185 @@ package org.cagnulein.android_remote.wrappers; +import org.cagnulein.android_remote.Ln; + +import android.annotation.TargetApi; import android.os.IInterface; +import android.view.IDisplayFoldListener; import android.view.IRotationWatcher; +import java.lang.reflect.Method; + public final class WindowManager { private final IInterface manager; + private Method getRotationMethod; + private Method freezeRotationMethod; + private Method freezeDisplayRotationMethod; + private Method isRotationFrozenMethod; + private Method isDisplayRotationFrozenMethod; + private Method thawRotationMethod; + private Method thawDisplayRotationMethod; + + static WindowManager create() { + IInterface manager = ServiceManager.getService("window", "android.view.IWindowManager"); + return new WindowManager(manager); + } - public WindowManager(IInterface manager) { + private WindowManager(IInterface manager) { this.manager = manager; } - public int getRotation() { - try { + private Method getGetRotationMethod() throws NoSuchMethodException { + if (getRotationMethod == null) { Class cls = manager.getClass(); try { - return (Integer) manager.getClass().getMethod("getRotation").invoke(manager); - } catch (NoSuchMethodException e) { // method changed since this commit: // https://android.googlesource.com/platform/frameworks/base/+/8ee7285128c3843401d4c4d0412cd66e86ba49e3%5E%21/#F2 - return (Integer) cls.getMethod("getDefaultDisplayRotation").invoke(manager); + getRotationMethod = cls.getMethod("getDefaultDisplayRotation"); + } catch (NoSuchMethodException e) { + // old version + getRotationMethod = cls.getMethod("getRotation"); } - } catch (Exception e) { - throw new AssertionError(e); + } + return getRotationMethod; + } + + private Method getFreezeRotationMethod() throws NoSuchMethodException { + if (freezeRotationMethod == null) { + freezeRotationMethod = manager.getClass().getMethod("freezeRotation", int.class); + } + return freezeRotationMethod; + } + + // New method added by this commit: + // + private Method getFreezeDisplayRotationMethod() throws NoSuchMethodException { + if (freezeDisplayRotationMethod == null) { + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class); + } + return freezeDisplayRotationMethod; + } + + private Method getIsRotationFrozenMethod() throws NoSuchMethodException { + if (isRotationFrozenMethod == null) { + isRotationFrozenMethod = manager.getClass().getMethod("isRotationFrozen"); + } + return isRotationFrozenMethod; + } + + // New method added by this commit: + // + private Method getIsDisplayRotationFrozenMethod() throws NoSuchMethodException { + if (isDisplayRotationFrozenMethod == null) { + isDisplayRotationFrozenMethod = manager.getClass().getMethod("isDisplayRotationFrozen", int.class); + } + return isDisplayRotationFrozenMethod; + } + + private Method getThawRotationMethod() throws NoSuchMethodException { + if (thawRotationMethod == null) { + thawRotationMethod = manager.getClass().getMethod("thawRotation"); + } + return thawRotationMethod; + } + + // New method added by this commit: + // + private Method getThawDisplayRotationMethod() throws NoSuchMethodException { + if (thawDisplayRotationMethod == null) { + thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class); + } + return thawDisplayRotationMethod; + } + + public int getRotation() { + try { + Method method = getGetRotationMethod(); + return (int) method.invoke(manager); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return 0; + } + } + + public void freezeRotation(int displayId, int rotation) { + try { + try { + Method method = getFreezeDisplayRotationMethod(); + method.invoke(manager, displayId, rotation); + } catch (ReflectiveOperationException e) { + if (displayId == 0) { + Method method = getFreezeRotationMethod(); + method.invoke(manager, rotation); + } else { + Ln.e("Could not invoke method", e); + } + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public boolean isRotationFrozen(int displayId) { + try { + try { + Method method = getIsDisplayRotationFrozenMethod(); + return (boolean) method.invoke(manager, displayId); + } catch (ReflectiveOperationException e) { + if (displayId == 0) { + Method method = getIsRotationFrozenMethod(); + return (boolean) method.invoke(manager); + } else { + Ln.e("Could not invoke method", e); + return false; + } + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + public void thawRotation(int displayId) { + try { + try { + Method method = getThawDisplayRotationMethod(); + method.invoke(manager, displayId); + } catch (ReflectiveOperationException e) { + if (displayId == 0) { + Method method = getThawRotationMethod(); + method.invoke(manager); + } else { + Ln.e("Could not invoke method", e); + } + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); } } - public void registerRotationWatcher(IRotationWatcher rotationWatcher) { + public void registerRotationWatcher(IRotationWatcher rotationWatcher, int displayId) { try { Class cls = manager.getClass(); try { - cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher); - } catch (NoSuchMethodException e) { // display parameter added since this commit: // https://android.googlesource.com/platform/frameworks/base/+/35fa3c26adcb5f6577849fd0df5228b1f67cf2c6%5E%21/#F1 - cls.getMethod("watchRotation", IRotationWatcher.class, int.class).invoke(manager, rotationWatcher, 0); + cls.getMethod("watchRotation", IRotationWatcher.class, int.class).invoke(manager, rotationWatcher, displayId); + } catch (NoSuchMethodException e) { + // old version + cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher); } } catch (Exception e) { - throw new AssertionError(e); + Ln.e("Could not register rotation watcher", e); + } + } + + @TargetApi(29) + public void registerDisplayFoldListener(IDisplayFoldListener foldListener) { + try { + Class cls = manager.getClass(); + cls.getMethod("registerDisplayFoldListener", IDisplayFoldListener.class).invoke(manager, foldListener); + } catch (Exception e) { + Ln.e("Could not register display fold listener", e); } } }