commit 477bd580e94099f2a2041d3f8369b32f9bb2efbb 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..4912ce4 --- /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_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +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..bdf0a70 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..37421ae --- /dev/null +++ b/app/src/main/java/com/jerryxiao/droidcast/MainActivity.java @@ -0,0 +1,102 @@ +package com.jerryxiao.droidcast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +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.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import com.pedro.rtplibrary.rtmp.RtmpDisplay; + +public class MainActivity extends AppCompatActivity { + + private RtmpDisplay screenCapDisplay; + private ActivityResultLauncher screenCapReq; + private ScreenCapService screenCapService; + private Button startButton; + private TextView infoDisplay; + private SharedPreferences prefs; + private final ServiceConnection serviceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName componentName, IBinder binder) { + MainActivity.this.screenCapService = ((ScreenCapService.MBinder)binder).getService(); + MainActivity.this.screenCapDisplay = MainActivity.this.screenCapService.getScreenCapDisplay(); + MainActivity.this.screenCapReq.launch(MainActivity.this.screenCapDisplay.sendIntent()); + MainActivity.this.screenCapService.addDisconnectCallback(() -> { + unbindService(serviceConnection); + runOnUiThread(() -> { + startButton.setText(getString(R.string.start_button)); + }); + MainActivity.this.screenCapService = null; + }); + MainActivity.this.screenCapService.addInfoDisplayCallback((t) -> { + runOnUiThread(() -> { + MainActivity.this.infoDisplay.setText(t); + }); + }); + runOnUiThread(() -> { + MainActivity.this.startButton.setText(getString(R.string.stop_button)); + }); + } + + public void onServiceDisconnected(ComponentName componentName) { + } + }; + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.prefs = PreferenceManager.getDefaultSharedPreferences(this); + setContentView(R.layout.main_activity); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission("android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{"android.permission.POST_NOTIFICATIONS"}, 0); + } + } + else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (checkSelfPermission("android.permission.RECORD_AUDIO") != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{"android.permission.RECORD_AUDIO"}, 0); + } + } + this.startButton = (Button)this.findViewById(R.id.startButton); + this.infoDisplay = (TextView)this.findViewById(R.id.infoDisplay); + this.screenCapReq = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), (activityResult) -> { + int result = activityResult.getResultCode(); + screenCapDisplay.setIntentResult(result, activityResult.getData()); + if (result == RESULT_OK) { + screenCapService.startCapture(prefs.getString("server", "")); + } + else { + runOnUiThread(() -> { + Toast.makeText(MainActivity.this, getText(R.string.error_capture_denied), Toast.LENGTH_SHORT).show(); + }); + screenCapService.stopCapture(); + } + }); + this.startButton.setOnClickListener((view) -> { + if (screenCapService != null) { + new Thread(() -> { + screenCapService.stopCapture(); + }).start(); + } + else { + Intent intent = new Intent(this, ScreenCapService.class); + bindService(intent, serviceConnection, BIND_AUTO_CREATE); + } + }); + Button settingsButton = (Button)this.findViewById(R.id.settingsButton); + settingsButton.setOnClickListener((view) -> { + Intent intent = new Intent(this, SettingsActivity.class); + startActivity(intent); + }); + } +} 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..650c988 --- /dev/null +++ b/app/src/main/java/com/jerryxiao/droidcast/ScreenCapService.java @@ -0,0 +1,194 @@ +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.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.LinkedList; +import java.util.function.Consumer; + +public class ScreenCapService extends Service { + private static final int RESOLUTION_GAP = 100000; + private final LinkedList disconnectCallbacks = new LinkedList<>(); + private final LinkedList> infoDisplayCallbacks = new LinkedList<>(); + private SharedPreferences prefs; + 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; + } + + public IBinder onBind(Intent intent) { + return this.mBinder; + } + + public void onCreate() { + super.onCreate(); + this.prefs = PreferenceManager.getDefaultSharedPreferences(this); + this.screenCapDisplay = new RtmpDisplay(this, prefs.getBoolean("use_gl", true), new ConnectCheckerRtmp() { + public void onAuthErrorRtmp() { + String text = getString(R.string.status_auth_error); + (new Handler(Looper.getMainLooper())).post(() -> { + Toast.makeText(ScreenCapService.this, text, Toast.LENGTH_SHORT).show(); + }); + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(text); + } + ScreenCapService.this.stop(); + } + + public void onAuthSuccessRtmp() { + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(getString(R.string.status_auth_success)); + } + } + + public void onConnectionFailedRtmp(@NonNull String reason) { + String text = getString(R.string.status_connection_failed_format, reason); + (new Handler(Looper.getMainLooper())).post(() -> { + Toast.makeText(ScreenCapService.this, text, Toast.LENGTH_SHORT).show(); + }); + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(text); + } + ScreenCapService.this.stop(); + } + + public void onConnectionStartedRtmp(@NonNull String rtmpUrl) { + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(getString(R.string.status_connecting)); + } + } + + public void onConnectionSuccessRtmp() { + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(getString(R.string.status_connected)); + } + } + + public void onDisconnectRtmp() { + String text = getString(R.string.status_disconnected); + (new Handler(Looper.getMainLooper())).post(() -> { + Toast.makeText(ScreenCapService.this, text, Toast.LENGTH_SHORT).show(); + }); + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(text); + } + ScreenCapService.this.stop(); + } + + public void onNewBitrateRtmp(long bitrate) { + synchronized (ScreenCapService.this.infoDisplayCallbacks) { + for (Consumer stringConsumer : ScreenCapService.this.infoDisplayCallbacks) { + stringConsumer.accept(getString(R.string.status_bitrate_format, bitrate)); + } + } + } + }); + } + + protected void startCapture(String server) { + 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.resizeCache(prefs.getInt("cache_size_int", 120)); + 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; + } + 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 { + (new Handler(Looper.getMainLooper())).post(() -> { + Toast.makeText(ScreenCapService.this, "Cannot prepare audio and video", Toast.LENGTH_SHORT).show(); + }); + this.stop(); + } + } + + private void stop() { + this.stopForeground(true); + this.stopSelf(); + synchronized (this.disconnectCallbacks) { + while (!this.disconnectCallbacks.isEmpty()) { + this.disconnectCallbacks.pop().run(); + } + } + synchronized (this.infoDisplayCallbacks) { + this.infoDisplayCallbacks.clear(); + } + } + + protected void addDisconnectCallback(Runnable runnable) { + synchronized (this.disconnectCallbacks) { + this.disconnectCallbacks.add(runnable); + } + } + + protected void addInfoDisplayCallback(Consumer stringConsumer) { + synchronized (this.infoDisplayCallbacks) { + this.infoDisplayCallbacks.add(stringConsumer); + } + } + + protected void stopCapture() { + this.screenCapDisplay.stopStream(); + this.stop(); + } +} 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..5aa7ba2 --- /dev/null +++ b/app/src/main/java/com/jerryxiao/droidcast/SettingsActivity.java @@ -0,0 +1,33 @@ +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(false); + } + } + + 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..e41b15e --- /dev/null +++ b/app/src/main/res/layout/main_activity.xml @@ -0,0 +1,43 @@ + + + +