commit b03e4c03b92ebb6e6b97404005632a7c81bfc3a3 Author: Jerry Date: Wed Aug 16 17:40:54 2023 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..cb82de0 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.jerryxiao.droidcast' + compileSdk 33 + + defaultConfig { + applicationId "com.jerryxiao.droidcast" + minSdk 29 + targetSdk 33 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation 'androidx.preference:preference:1.2.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + implementation 'com.github.pedroSG94.rtmp-rtsp-stream-client-java:rtplibrary:2.2.6' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/jerryxiao/droidcast/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/jerryxiao/droidcast/ExampleInstrumentedTest.java new file mode 100644 index 0000000..4d9055d --- /dev/null +++ b/app/src/androidTest/java/com/jerryxiao/droidcast/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.jerryxiao.droidcast; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.jerryxiao.droidcast", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..27d45fa --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/jerryxiao/droidcast/MainActivity.java b/app/src/main/java/com/jerryxiao/droidcast/MainActivity.java new file mode 100644 index 0000000..dc31587 --- /dev/null +++ b/app/src/main/java/com/jerryxiao/droidcast/MainActivity.java @@ -0,0 +1,149 @@ +package com.jerryxiao.droidcast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +import android.widget.Button; +import android.widget.TextView; + +import java.util.HashMap; +import java.util.Map; + +public class MainActivity extends AppCompatActivity { + private static final String TAG = "MainActivity"; + private ActivityResultLauncher screenCapReq; + private ScreenCapService screenCapService; + private SharedPreferences prefs; + private final ServiceConnection serviceConnection = new ServiceConnection() { + private void updateButtonText(boolean connected) { + Log.d(TAG, String.format("updateButtonText: %b", connected)); + runOnUiThread(() -> { + ((Button)MainActivity.this.findViewById(R.id.startButton)).setText(connected + ? getString(R.string.stop_button) + : getString(R.string.start_button)); + }); + } + private void updateInfoDisplay(String t) { + Log.d(TAG, String.format("updateInfoDisplay: %s", t)); + runOnUiThread(() -> { + ((TextView)MainActivity.this.findViewById(R.id.infoDisplay)).setText(t); + }); + } + public void onServiceConnected(ComponentName componentName, IBinder binder) { + Log.d(TAG, "service connected"); + MainActivity.this.screenCapService = ((ScreenCapService.MBinder)binder).getService(); + MainActivity.this.screenCapService.addConnectionStateCallback(this::updateButtonText); + MainActivity.this.screenCapService.addInfoDisplayCallback(this::updateInfoDisplay); + this.updateButtonText(MainActivity.this.screenCapService != null && MainActivity.this.screenCapService.isConnected()); + this.updateInfoDisplay(MainActivity.this.screenCapService.getInfoDisplayText()); + } + + public void onServiceDisconnected(ComponentName componentName) { + Log.d(TAG, "service disconnected"); + MainActivity.this.screenCapService.removeConnectionStateCallback(this::updateButtonText); + MainActivity.this.screenCapService.removeInfoDisplayCallback(this::updateInfoDisplay); + } + }; + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Log.d(TAG, "activity created"); + this.prefs = PreferenceManager.getDefaultSharedPreferences(this); + setContentView(R.layout.main_activity); + Map permissionsToRequest = new HashMap<>(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsToRequest.putAll(Map.of("android.permission.POST_NOTIFICATIONS", + getString(R.string.permission_denied_post_notifications))); + } + else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + permissionsToRequest.putAll(Map.of("android.permission.RECORD_AUDIO", + getString(R.string.permission_denied_record_audio))); + } + permissionsToRequest.entrySet().removeIf(i -> checkSelfPermission(i.getKey()) == PackageManager.PERMISSION_GRANTED); + if (!permissionsToRequest.isEmpty()) { + Log.d(TAG, String.format("requesting permissions %s", String.join(" + ", permissionsToRequest.keySet().toArray(new String[0])))); + ActivityResultLauncher permissionReq = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), (result) -> { + result.forEach((perm, granted) -> { + if (!granted) { + Log.d(TAG, String.format("permission denied: %s", perm)); + (new AlertDialog.Builder(this)) + .setTitle(getText(R.string.error_title)) + .setMessage(permissionsToRequest.get(perm)) + .setCancelable(false) + .setPositiveButton(getString(R.string.button_ok), (dialog, id) -> {}) + .create() + .show(); + } + }); + }); + permissionReq.launch(permissionsToRequest.keySet().toArray(new String[0])); + } + this.screenCapReq = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), (activityResult) -> { + int result = activityResult.getResultCode(); + screenCapService.getScreenCapDisplay().setIntentResult(result, activityResult.getData()); + if (result == RESULT_OK) { + Log.d(TAG, "capture granted"); + screenCapService.startCapture(prefs.getString("server", "")); + } + else { + runOnUiThread(() -> { + Log.d(TAG, "capture denied"); + (new AlertDialog.Builder(this)) + .setTitle(getText(R.string.error_title)) + .setMessage(getText(R.string.error_capture_denied)) + .setCancelable(false) + .setPositiveButton(getString(R.string.button_ok), (dialog, id) -> {}) + .create() + .show(); + }); + } + }); + bindService(new Intent(this, ScreenCapService.class), serviceConnection, BIND_AUTO_CREATE); + ((Button)this.findViewById(R.id.startButton)).setOnClickListener((view) -> { + if (screenCapService != null) { + if (screenCapService.isConnected()) { + Log.d(TAG, "button stop"); + new Thread(() -> { if (screenCapService.getScreenCapDisplay() != null) screenCapService.getScreenCapDisplay().stopStream(); }).start(); + } + else { + Log.d(TAG, "button start"); + MainActivity.this.screenCapService.createDisplay(); + MainActivity.this.screenCapReq.launch(MainActivity.this.screenCapService.getScreenCapDisplay().sendIntent()); + } + } + else { + Log.e(TAG, "service died"); + (new AlertDialog.Builder(this)) + .setTitle(getText(R.string.error_title)) + .setMessage(getText(R.string.error_service_died)) + .setCancelable(false) + .setPositiveButton(getString(R.string.button_ok), (dialog, id) -> {}) + .create() + .show(); + } + }); + ((Button)this.findViewById(R.id.settingsButton)).setOnClickListener((view) -> { + startActivity(new Intent(this, SettingsActivity.class)); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + Log.d(TAG, "activity destroyed"); + unbindService(serviceConnection); + this.screenCapService = null; + } +} diff --git a/app/src/main/java/com/jerryxiao/droidcast/ScreenCapService.java b/app/src/main/java/com/jerryxiao/droidcast/ScreenCapService.java new file mode 100644 index 0000000..3b8d3ba --- /dev/null +++ b/app/src/main/java/com/jerryxiao/droidcast/ScreenCapService.java @@ -0,0 +1,237 @@ +package com.jerryxiao.droidcast; + +import static androidx.core.app.NotificationCompat.PRIORITY_LOW; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ServiceInfo; +import android.graphics.Point; +import android.os.Binder; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.util.Log; +import android.view.WindowManager; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.preference.PreferenceManager; + +import com.pedro.encoder.utils.CodecUtil; +import com.pedro.rtmp.utils.ConnectCheckerRtmp; +import com.pedro.rtplibrary.rtmp.RtmpDisplay; + +import java.util.HashSet; +import java.util.function.Consumer; + +public class ScreenCapService extends Service { + private static final String TAG = "ScreenCapService"; + private static final int RESOLUTION_GAP = 100000; + private final HashSet> connectionStateCallbacks = new HashSet<>(); + private final HashSet> infoDisplayCallbacks = new HashSet<>(); + private SharedPreferences prefs; + + private boolean connected = false; + public boolean isConnected() { + return connected; + } + + protected class MBinder extends Binder { + public ScreenCapService getService() { + return ScreenCapService.this; + } + } + + private final IBinder mBinder = new MBinder(); + private RtmpDisplay screenCapDisplay; + + public RtmpDisplay getScreenCapDisplay() { + return this.screenCapDisplay; + } + private String infoDisplayText = ""; + public String getInfoDisplayText() { + return infoDisplayText; + } + + public IBinder onBind(Intent intent) { + return this.mBinder; + } + + public void onCreate() { + super.onCreate(); + Log.d(TAG, "service created"); + this.prefs = PreferenceManager.getDefaultSharedPreferences(this); + this.infoDisplayText = getString(R.string.status_disconnected); + } + + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "service destroyed"); + } + + protected void createDisplay() { + boolean use_gl = prefs.getBoolean("use_gl", true); + Log.d(TAG, String.format("creating display use_gl=%b", use_gl)); + this.screenCapDisplay = new RtmpDisplay(this, use_gl, new ConnectCheckerRtmp() { + public void onAuthErrorRtmp() { + String text = getString(R.string.status_auth_error); + showToast(text); + setInfoDisplay(text); + ScreenCapService.this.stop(); + } + + public void onAuthSuccessRtmp() { + setInfoDisplay(getString(R.string.status_auth_success)); + } + + public void onConnectionFailedRtmp(@NonNull String reason) { + String text = getString(R.string.status_connection_failed_format, reason); + showToast(text); + setInfoDisplay(text); + ScreenCapService.this.stop(); + } + + public void onConnectionStartedRtmp(@NonNull String rtmpUrl) { + setInfoDisplay(getString(R.string.status_connecting, rtmpUrl)); + ScreenCapService.this.connectionStateChange(true); + } + + public void onConnectionSuccessRtmp() { + setInfoDisplay(getString(R.string.status_connected)); + } + + public void onDisconnectRtmp() { + setInfoDisplay(getString(R.string.status_disconnected)); + ScreenCapService.this.stop(); + } + + public void onNewBitrateRtmp(long bitrate) { + setInfoDisplay(getString(R.string.status_bitrate_format, bitrate)); + } + }); + } + + protected void startCapture(String server) { + if (this.connected) { + Log.d(TAG, "not starting capture"); + return; + } + Log.d(TAG, "start capture"); + startService(new Intent(this, ScreenCapService.class)); + this.connected = true; + NotificationChannel notificationChannel = new NotificationChannel("notifications", "Notifications", NotificationManager.IMPORTANCE_LOW); + notificationChannel.setDescription("Notifications"); + getSystemService(NotificationManager.class).createNotificationChannel(notificationChannel); + NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, "notifications"); + notificationBuilder.setChannelId("notifications").setContentTitle("DroidCast").setContentText("Streaming") + .setContentIntent(PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), PendingIntent.FLAG_IMMUTABLE)) + .setSmallIcon(R.drawable.ic_launcher_foreground).setOngoing(true).setPriority(PRIORITY_LOW); + this.startForeground(1, notificationBuilder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION); + + screenCapDisplay.setForce(prefs.getBoolean("software_video_encoder", false) ? CodecUtil.Force.SOFTWARE : CodecUtil.Force.FIRST_COMPATIBLE_FOUND, + prefs.getBoolean("software_audio_encoder", false) ? CodecUtil.Force.SOFTWARE : CodecUtil.Force.FIRST_COMPATIBLE_FOUND); + screenCapDisplay.setWriteChunkSize(prefs.getInt("write_chunk_size_int", 1024)); + screenCapDisplay.setReTries(prefs.getInt("retries_int", 0)); + screenCapDisplay.resizeCache(prefs.getInt("cache_size_int", 120)); + screenCapDisplay.setLogs(prefs.getBoolean("enable_lib_logs", true)); + if (prefs.getBoolean("use_gl", true) && prefs.getBoolean("use_force_render", true)) { + screenCapDisplay.getGlInterface().setForceRender(true); + } + int video_combined = 192001080; + try { + video_combined = Integer.parseInt(prefs.getString("resolution", "match_device")); + } + catch (NumberFormatException e) { + Point resolution = new Point(); + ((WindowManager)getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay().getRealSize(resolution); + assert resolution.x < RESOLUTION_GAP && resolution.y < RESOLUTION_GAP; + video_combined = resolution.x * RESOLUTION_GAP + resolution.y; + } + int video_width = video_combined / RESOLUTION_GAP; + int video_height = video_combined % RESOLUTION_GAP; + if (prefs.getBoolean("screen_landscape", true) != video_width > video_height) { + int tmp = video_height; + video_height = video_width; + video_width = tmp; + } + Log.d(TAG, String.format("capture width %d height %d", video_width, video_height)); + if (this.screenCapDisplay.prepareInternalAudio(Integer.parseInt(prefs.getString("audio_bitrate", "131072")), 48000, prefs.getBoolean("stereo_audio", true), false, false) && + this.screenCapDisplay.prepareVideo(video_width, video_height, Integer.parseInt(prefs.getString("fps", "30")), Integer.parseInt(prefs.getString("video_bitrate", "10485760")), 0, 320, -1, -1, 2)) { + this.screenCapDisplay.startStream(server); + } + else { + Log.d(TAG, "cannot prepare video audio"); + String text = getString(R.string.error_prepare_video_audio); + setInfoDisplay(text); + showToast(text); + this.stop(); + } + } + + private void stop() { + Log.d(TAG, "stop called"); + this.connected = false; + this.stopForeground(true); + this.stopSelf(); + this.connectionStateChange(false); + } + + private void connectionStateChange(boolean connected) { + synchronized (this.connectionStateCallbacks) { + for (Consumer booleanConsumer : this.connectionStateCallbacks) { + try { + booleanConsumer.accept(connected); + } + catch (Throwable ignored) {} + } + } + } + + protected void addConnectionStateCallback(Consumer runnable) { + synchronized (this.connectionStateCallbacks) { + this.connectionStateCallbacks.add(runnable); + } + } + + protected void addInfoDisplayCallback(Consumer stringConsumer) { + synchronized (this.infoDisplayCallbacks) { + this.infoDisplayCallbacks.add(stringConsumer); + } + } + + protected void removeConnectionStateCallback(Consumer runnable) { + synchronized (this.connectionStateCallbacks) { + this.connectionStateCallbacks.remove(runnable); + } + } + + protected void removeInfoDisplayCallback(Consumer stringConsumer) { + synchronized (this.infoDisplayCallbacks) { + this.infoDisplayCallbacks.remove(stringConsumer); + } + } + + private void setInfoDisplay(String t) { + synchronized (this.infoDisplayCallbacks) { + this.infoDisplayText = t; + for (Consumer stringConsumer : this.infoDisplayCallbacks) { + try { + stringConsumer.accept(t); + } + catch (Throwable ignored) {} + } + } + } + + private void showToast(String text) { + (new Handler(Looper.getMainLooper())).post(() -> { + Toast.makeText(ScreenCapService.this, text, Toast.LENGTH_SHORT).show(); + }); + } +} diff --git a/app/src/main/java/com/jerryxiao/droidcast/SettingsActivity.java b/app/src/main/java/com/jerryxiao/droidcast/SettingsActivity.java new file mode 100644 index 0000000..6657808 --- /dev/null +++ b/app/src/main/java/com/jerryxiao/droidcast/SettingsActivity.java @@ -0,0 +1,39 @@ +package com.jerryxiao.droidcast; + +import android.os.Bundle; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceFragmentCompat; + +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.settings_activity); + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings_frame, new SettingsFragment()) + .commit(); + } + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.preferences, rootKey); + } + } +} diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml new file mode 100644 index 0000000..ae04844 --- /dev/null +++ b/app/src/main/res/layout/main_activity.xml @@ -0,0 +1,55 @@ + + + + + +