From ab5c28b49a33226c2f3e566bf2a5e323f0315bfa Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 17 May 2026 18:17:14 +0200 Subject: [PATCH] ref --- app/src/main/AndroidManifest.xml | 8 + .../androidcast/AndroidCastApplication.java | 12 + .../com/foxx/androidcast/AppPreferences.java | 179 +++++++ .../com/foxx/androidcast/CastActiveState.java | 9 + .../foxx/androidcast/CastActivityTracker.java | 28 ++ .../foxx/androidcast/CastNotifications.java | 4 +- .../com/foxx/androidcast/CastSettings.java | 73 ++- .../androidcast/CastSettingsActivity.java | 66 +++ .../foxx/androidcast/CastSettingsBinder.java | 443 ++++++++++++++++++ .../androidcast/CastSettingsFragment.java | 105 +++++ .../com/foxx/androidcast/CastThemeHelper.java | 57 +++ .../foxx/androidcast/CastTrayNotifier.java | 101 ++++ .../foxx/androidcast/DrawerHostActivity.java | 69 +++ .../com/foxx/androidcast/MainActivity.java | 23 +- .../java/com/foxx/androidcast/PinAuth.java | 38 ++ .../foxx/androidcast/PlatformCastSupport.java | 23 + .../discovery/DiscoveryManager.java | 48 +- .../androidcast/network/CastProtocol.java | 11 + .../receiver/ReceiverActivity.java | 43 +- .../receiver/ReceiverCastService.java | 23 +- .../receiver/ReceiverPlaybackActivity.java | 44 +- .../sender/MultiCastCoordinator.java | 2 + .../androidcast/sender/ScreenCastService.java | 25 +- .../androidcast/sender/SenderActivity.java | 387 ++++++++------- .../sender/SenderCapturePreview.java | 274 +++++++++++ .../sender/SenderScreenPreview.java | 150 ++++++ .../sender/camera/CameraCastCapture.java | 14 +- .../drawable/bg_settings_section_header.xml | 24 + .../main/res/layout-land/activity_sender.xml | 100 ++++ .../res/layout/activity_cast_settings.xml | 239 ++++++++++ .../main/res/layout/activity_drawer_host.xml | 21 + app/src/main/res/layout/activity_main.xml | 9 + app/src/main/res/layout/activity_receiver.xml | 70 ++- app/src/main/res/layout/activity_sender.xml | 198 ++++---- .../res/layout/fragment_cast_settings.xml | 248 ++++++++++ .../include_settings_section_header.xml | 15 + app/src/main/res/values-night/colors.xml | 6 +- app/src/main/res/values/colors.xml | 11 +- app/src/main/res/values/ids.xml | 5 + app/src/main/res/values/strings.xml | 46 ++ app/src/main/res/values/themes.xml | 22 + 41 files changed, 2852 insertions(+), 421 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java create mode 100644 app/src/main/java/com/foxx/androidcast/AppPreferences.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastActivityTracker.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastThemeHelper.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java create mode 100644 app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java create mode 100644 app/src/main/java/com/foxx/androidcast/PinAuth.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java create mode 100644 app/src/main/res/drawable/bg_settings_section_header.xml create mode 100644 app/src/main/res/layout-land/activity_sender.xml create mode 100644 app/src/main/res/layout/activity_cast_settings.xml create mode 100644 app/src/main/res/layout/activity_drawer_host.xml create mode 100644 app/src/main/res/layout/fragment_cast_settings.xml create mode 100644 app/src/main/res/layout/include_settings_section_header.xml create mode 100644 app/src/main/res/values/ids.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e42917a..4245e6c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + + > T readEnum(SharedPreferences p, String key, T[] values, T fallback) { + String name = p.getString(key, fallback.name()); + for (T v : values) { + if (v.name().equals(name)) { + return v; + } + } + return fallback; + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastActiveState.java b/app/src/main/java/com/foxx/androidcast/CastActiveState.java index fd8e798..9db3e4b 100644 --- a/app/src/main/java/com/foxx/androidcast/CastActiveState.java +++ b/app/src/main/java/com/foxx/androidcast/CastActiveState.java @@ -5,6 +5,7 @@ import java.util.concurrent.atomic.AtomicBoolean; /** Process-wide cast session flags for UI back-key handling. */ public final class CastActiveState { private static final AtomicBoolean senderCasting = new AtomicBoolean(false); + private static final AtomicBoolean receiverListening = new AtomicBoolean(false); private CastActiveState() {} @@ -15,4 +16,12 @@ public final class CastActiveState { public static boolean isSenderCasting() { return senderCasting.get(); } + + public static void setReceiverListening(boolean active) { + receiverListening.set(active); + } + + public static boolean isReceiverListening() { + return receiverListening.get(); + } } diff --git a/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java b/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java new file mode 100644 index 0000000..568d3ab --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java @@ -0,0 +1,28 @@ +package com.foxx.androidcast; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; + +/** Remembers the last foreground activity for tray / notification resume. */ +public final class CastActivityTracker { + private static volatile Class lastActivityClass; + + private CastActivityTracker() {} + + public static void onResume(Activity activity) { + if (activity != null) { + lastActivityClass = activity.getClass(); + } + } + + public static Intent resumeIntent(Context context) { + Class cls = lastActivityClass; + if (cls != null) { + Intent intent = new Intent(context, cls); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + return intent; + } + return new Intent(context, MainActivity.class); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastNotifications.java b/app/src/main/java/com/foxx/androidcast/CastNotifications.java index 4451ba3..6d551f4 100644 --- a/app/src/main/java/com/foxx/androidcast/CastNotifications.java +++ b/app/src/main/java/com/foxx/androidcast/CastNotifications.java @@ -42,9 +42,9 @@ public final class CastNotifications { private static Notification build( Service service, String channel, int id, String text, Class serviceClass, String stopAction) { - Intent open = new Intent(service, MainActivity.class); PendingIntent pi = PendingIntent.getActivity( - service, 0, open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + service, 0, CastActivityTracker.resumeIntent(service), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); Intent stop = new Intent(service, serviceClass); stop.setAction(stopAction); PendingIntent stopPi = PendingIntent.getService( diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java index 7f13351..87d6563 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettings.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java @@ -5,7 +5,7 @@ import java.io.Serializable; /** User-selected cast parameters (passed sender → service → receiver via protocol). */ public class CastSettings implements Serializable { public static final String EXTRA = "cast_settings"; - public static final int SETTINGS_VERSION = 3; + public static final int SETTINGS_VERSION = 4; public enum Quality { LOW(1_500_000, 24), @@ -49,11 +49,25 @@ public class CastSettings implements Serializable { USER_CHOICE, /** Generated TV calibration pattern (no MediaProjection). */ CALIBRATION_TEST, - /** - * Future: Camera2 preview as video source (not MediaProjection app picker). - * {@link PlatformCastSupport#isCameraCaptureModeSupported()} - */ - CAMERA + /** Camera2 rear camera as video source. */ + CAMERA, + /** Camera2 front camera as video source. */ + CAMERA_FRONT + } + + /** UDP stream protection hint (negotiated with peer; stubs until implemented). */ + public enum StreamProtection { + AUTO, + FEC_3_4, + FEC_REED_SOLOMON, + NACK, + FEC_NACK + } + + /** How the receiver fits incoming video on its display. */ + public enum ReceiverDisplayResolution { + AUTO_FIT, + ADAPTIVE_EXPERIMENTAL } public enum Resolution { @@ -78,13 +92,15 @@ public class CastSettings implements Serializable { private String transport = CastConfig.DEFAULT_TRANSPORT; private Quality quality = Quality.MEDIUM; - private Resolution resolution = Resolution.HD_720P; + private Resolution resolution = Resolution.ADAPTIVE; private boolean audioEnabled; private VideoCodec videoCodec = VideoCodec.AUTO; private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO; private BitrateMode bitrateMode = BitrateMode.AUTO; private NetworkAdoption networkAdoption = NetworkAdoption.AUTO; private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode(); + private StreamProtection streamProtection = StreamProtection.AUTO; + private ReceiverDisplayResolution receiverDisplayResolution = ReceiverDisplayResolution.AUTO_FIT; /** Filled after codec handshake (e.g. video/avc). */ private transient String negotiatedVideoMime; @@ -200,4 +216,47 @@ public class CastSettings implements Serializable { public void setCaptureMode(CaptureMode captureMode) { this.captureMode = captureMode != null ? captureMode : CaptureMode.FULL_SCREEN; } + + public StreamProtection getStreamProtection() { + return streamProtection; + } + + public void setStreamProtection(StreamProtection streamProtection) { + this.streamProtection = streamProtection != null ? streamProtection : StreamProtection.AUTO; + } + + public ReceiverDisplayResolution getReceiverDisplayResolution() { + return receiverDisplayResolution; + } + + public void setReceiverDisplayResolution(ReceiverDisplayResolution receiverDisplayResolution) { + this.receiverDisplayResolution = receiverDisplayResolution != null + ? receiverDisplayResolution : ReceiverDisplayResolution.AUTO_FIT; + } + + public boolean isCameraCapture() { + return captureMode == CaptureMode.CAMERA || captureMode == CaptureMode.CAMERA_FRONT; + } + + public boolean isFrontCamera() { + return captureMode == CaptureMode.CAMERA_FRONT; + } + + public void copyFrom(CastSettings other) { + if (other == null) { + return; + } + transport = other.transport; + quality = other.quality; + resolution = other.resolution; + audioEnabled = other.audioEnabled; + videoCodec = other.videoCodec; + adjustmentPreset = other.adjustmentPreset; + bitrateMode = other.bitrateMode; + networkAdoption = other.networkAdoption; + captureMode = other.captureMode; + streamProtection = other.streamProtection; + receiverDisplayResolution = other.receiverDisplayResolution; + negotiatedVideoMime = other.negotiatedVideoMime; + } } diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java new file mode 100644 index 0000000..c24599c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java @@ -0,0 +1,66 @@ +package com.foxx.androidcast; + +import android.os.Bundle; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.foxx.androidcast.receiver.ReceiverPrefs; + +/** Global, sender, and receiver default cast settings. */ +public class CastSettingsActivity extends AppCompatActivity { + private final CastSettings senderSettings = new CastSettings(); + private final CastSettings receiverSettings = new CastSettings(); + + private EditText usernameInput; + private EditText pinInput; + private CheckBox trayIconCheck; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_cast_settings); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + usernameInput = findViewById(R.id.edit_username); + pinInput = findViewById(R.id.edit_settings_pin); + trayIconCheck = findViewById(R.id.check_tray_icon); + + senderSettings.copyFrom(AppPreferences.loadSenderDefaults(this)); + receiverSettings.copyFrom(AppPreferences.loadReceiverDefaults(this)); + + CastSettingsBinder.bindGlobal(this, usernameInput, pinInput, trayIconCheck); + CastSettingsBinder.bindSender(this, senderSettings); + CastSettingsBinder.bindReceiver(this, receiverSettings); + } + + @Override + protected void onResume() { + super.onResume(); + CastActivityTracker.onResume(this); + } + + @Override + protected void onPause() { + saveAll(); + super.onPause(); + } + + @Override + public boolean onSupportNavigateUp() { + finish(); + return true; + } + + private void saveAll() { + CastSettingsBinder.saveGlobal(this, usernameInput, pinInput, trayIconCheck); + CastSettingsBinder.saveSender(this, senderSettings); + CastSettingsBinder.saveReceiver(this, receiverSettings); + ReceiverPrefs.setAllowAudio(this, AppPreferences.isPlayIncomingAudio(this)); + Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java new file mode 100644 index 0000000..9701e82 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -0,0 +1,443 @@ +package com.foxx.androidcast; + +import android.os.Build; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import com.foxx.androidcast.sender.CodecCatalog; + +import java.util.ArrayList; +import java.util.List; + +/** Binds settings UI widgets to {@link AppPreferences} and {@link CastSettings}. */ +public final class CastSettingsBinder { + private CastSettingsBinder() {} + + public static void bindGlobal(AppCompatActivity activity, EditText username, EditText pin, + CheckBox trayIcon) { + username.setText(AppPreferences.getUsername(activity)); + pin.setText(AppPreferences.getPin(activity)); + trayIcon.setChecked(AppPreferences.isShowTrayIcon(activity)); + } + + public static void saveGlobal(AppCompatActivity activity, EditText username, EditText pin, + CheckBox trayIcon) { + String name = username.getText().toString().trim(); + if (name.isEmpty()) { + name = AppPreferences.defaultDeviceName(activity); + username.setText(name); + } + AppPreferences.setUsername(activity, name); + AppPreferences.setPin(activity, pin.getText().toString().trim()); + AppPreferences.setShowTrayIcon(activity, trayIcon.isChecked()); + } + + public static void bindTheme(AppCompatActivity activity, Spinner spinner) { + CastThemeHelper.AppThemeMode[] modes = CastThemeHelper.AppThemeMode.values(); + String[] labels = { + activity.getString(R.string.theme_system), + activity.getString(R.string.theme_dark), + activity.getString(R.string.theme_light), + activity.getString(R.string.theme_inverted) + }; + bindEnumSpinner(spinner, modes, labels, AppPreferences.getThemeMode(activity), + mode -> AppPreferences.setThemeMode(activity, mode)); + } + + public static void saveTheme(AppCompatActivity activity, Spinner spinner) { + readEnumSpinner(spinner, (CastThemeHelper.AppThemeMode mode) -> + AppPreferences.setThemeMode(activity, mode)); + } + + public static CastThemeHelper.AppThemeMode themeModeAt(int position) { + CastThemeHelper.AppThemeMode[] modes = CastThemeHelper.AppThemeMode.values(); + if (position >= 0 && position < modes.length) { + return modes[position]; + } + return CastThemeHelper.AppThemeMode.SYSTEM; + } + + public static void bindSender(AppCompatActivity activity, CastSettings settings) { + bindTransport(activity, settings, R.id.spinner_sender_transport, true); + bindVideoCodec(activity, settings, R.id.spinner_sender_video_codec); + bindBitrate(activity, settings); + bindNetworkAdoption(activity, settings, R.id.spinner_sender_network); + bindSenderResolution(activity, settings); + bindCaptureMode(activity, settings); + bindStreamProtection(activity, settings); + bindAudioSpinner(activity, settings, R.id.spinner_sender_audio); + } + + public static void bindReceiver(AppCompatActivity activity, CastSettings settings) { + bindTransport(activity, settings, R.id.spinner_receiver_transport, true); + bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); + bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network); + bindReceiverDisplay(activity, settings); + Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); + if (audioPlay != null) { + bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity), + on -> AppPreferences.setPlayIncomingAudio(activity, on)); + } + } + + public static void saveSender(AppCompatActivity activity, CastSettings settings) { + readTransport(activity, settings, R.id.spinner_sender_transport); + readVideoCodec(activity, settings, R.id.spinner_sender_video_codec); + readBitrate(activity, settings); + readNetworkAdoption(activity, settings, R.id.spinner_sender_network); + readSenderResolution(activity, settings); + readCaptureMode(activity, settings); + readStreamProtection(activity, settings); + readAudioSpinner(activity, settings, R.id.spinner_sender_audio); + settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); + AppPreferences.saveSenderDefaults(activity, settings); + } + + public static void saveReceiver(AppCompatActivity activity, CastSettings settings) { + readTransport(activity, settings, R.id.spinner_receiver_transport); + readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); + readNetworkAdoption(activity, settings, R.id.spinner_receiver_network); + readReceiverDisplay(activity, settings); + Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); + if (audioPlay != null) { + AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0); + } + settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); + AppPreferences.saveReceiverDefaults(activity, settings); + } + + private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId, + boolean unused) { + Spinner spinner = activity.findViewById(spinnerId); + String[] labels = { + activity.getString(R.string.transport_udp), + activity.getString(R.string.transport_tcp), + activity.getString(R.string.transport_quic) + }; + String[] values = { + CastConfig.TRANSPORT_UDP, + CastConfig.TRANSPORT_TCP, + CastConfig.TRANSPORT_QUIC + }; + bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> { + settings.setTransport(v); + updateStreamProtectionEnabled(activity, settings); + }); + updateStreamProtectionEnabled(activity, settings); + } + + private static void readTransport(AppCompatActivity activity, CastSettings settings, int spinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + Object tag = spinner.getTag(R.id.tag_spinner_values); + if (tag instanceof String[]) { + int pos = spinner.getSelectedItemPosition(); + String[] values = (String[]) tag; + if (pos >= 0 && pos < values.length) { + settings.setTransport(values[pos]); + } + } + } + + private static void bindVideoCodec(AppCompatActivity activity, CastSettings settings, int spinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + List codecs = CodecCatalog.supportedChoices(); + List labels = new ArrayList<>(); + for (CastSettings.VideoCodec c : codecs) { + labels.add(labelVideoCodec(activity, c)); + } + bindEnumSpinner(spinner, codecs.toArray(new CastSettings.VideoCodec[0]), + labels.toArray(new String[0]), settings.getVideoCodec(), + v -> settings.setVideoCodec(v)); + } + + private static void readVideoCodec(AppCompatActivity activity, CastSettings settings, int spinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + Object tag = spinner.getTag(R.id.tag_spinner_enum); + if (tag instanceof CastSettings.VideoCodec[]) { + CastSettings.VideoCodec[] values = (CastSettings.VideoCodec[]) tag; + int pos = spinner.getSelectedItemPosition(); + if (pos >= 0 && pos < values.length) { + settings.setVideoCodec(values[pos]); + } + } + } + + private static void bindBitrate(AppCompatActivity activity, CastSettings settings) { + Spinner spinner = activity.findViewById(R.id.spinner_sender_bitrate); + CastSettings.BitrateMode[] modes = { + CastSettings.BitrateMode.ESTIMATED, + CastSettings.BitrateMode.QUALITY, + CastSettings.BitrateMode.SPEED, + CastSettings.BitrateMode.BALANCED + }; + String[] labels = { + activity.getString(R.string.bitrate_estimated), + activity.getString(R.string.option_quality), + activity.getString(R.string.option_speed), + activity.getString(R.string.option_balanced) + }; + CastSettings.BitrateMode current = settings.getBitrateMode(); + if (current == CastSettings.BitrateMode.AUTO) { + current = CastSettings.BitrateMode.ESTIMATED; + } + bindEnumSpinner(spinner, modes, labels, current, settings::setBitrateMode); + } + + private static void readBitrate(AppCompatActivity activity, CastSettings settings) { + readEnumSpinner(activity.findViewById(R.id.spinner_sender_bitrate), settings::setBitrateMode); + } + + private static void bindNetworkAdoption(AppCompatActivity activity, CastSettings settings, int spinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + CastSettings.NetworkAdoption[] modes = { + CastSettings.NetworkAdoption.ESTIMATED, + CastSettings.NetworkAdoption.QUALITY, + CastSettings.NetworkAdoption.SPEED + }; + String[] labels = { + activity.getString(R.string.network_estimated_auto), + activity.getString(R.string.option_quality), + activity.getString(R.string.option_speed) + }; + CastSettings.NetworkAdoption current = settings.getNetworkAdoption(); + if (current == CastSettings.NetworkAdoption.AUTO) { + current = CastSettings.NetworkAdoption.ESTIMATED; + } + bindEnumSpinner(spinner, modes, labels, current, settings::setNetworkAdoption); + } + + private static void readNetworkAdoption(AppCompatActivity activity, CastSettings settings, int spinnerId) { + readEnumSpinner(activity.findViewById(spinnerId), settings::setNetworkAdoption); + } + + private static void bindSenderResolution(AppCompatActivity activity, CastSettings settings) { + Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution); + CastSettings.Resolution[] modes = { + CastSettings.Resolution.ADAPTIVE, + CastSettings.Resolution.FIXED_1080P, + CastSettings.Resolution.FIXED_720P, + CastSettings.Resolution.FULL, + CastSettings.Resolution.THREE_QUARTERS + }; + String[] labels = { + activity.getString(R.string.resolution_adaptive), + activity.getString(R.string.resolution_fixed_1080p), + activity.getString(R.string.resolution_fixed_720p), + activity.getString(R.string.resolution_full), + activity.getString(R.string.resolution_75) + }; + bindEnumSpinner(spinner, modes, labels, settings.getResolution(), settings::setResolution); + } + + private static void readSenderResolution(AppCompatActivity activity, CastSettings settings) { + readEnumSpinner(activity.findViewById(R.id.spinner_sender_resolution), settings::setResolution); + } + + private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) { + Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution); + CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values(); + String[] labels = { + activity.getString(R.string.receiver_display_auto_fit), + activity.getString(R.string.receiver_display_adaptive) + }; + bindEnumSpinner(spinner, modes, labels, settings.getReceiverDisplayResolution(), + settings::setReceiverDisplayResolution); + } + + private static void readReceiverDisplay(AppCompatActivity activity, CastSettings settings) { + readEnumSpinner(activity.findViewById(R.id.spinner_receiver_resolution), + settings::setReceiverDisplayResolution); + } + + private static void bindCaptureMode(AppCompatActivity activity, CastSettings settings) { + Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode); + List labels = new ArrayList<>(); + List values = new ArrayList<>(); + labels.add(activity.getString(R.string.capture_full_screen)); + values.add(CastSettings.CaptureMode.FULL_SCREEN); + if (PlatformCastSupport.isCaptureUserChoiceSupported()) { + labels.add(activity.getString(R.string.capture_user_choice)); + values.add(CastSettings.CaptureMode.USER_CHOICE); + } + if (PlatformCastSupport.isCameraCaptureModeSupported()) { + labels.add(activity.getString(R.string.capture_camera_rear)); + values.add(CastSettings.CaptureMode.CAMERA); + if (PlatformCastSupport.hasFrontCamera(activity)) { + labels.add(activity.getString(R.string.capture_camera_front)); + values.add(CastSettings.CaptureMode.CAMERA_FRONT); + } + } + labels.add(activity.getString(R.string.capture_calibration_test)); + values.add(CastSettings.CaptureMode.CALIBRATION_TEST); + bindEnumSpinner(spinner, values.toArray(new CastSettings.CaptureMode[0]), + labels.toArray(new String[0]), settings.getCaptureMode(), settings::setCaptureMode); + } + + private static void readCaptureMode(AppCompatActivity activity, CastSettings settings) { + readEnumSpinner(activity.findViewById(R.id.spinner_sender_capture_mode), settings::setCaptureMode); + } + + private static void bindStreamProtection(AppCompatActivity activity, CastSettings settings) { + Spinner spinner = activity.findViewById(R.id.spinner_sender_stream_protection); + CastSettings.StreamProtection[] modes = CastSettings.StreamProtection.values(); + String[] labels = { + activity.getString(R.string.option_auto), + activity.getString(R.string.protection_fec_34), + activity.getString(R.string.protection_fec_rs), + activity.getString(R.string.protection_nack), + activity.getString(R.string.protection_fec_nack) + }; + bindEnumSpinner(spinner, modes, labels, settings.getStreamProtection(), + settings::setStreamProtection); + updateStreamProtectionEnabled(activity, settings); + } + + private static void readStreamProtection(AppCompatActivity activity, CastSettings settings) { + readEnumSpinner(activity.findViewById(R.id.spinner_sender_stream_protection), + settings::setStreamProtection); + } + + private static void updateStreamProtectionEnabled(AppCompatActivity activity, CastSettings settings) { + Spinner protection = activity.findViewById(R.id.spinner_sender_stream_protection); + if (protection == null) { + return; + } + boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport()); + protection.setEnabled(udp); + TextView hint = activity.findViewById(R.id.text_stream_protection_hint); + if (hint != null) { + hint.setVisibility(udp ? View.VISIBLE : View.GONE); + } + } + + private static void bindAudioSpinner(AppCompatActivity activity, CastSettings settings, int spinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + TextView hint = activity.findViewById(R.id.text_sender_audio_hint); + if (!PlatformCastSupport.isAudioCaptureSupported()) { + spinner.setEnabled(false); + if (hint != null) { + hint.setVisibility(View.VISIBLE); + hint.setText(R.string.label_audio_unavailable); + } + settings.setAudioEnabled(false); + return; + } + if (hint != null) { + hint.setVisibility(View.GONE); + } + bindOnOffSpinner(activity, spinner, settings.isAudioRequested(), + settings::setAudioEnabled); + } + + private static void readAudioSpinner(AppCompatActivity activity, CastSettings settings, int spinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + if (!PlatformCastSupport.isAudioCaptureSupported()) { + settings.setAudioEnabled(false); + return; + } + settings.setAudioEnabled(spinner.getSelectedItemPosition() == 0); + } + + private static void bindOnOffSpinner(AppCompatActivity activity, Spinner spinner, boolean on, + java.util.function.Consumer consumer) { + String[] labels = {activity.getString(R.string.option_on), activity.getString(R.string.option_off)}; + spinner.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, labels)); + spinner.setSelection(on ? 0 : 1); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + consumer.accept(position == 0); + } + + @Override + public void onNothingSelected(AdapterView parent) {} + }); + } + + private static void bindStringSpinner(Spinner spinner, String[] labels, String[] values, + String current, java.util.function.Consumer onSelect) { + spinner.setTag(R.id.tag_spinner_values, values); + spinner.setAdapter(new ArrayAdapter<>(spinner.getContext(), + android.R.layout.simple_spinner_dropdown_item, labels)); + int sel = 0; + for (int i = 0; i < values.length; i++) { + if (values[i].equals(current)) { + sel = i; + break; + } + } + spinner.setSelection(sel); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (position >= 0 && position < values.length) { + onSelect.accept(values[position]); + } + } + + @Override + public void onNothingSelected(AdapterView parent) {} + }); + } + + private static > void bindEnumSpinner(Spinner spinner, T[] values, String[] labels, + T current, java.util.function.Consumer onSelect) { + spinner.setTag(R.id.tag_spinner_enum, values); + spinner.setAdapter(new ArrayAdapter<>(spinner.getContext(), + android.R.layout.simple_spinner_dropdown_item, labels)); + int sel = 0; + for (int i = 0; i < values.length; i++) { + if (values[i] == current) { + sel = i; + break; + } + } + spinner.setSelection(sel); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (position >= 0 && position < values.length) { + onSelect.accept(values[position]); + } + } + + @Override + public void onNothingSelected(AdapterView parent) {} + }); + } + + private static > void readEnumSpinner(Spinner spinner, + java.util.function.Consumer consumer) { + Object tag = spinner.getTag(R.id.tag_spinner_enum); + if (tag instanceof Enum[]) { + Enum[] values = (Enum[]) tag; + int pos = spinner.getSelectedItemPosition(); + if (pos >= 0 && pos < values.length) { + @SuppressWarnings("unchecked") + T v = (T) values[pos]; + consumer.accept(v); + } + } + } + + private static String labelVideoCodec(AppCompatActivity activity, CastSettings.VideoCodec codec) { + switch (codec) { + case H264: + return activity.getString(R.string.codec_h264); + case H265: + return activity.getString(R.string.codec_h265); + case PASSTHROUGH: + return activity.getString(R.string.codec_passthrough); + case AUTO: + default: + return activity.getString(R.string.option_auto); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java new file mode 100644 index 0000000..fbe4afa --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java @@ -0,0 +1,105 @@ +package com.foxx.androidcast; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; + +import com.foxx.androidcast.receiver.ReceiverPrefs; + +/** Settings drawer content — swipe from the left edge on main screens. */ +public class CastSettingsFragment extends Fragment { + private final CastSettings senderSettings = new CastSettings(); + private final CastSettings receiverSettings = new CastSettings(); + + private EditText usernameInput; + private EditText pinInput; + private CheckBox trayIconCheck; + private Spinner themeSpinner; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cast_settings, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + TextView headerGlobal = view.findViewById(R.id.header_global); + TextView headerSender = view.findViewById(R.id.header_sender); + TextView headerReceiver = view.findViewById(R.id.header_receiver); + headerGlobal.setText(R.string.settings_section_global); + headerSender.setText(R.string.settings_section_sender); + headerReceiver.setText(R.string.settings_section_receiver); + + usernameInput = view.findViewById(R.id.edit_username); + pinInput = view.findViewById(R.id.edit_settings_pin); + trayIconCheck = view.findViewById(R.id.check_tray_icon); + themeSpinner = view.findViewById(R.id.spinner_theme); + + senderSettings.copyFrom(AppPreferences.loadSenderDefaults(requireContext())); + receiverSettings.copyFrom(AppPreferences.loadReceiverDefaults(requireContext())); + + AppCompatActivity activity = (AppCompatActivity) requireActivity(); + CastSettingsBinder.bindGlobal(activity, usernameInput, pinInput, trayIconCheck); + CastSettingsBinder.bindTheme(activity, themeSpinner); + CastSettingsBinder.bindSender(activity, senderSettings); + CastSettingsBinder.bindReceiver(activity, receiverSettings); + + trayIconCheck.setOnCheckedChangeListener((btn, checked) -> { + AppPreferences.setShowTrayIcon(requireContext(), checked); + CastTrayNotifier.onPreferenceChanged(requireContext()); + }); + themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + private boolean first = true; + + @Override + public void onItemSelected(android.widget.AdapterView parent, View v, int position, long id) { + CastThemeHelper.AppThemeMode mode = CastSettingsBinder.themeModeAt(position); + if (first) { + first = false; + return; + } + if (mode != AppPreferences.getThemeMode(requireContext())) { + AppPreferences.setThemeMode(requireContext(), mode); + Toast.makeText(requireContext(), R.string.theme_apply_restart, Toast.LENGTH_LONG).show(); + requireActivity().recreate(); + } + } + + @Override + public void onNothingSelected(android.widget.AdapterView parent) {} + }); + } + + @Override + public void onPause() { + saveAll(); + super.onPause(); + } + + private void saveAll() { + if (usernameInput == null) { + return; + } + AppCompatActivity activity = (AppCompatActivity) requireActivity(); + CastSettingsBinder.saveGlobal(activity, usernameInput, pinInput, trayIconCheck); + CastSettingsBinder.saveTheme(activity, themeSpinner); + CastSettingsBinder.saveSender(activity, senderSettings); + CastSettingsBinder.saveReceiver(activity, receiverSettings); + ReceiverPrefs.setAllowAudio(requireContext(), AppPreferences.isPlayIncomingAudio(requireContext())); + CastTrayNotifier.onPreferenceChanged(requireContext()); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastThemeHelper.java b/app/src/main/java/com/foxx/androidcast/CastThemeHelper.java new file mode 100644 index 0000000..28c90e0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastThemeHelper.java @@ -0,0 +1,57 @@ +package com.foxx.androidcast; + +import android.app.Activity; +import android.content.Context; + +import androidx.appcompat.app.AppCompatDelegate; + +/** Applies user-selected app theme before {@link Activity#setContentView}. */ +public final class CastThemeHelper { + private CastThemeHelper() {} + + public enum AppThemeMode { + SYSTEM, + DARK, + LIGHT, + INVERTED + } + + public static void applyTheme(Activity activity) { + AppThemeMode mode = AppPreferences.getThemeMode(activity); + switch (mode) { + case DARK: + activity.setTheme(R.style.Theme_AndroidCast_Dark); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case LIGHT: + activity.setTheme(R.style.Theme_AndroidCast_Light); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case INVERTED: + activity.setTheme(R.style.Theme_AndroidCast_Inverted); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case SYSTEM: + default: + activity.setTheme(R.style.Theme_AndroidCast); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + break; + } + } + + public static void applyNightMode(Context context) { + switch (AppPreferences.getThemeMode(context)) { + case DARK: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case LIGHT: + case INVERTED: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case SYSTEM: + default: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + break; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java new file mode 100644 index 0000000..613033a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java @@ -0,0 +1,101 @@ +package com.foxx.androidcast; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Build; + +import androidx.core.app.NotificationCompat; + +/** + * Persistent status-bar icon while cast services run (when enabled in settings). + */ +public final class CastTrayNotifier { + public static final String CHANNEL_TRAY = "cast_tray"; + public static final int ID_TRAY = 3; + + private CastTrayNotifier() {} + + /** Sync tray with cast service lifecycle (call from foreground services). */ + public static void refresh(Context context) { + Context app = context.getApplicationContext(); + if (!AppPreferences.isShowTrayIcon(app)) { + hide(app); + return; + } + if (CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening()) { + show(app, false); + } else { + hide(app); + } + } + + /** User toggled the preference — show or hide immediately. */ + public static void onPreferenceChanged(Context context) { + Context app = context.getApplicationContext(); + if (AppPreferences.isShowTrayIcon(app)) { + show(app, true); + } else { + hide(app); + } + } + + private static void show(Context context, boolean idle) { + createChannel(context); + NotificationManager nm = context.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + String body = idle + ? context.getString(R.string.tray_cast_idle) + : context.getString(R.string.tray_cast_active); + PendingIntent pi = PendingIntent.getActivity( + context, 0, CastActivityTracker.resumeIntent(context), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + Notification n = new NotificationCompat.Builder(context, CHANNEL_TRAY) + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(body) + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(pi) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build(); + nm.notify(ID_TRAY, n); + } + + private static void hide(Context context) { + NotificationManager nm = context.getSystemService(NotificationManager.class); + if (nm != null) { + nm.cancel(ID_TRAY); + } + } + + private static void createChannel(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = context.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + NotificationChannel existing = nm.getNotificationChannel(CHANNEL_TRAY); + if (existing != null && existing.getImportance() < NotificationManager.IMPORTANCE_DEFAULT) { + nm.deleteNotificationChannel(CHANNEL_TRAY); + } + if (nm.getNotificationChannel(CHANNEL_TRAY) == null) { + NotificationChannel ch = new NotificationChannel( + CHANNEL_TRAY, + context.getString(R.string.tray_channel_name), + NotificationManager.IMPORTANCE_DEFAULT); + ch.setDescription(context.getString(R.string.tray_channel_description)); + ch.setShowBadge(true); + nm.createNotificationChannel(ch); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java b/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java new file mode 100644 index 0000000..ed0f554 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java @@ -0,0 +1,69 @@ +package com.foxx.androidcast; + +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.LayoutRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.GravityCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.Fragment; + +/** + * Hosts main content and a start-edge settings drawer (swipe right from left edge to open). + */ +public abstract class DrawerHostActivity extends AppCompatActivity { + private DrawerLayout drawerLayout; + + protected void applyActivityTheme() { + CastThemeHelper.applyTheme(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + applyActivityTheme(); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_drawer_host); + + drawerLayout = findViewById(R.id.drawer_layout); + android.view.ViewGroup contentRoot = findViewById(R.id.content_root); + getLayoutInflater().inflate(getContentLayoutId(), contentRoot, true); + + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.settings_drawer_container, new CastSettingsFragment()) + .commit(); + } + + drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (drawerLayout.isDrawerOpen(GravityCompat.START)) { + drawerLayout.closeDrawer(GravityCompat.START); + } else { + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + CastActivityTracker.onResume(this); + } + + protected void openSettingsDrawer() { + drawerLayout.openDrawer(GravityCompat.START); + } + + protected void closeSettingsDrawer() { + drawerLayout.closeDrawer(GravityCompat.START); + } + + protected abstract @LayoutRes int getContentLayoutId(); +} diff --git a/app/src/main/java/com/foxx/androidcast/MainActivity.java b/app/src/main/java/com/foxx/androidcast/MainActivity.java index 9d5976e..181a94d 100644 --- a/app/src/main/java/com/foxx/androidcast/MainActivity.java +++ b/app/src/main/java/com/foxx/androidcast/MainActivity.java @@ -4,27 +4,30 @@ import android.content.Intent; import android.os.Bundle; import android.widget.Button; -import androidx.appcompat.app.AppCompatActivity; - -import com.foxx.androidcast.receiver.ReceiverActivity; +import com.foxx.androidcast.receiver.ReceiverPlaybackActivity; import com.foxx.androidcast.sender.SenderActivity; import com.foxx.androidcast.stats.SessionStatsStore; -public class MainActivity extends AppCompatActivity { +public class MainActivity extends DrawerHostActivity { + @Override + protected int getContentLayoutId() { + return R.layout.activity_main; + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); SessionStatsStore.pruneOnAppStart(this); - setContentView(R.layout.activity_main); com.foxx.androidcast.network.CastTransportFactory.init(this); PermissionHelper.request(this); - Button sendBtn = findViewById(R.id.btn_send); - Button recvBtn = findViewById(R.id.btn_receive); - sendBtn.setOnClickListener(v -> + findViewById(R.id.btn_send).setOnClickListener(v -> startActivity(new Intent(this, SenderActivity.class))); - recvBtn.setOnClickListener(v -> - startActivity(new Intent(this, ReceiverActivity.class))); + findViewById(R.id.btn_receive).setOnClickListener(v -> { + Intent i = new Intent(this, ReceiverPlaybackActivity.class); + i.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true); + startActivity(i); + }); } @Override diff --git a/app/src/main/java/com/foxx/androidcast/PinAuth.java b/app/src/main/java/com/foxx/androidcast/PinAuth.java new file mode 100644 index 0000000..9277bc9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/PinAuth.java @@ -0,0 +1,38 @@ +package com.foxx.androidcast; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** PIN hashing for discovery and handshake (never send raw PIN on LAN). */ +public final class PinAuth { + private PinAuth() {} + + public static String hash(String pin) { + if (pin == null) { + pin = ""; + } + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(pin.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + return Integer.toHexString(pin.hashCode()); + } + } + + public static boolean matches(String pin, String expectedHash) { + if (expectedHash == null || expectedHash.isEmpty()) { + return true; + } + return hash(pin).equals(expectedHash); + } + + public static boolean isProtected(String pin) { + return pin != null && !pin.isEmpty(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java index f347fce..69b31b8 100644 --- a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java +++ b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java @@ -32,6 +32,29 @@ public final class PlatformCastSupport { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; } + public static boolean hasFrontCamera(android.content.Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return false; + } + try { + android.hardware.camera2.CameraManager mgr = + context.getSystemService(android.hardware.camera2.CameraManager.class); + if (mgr == null) { + return false; + } + for (String id : mgr.getCameraIdList()) { + android.hardware.camera2.CameraCharacteristics chars = mgr.getCameraCharacteristics(id); + Integer facing = chars.get(android.hardware.camera2.CameraCharacteristics.LENS_FACING); + if (facing != null + && facing == android.hardware.camera2.CameraCharacteristics.LENS_FACING_FRONT) { + return true; + } + } + } catch (Exception ignored) { + } + return false; + } + /** Applies default audio flag: on for Android 10+, off otherwise. */ public static void applyDefaultAudioSetting(CastSettings settings) { settings.setAudioEnabled(isAudioCaptureSupported()); diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 5759125..5372757 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -8,6 +8,7 @@ import android.util.Log; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.DeviceInfo; +import com.foxx.androidcast.PinAuth; import com.foxx.androidcast.network.CastCodecFlags; import com.foxx.androidcast.network.CastPacketFramer; import com.foxx.androidcast.network.CastProtoHeader; @@ -53,13 +54,20 @@ public class DiscoveryManager { public final int audioCodecs; public final int placeholderId; public final long protoVersion; + /** SHA-256 hex of receiver PIN, empty if anonymous. */ + public final String pinAuth; public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) { - this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L); + this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, ""); } public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs, int videoCodecs, int audioCodecs, int placeholderId, long protoVersion) { + this(name, host, port, transport, lastSeenMs, videoCodecs, audioCodecs, placeholderId, protoVersion, ""); + } + + public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs, + int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth) { this.name = name; this.host = host; this.port = port; @@ -69,6 +77,11 @@ public class DiscoveryManager { this.audioCodecs = audioCodecs; this.placeholderId = placeholderId; this.protoVersion = protoVersion; + this.pinAuth = pinAuth != null ? pinAuth : ""; + } + + public boolean pinMatches(String pin) { + return PinAuth.matches(pin, pinAuth); } } @@ -133,14 +146,19 @@ public class DiscoveryManager { public void startAnnouncing(Context context, String deviceName, int port, String transport, boolean listening) { + startAnnouncing(context, deviceName, port, transport, listening, ""); + } + + public void startAnnouncing(Context context, String deviceName, int port, String transport, + boolean listening, String pin) { if (!running.compareAndSet(false, true)) { return; } - executor.execute(() -> announceLoop(context, deviceName, port, transport, listening)); + executor.execute(() -> announceLoop(context, deviceName, port, transport, listening, pin)); } public void startAnnouncing(String deviceName, int port, String transport, boolean listening) { - startAnnouncing(appContext, deviceName, port, transport, listening); + startAnnouncing(appContext, deviceName, port, transport, listening, ""); } private void browseLoop() { @@ -203,7 +221,7 @@ public class DiscoveryManager { } private void announceLoop(Context context, String deviceName, int port, String transport, - boolean listening) { + boolean listening, String pin) { acquireMulticastLock(); int placeholderId = DeviceInfo.getPlaceholderId(context); int videoMask = CastCodecFlags.localDecoderVideoMask(); @@ -213,7 +231,7 @@ public class DiscoveryManager { sock.setBroadcast(true); sock.setSoTimeout(400); while (running.get()) { - sendAnnounce(sock, context, deviceName, port, transport, listening, + sendAnnounce(sock, context, deviceName, port, transport, listening, pin, placeholderId, videoMask, audioMask); long listenUntil = System.currentTimeMillis() + 2_000; while (running.get() && System.currentTimeMillis() < listenUntil) { @@ -222,9 +240,9 @@ public class DiscoveryManager { DatagramPacket packet = new DatagramPacket(buf, buf.length); sock.receive(packet); if (isDiscoverProbe(packet)) { - sendAnnounce(sock, context, deviceName, port, transport, listening, + sendAnnounce(sock, context, deviceName, port, transport, listening, pin, placeholderId, videoMask, audioMask); - sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, + sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, pin, placeholderId, videoMask, audioMask, packet.getAddress()); } } catch (java.net.SocketTimeoutException ignored) { @@ -269,9 +287,9 @@ public class DiscoveryManager { } private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port, - String transport, boolean listening, int placeholderId, int videoMask, int audioMask) + String transport, boolean listening, String pin, int placeholderId, int videoMask, int audioMask) throws Exception { - byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, + byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin, placeholderId, videoMask, audioMask); InetAddress broadcast = InetAddress.getByName("255.255.255.255"); DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT); @@ -279,10 +297,10 @@ public class DiscoveryManager { } private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port, - String transport, boolean listening, int placeholderId, int videoMask, int audioMask, + String transport, boolean listening, String pin, int placeholderId, int videoMask, int audioMask, InetAddress target) { try { - byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, + byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin, placeholderId, videoMask, audioMask); DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT); sock.send(packet); @@ -292,7 +310,7 @@ public class DiscoveryManager { } private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport, - boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception { + boolean listening, String pin, int placeholderId, int videoMask, int audioMask) throws Exception { JSONObject json = new JSONObject(); json.put("magic", CastConfig.DISCOVERY_MAGIC); json.put("name", deviceName); @@ -300,6 +318,9 @@ public class DiscoveryManager { json.put("transport", transport); json.put("listening", listening); json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); + if (PinAuth.isProtected(pin)) { + json.put("pin_auth", PinAuth.hash(pin)); + } byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); try { @@ -364,9 +385,10 @@ public class DiscoveryManager { } return; } + String pinAuth = json.optString("pin_auth", ""); String key = host + ":" + port; DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, - System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion); + System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth); devices.put(key, device); notifyListener(); } catch (Exception ignored) { diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index d9a6b52..e7bc4e5 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -80,6 +80,8 @@ public final class CastProtocol { dos.writeInt(settings.getBitrateMode().ordinal()); dos.writeInt(settings.getNetworkAdoption().ordinal()); dos.writeInt(settings.getCaptureMode().ordinal()); + dos.writeInt(settings.getStreamProtection().ordinal()); + dos.writeInt(settings.getReceiverDisplayResolution().ordinal()); dos.flush(); return bos.toByteArray(); } @@ -115,6 +117,15 @@ public final class CastProtocol { settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(), CastSettings.CaptureMode.FULL_SCREEN)); } + if (dis.available() > 0) { + settings.setStreamProtection(readEnum(CastSettings.StreamProtection.values(), dis.readInt(), + CastSettings.StreamProtection.AUTO)); + } + if (dis.available() > 0) { + settings.setReceiverDisplayResolution( + readEnum(CastSettings.ReceiverDisplayResolution.values(), dis.readInt(), + CastSettings.ReceiverDisplayResolution.AUTO_FIT)); + } } else { dis.reset(); settings.setTransport(dis.readUTF()); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java index 965567b..3b27eb7 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java @@ -8,31 +8,25 @@ import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; -import android.text.TextUtils; import android.view.WindowManager; import android.widget.Button; -import android.widget.CheckBox; -import android.widget.EditText; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; -import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.CastActivityTracker; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.R; -import com.foxx.androidcast.SettingsUi; -/** Receiver setup — listening runs in background; fullscreen playback opens when video stream starts. */ +/** Receiver setup — options live in {@link com.foxx.androidcast.CastSettingsActivity}. */ public class ReceiverActivity extends AppCompatActivity { private final CastSettings castSettings = new CastSettings(); private TextView statusText; - private EditText pinInput; - private CheckBox allowAudioCheck; - private ICastReceiverService receiverService; private boolean bound; @@ -93,19 +87,20 @@ public class ReceiverActivity extends AppCompatActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_receiver); PermissionHelper.request(this); - SettingsUi.bindReceiver(this, castSettings); + castSettings.copyFrom(AppPreferences.loadReceiverDefaults(this)); statusText = findViewById(R.id.text_status); - pinInput = findViewById(R.id.edit_pin); - pinInput.setText(CastConfig.DEFAULT_PIN); - allowAudioCheck = findViewById(R.id.check_allow_audio); - allowAudioCheck.setChecked(ReceiverPrefs.isAllowAudio(this)); - allowAudioCheck.setOnCheckedChangeListener((btn, checked) -> - ReceiverPrefs.setAllowAudio(ReceiverActivity.this, checked)); - findViewById(R.id.btn_start_receiver).setOnClickListener(v -> startReceiverService()); } + @Override + protected void onResume() { + super.onResume(); + CastActivityTracker.onResume(this); + PermissionHelper.remindIfMissing(this, AppPreferences.isPlayIncomingAudio(this)); + updateKeepScreenOn(); + } + @Override protected void onStart() { super.onStart(); @@ -126,13 +121,6 @@ public class ReceiverActivity extends AppCompatActivity { super.onStop(); } - @Override - protected void onResume() { - super.onResume(); - PermissionHelper.remindIfMissing(this, allowAudioCheck.isChecked()); - updateKeepScreenOn(); - } - @Override protected void onPause() { getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -148,11 +136,8 @@ public class ReceiverActivity extends AppCompatActivity { } private void startReceiverService() { - String pin = pinInput.getText().toString().trim(); - if (TextUtils.isEmpty(pin)) { - pin = CastConfig.DEFAULT_PIN; - } - boolean allowAudio = allowAudioCheck.isChecked(); + String pin = AppPreferences.getPin(this); + boolean allowAudio = AppPreferences.isPlayIncomingAudio(this); ReceiverPrefs.setAllowAudio(this, allowAudio); Intent intent = new Intent(this, ReceiverCastService.class); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 4c5c3fa..d4fd588 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -14,8 +14,11 @@ import android.os.RemoteException; import android.util.Log; import android.view.Surface; +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.CastActiveState; import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.IntentExtras; @@ -166,19 +169,23 @@ public class ReceiverCastService extends Service { } if (intent != null && ACTION_START.equals(intent.getAction())) { String pin = intent.getStringExtra(EXTRA_PIN); - if (pin == null || pin.isEmpty()) { - pin = com.foxx.androidcast.CastConfig.DEFAULT_PIN; + if (pin == null) { + pin = AppPreferences.getPin(this); } CastSettings fromIntent = IntentExtras.getCastSettings(intent); if (fromIntent != null) { settings = fromIntent; + } else { + settings = AppPreferences.loadReceiverDefaults(this); } allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO, - ReceiverPrefs.isAllowAudio(this)); + AppPreferences.isPlayIncomingAudio(this)); activePin = pin; restartListening(pin); } else if (intent == null && phase == Phase.IDLE) { - allowIncomingAudio = ReceiverPrefs.isAllowAudio(this); + allowIncomingAudio = AppPreferences.isPlayIncomingAudio(this); + settings = AppPreferences.loadReceiverDefaults(this); + activePin = AppPreferences.getPin(this); restartListening(activePin); } return START_STICKY; @@ -203,6 +210,8 @@ public class ReceiverCastService extends Service { acquireWakeLock(); startForeground(CastNotifications.ID_RECEIVER, CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class)); + CastActiveState.setReceiverListening(true); + CastTrayNotifier.refresh(this); if (videoDecoder != null) { videoDecoder.release(); @@ -592,8 +601,8 @@ public class ReceiverCastService extends Service { discovery = new DiscoveryManager(this); com.foxx.androidcast.network.CastTransportFactory.init(this); int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport()); - discovery.startAnnouncing(android.os.Build.MODEL, announcePort, - settings.getTransport(), true); + discovery.startAnnouncing(this, AppPreferences.getUsername(this), announcePort, + settings.getTransport(), true, activePin); Log.i(TAG, "Discovery announcing on port " + announcePort); } @@ -878,6 +887,8 @@ public class ReceiverCastService extends Service { private void releaseAll() { finishSessionStatsAsync(); + CastActiveState.setReceiverListening(false); + CastTrayNotifier.refresh(this); phase = Phase.IDLE; resetStreamState(); playbackLaunched = false; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index ab92e8b..0428efa 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -3,6 +3,7 @@ package com.foxx.androidcast.receiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.content.ServiceConnection; import android.os.Bundle; import android.os.Handler; @@ -21,9 +22,11 @@ import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; -import androidx.appcompat.app.AppCompatActivity; - +import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.DrawerHostActivity; +import com.foxx.androidcast.CastThemeHelper; import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.R; @@ -37,12 +40,13 @@ import androidx.core.text.HtmlCompat; * Fullscreen display: dancing-robot overlay while listening / waiting; * video surface shown only after the first decoded frame. */ -public class ReceiverPlaybackActivity extends AppCompatActivity { +public class ReceiverPlaybackActivity extends DrawerHostActivity { public static final String EXTRA_WIDTH = "video_width"; public static final String EXTRA_HEIGHT = "video_height"; public static final String EXTRA_FINISH = "finish_playback"; public static final String EXTRA_SHOW_AWAITING = "show_awaiting"; public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message"; + public static final String EXTRA_AUTO_START_LISTENING = "auto_start_listening"; private AspectRatioFrameLayout aspectContainer; private TextureView textureView; @@ -132,13 +136,23 @@ public class ReceiverPlaybackActivity extends AppCompatActivity { } }; + @Override + protected void applyActivityTheme() { + CastThemeHelper.applyTheme(this); + setTheme(R.style.Theme_AndroidCast_Fullscreen); + } + + @Override + protected int getContentLayoutId() { + return R.layout.activity_playback; + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (handleCommandIntent(getIntent())) { return; } - setContentView(R.layout.activity_playback); applyFullscreen(); readSizeFromIntent(getIntent()); @@ -154,12 +168,32 @@ public class ReceiverPlaybackActivity extends AppCompatActivity { textureView.setSurfaceTextureListener(surfaceListener); applyAspectRatio(); - if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) { + if (getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false)) { + startListeningService(); + showAwaitingOverlay(R.string.playback_listening); + } else if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) { int msg = getIntent().getIntExtra(EXTRA_AWAITING_MESSAGE, R.string.playback_listening); showAwaitingOverlay(msg); } } + private void startListeningService() { + String pin = AppPreferences.getPin(this); + boolean allowAudio = AppPreferences.isPlayIncomingAudio(this); + ReceiverPrefs.setAllowAudio(this, allowAudio); + CastSettings settings = AppPreferences.loadReceiverDefaults(this); + Intent intent = new Intent(this, ReceiverCastService.class); + intent.setAction(ReceiverCastService.ACTION_START); + intent.putExtra(ReceiverCastService.EXTRA_PIN, pin); + intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio); + intent.putExtra(CastSettings.EXTRA, settings); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent); + } else { + startService(intent); + } + } + @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java index 6217d97..21f09bd 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java @@ -224,6 +224,8 @@ public final class MultiCastCoordinator { s.setBitrateMode(src.getBitrateMode()); s.setNetworkAdoption(src.getNetworkAdoption()); s.setCaptureMode(src.getCaptureMode()); + s.setStreamProtection(src.getStreamProtection()); + s.setReceiverDisplayResolution(src.getReceiverDisplayResolution()); return s; } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 9dd9b56..633e3de 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -21,7 +21,9 @@ import android.util.Log; import android.view.Surface; import android.view.WindowManager; +import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.CastActiveState; +import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastResolution; @@ -63,6 +65,7 @@ public class ScreenCastService extends Service implements public static final String EXTRA_PORT = "port"; public static final String EXTRA_PIN = "pin"; public static final String EXTRA_DEVICE_NAME = "device_name"; + public static final String EXTRA_SENDER_NAME = "sender_name"; public static final String EXTRA_TARGETS = "cast_targets"; public static final String EXTRA_CALIBRATION = "calibration_mode"; public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND"; @@ -181,22 +184,28 @@ public class ScreenCastService extends Service implements String host = intent.getStringExtra(EXTRA_HOST); int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT); String pin = intent.getStringExtra(EXTRA_PIN); + if (pin == null) { + pin = ""; + } String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME); + String senderName = intent.getStringExtra(EXTRA_SENDER_NAME); CastSettings settings = IntentExtras.getCastSettings(intent); boolean calibration = settings != null && settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; - boolean camera = settings != null - && settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA; + boolean camera = settings != null && settings.isCameraCapture(); if (!calibration && !camera && data == null) { failAndStop(getString(R.string.error_projection_data)); return; } - if (host == null || pin == null || settings == null) { + if (host == null || settings == null) { failAndStop(getString(R.string.error_missing_params)); return; } - cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA; + if (senderName == null || senderName.isEmpty()) { + senderName = AppPreferences.getUsername(this); + } + cameraMode = settings.isCameraCapture(); calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) { failAndStop(getString(R.string.capture_camera_not_implemented)); @@ -222,8 +231,7 @@ public class ScreenCastService extends Service implements } })); updateStatus(getString(R.string.connecting_to, host)); - multiCoordinator.connectAndHandshake(targets, settings, - deviceName != null ? deviceName : "Sender", pin); + multiCoordinator.connectAndHandshake(targets, settings, senderName, pin); List peers = multiCoordinator.getPeers(); if (peers.isEmpty()) { throw new IOException("No receivers connected"); @@ -247,6 +255,7 @@ public class ScreenCastService extends Service implements } casting.set(true); CastActiveState.setSenderCasting(true); + CastTrayNotifier.refresh(this); activeSettings = settings; sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport()); sessionStatsRecorder.mergeSettings(settings); @@ -382,7 +391,8 @@ public class ScreenCastService extends Service implements } cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this); try { - cameraCapture.start(surface, captureWidth, captureHeight); + cameraCapture.start(surface, captureWidth, captureHeight, activeSettings != null + && activeSettings.isFrontCamera()); } catch (android.hardware.camera2.CameraAccessException e) { throw new IOException("Camera open failed: " + e.getMessage(), e); } @@ -790,6 +800,7 @@ public class ScreenCastService extends Service implements stopping.set(true); casting.set(false); CastActiveState.setSenderCasting(false); + CastTrayNotifier.refresh(this); networkFeedback = null; calibrationMode = false; cameraMode = false; diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index 140e49c..d76ce02 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -2,16 +2,12 @@ package com.foxx.androidcast.sender; import android.app.Activity; import android.content.Intent; -import android.graphics.drawable.Animatable; -import android.graphics.drawable.Drawable; import android.media.projection.MediaProjectionConfig; import android.media.projection.MediaProjectionManager; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.provider.Settings; -import android.text.TextUtils; import android.view.View; import android.view.WindowManager; import android.widget.Button; @@ -22,33 +18,37 @@ import android.widget.TextView; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; -import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AlertDialog; +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.DrawerHostActivity; import com.foxx.androidcast.CastActiveState; +import com.foxx.androidcast.CastActivityTracker; import com.foxx.androidcast.CastConfig; -import com.foxx.androidcast.sender.CastReceiverTarget; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.PlatformCastSupport; import com.foxx.androidcast.R; -import com.foxx.androidcast.SettingsUi; import com.foxx.androidcast.discovery.DiscoveryManager; +import com.google.android.material.switchmaterial.SwitchMaterial; -public class SenderActivity extends AppCompatActivity { +public class SenderActivity extends DrawerHostActivity { private static final int REQUEST_MEDIA_PROJECTION = 2001; - private static final long BACK_STOP_INTERVAL_MS = 2_000; - private static final int DEV_TAP_UNLOCK = 7; + private static final int REQUEST_PREVIEW_PROJECTION = 2002; private final CastSettings castSettings = new CastSettings(); private final Handler handler = new Handler(Looper.getMainLooper()); private DiscoveryManager discovery; private DeviceListAdapter deviceAdapter; + private SenderCapturePreview capturePreview; + private SenderScreenPreview screenPreview; private TextView statusText; - private TextView titleText; private ProgressBar discoveryProgress; - private Button refreshButton; - private View advancedPanel; + private EditText pinInput; + private SwitchMaterial muteSwitch; + private Button startButton; + private boolean pinLocked; private enum DiscoveryPhase { IDLE, SCANNING, PAUSED @@ -57,62 +57,55 @@ public class SenderActivity extends AppCompatActivity { private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE; private int discoverySecondsLeft; private final Runnable autoScanRunnable = () -> beginScanPass(false); - private final Runnable discoveryCountdownTick = new Runnable() { - @Override - public void run() { - if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) { - if (discoverySecondsLeft > 0) { - discoverySecondsLeft--; - } - updateRefreshButtonLabel(); - handler.postDelayed(this, 1000); - } - } - }; - private EditText pinInput; - private EditText manualHostInput; - private EditText manualPortInput; - private int titleTapCount; - private long lastTitleTapMs; - private boolean useManualConnect; - private long lastBackPressMs; + private String pendingPin; + private String pendingHost; + private int pendingPort; + private String pendingDeviceName; + private java.util.ArrayList pendingTargets; + private boolean pendingCameraLaunch; + private boolean previewConsentDeclined; + private boolean previewConsentInFlight; + + @Override + protected int getContentLayoutId() { + return R.layout.activity_sender; + } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_sender); PermissionHelper.request(this); - SettingsUi.bindSender(this, castSettings); - titleText = findViewById(R.id.text_sender_title); - pinInput = findViewById(R.id.edit_pin); - pinInput.setText(CastConfig.DEFAULT_PIN); + castSettings.copyFrom(AppPreferences.loadSenderDefaults(this)); + screenPreview = new SenderScreenPreview(this); + statusText = findViewById(R.id.text_status); + pinInput = findViewById(R.id.edit_pin); + muteSwitch = findViewById(R.id.switch_mute); + startButton = findViewById(R.id.btn_start_cast); discoveryProgress = findViewById(R.id.progress_discovery); - refreshButton = findViewById(R.id.btn_refresh); - advancedPanel = findViewById(R.id.panel_advanced); - manualHostInput = findViewById(R.id.edit_manual_host); - manualPortInput = findViewById(R.id.edit_manual_port); - titleText.setOnClickListener(v -> { - long now = System.currentTimeMillis(); - if (now - lastTitleTapMs > 2000) { - titleTapCount = 0; + pinInput.setText(AppPreferences.getPin(this)); + muteSwitch.setChecked(!castSettings.isAudioRequested()); + muteSwitch.setOnCheckedChangeListener((btn, muted) -> + castSettings.setAudioEnabled(!muted)); + + capturePreview = new SenderCapturePreview( + findViewById(R.id.texture_preview), + findViewById(R.id.text_preview_label), + screenPreview); + View.OnClickListener requestPreviewConsent = v -> { + if (screenPreview.needsProjection(castSettings.getCaptureMode())) { + previewConsentDeclined = false; + maybeRequestPreviewConsent(); } - lastTitleTapMs = now; - titleTapCount++; - if (titleTapCount >= DEV_TAP_UNLOCK) { - titleTapCount = 0; - advancedPanel.setVisibility( - advancedPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); - Toast.makeText(this, - advancedPanel.getVisibility() == View.VISIBLE - ? R.string.advanced_unlocked : R.string.advanced_hidden, - Toast.LENGTH_SHORT).show(); - } - }); + }; + findViewById(R.id.text_preview_label).setOnClickListener(requestPreviewConsent); + findViewById(R.id.texture_preview).setOnClickListener(requestPreviewConsent); + refreshPreview(); + maybeRequestPreviewConsent(); ListView listView = findViewById(R.id.list_devices); listView.setChoiceMode(CastConfig.MULTI_RECEIVER_ENABLED @@ -121,7 +114,6 @@ public class SenderActivity extends AppCompatActivity { listView.setAdapter(deviceAdapter); listView.setOnItemClickListener((parent, view, position, id) -> { deviceAdapter.setSelectedPosition(position); - useManualConnect = false; java.util.List selectedList = deviceAdapter.getSelectedDevices(); if (selectedList.isEmpty()) { @@ -158,18 +150,14 @@ public class SenderActivity extends AppCompatActivity { public void onScanStarted() { discoveryPhase = DiscoveryPhase.SCANNING; discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000); - updateRefreshButtonLabel(); - handler.removeCallbacks(discoveryCountdownTick); - handler.post(discoveryCountdownTick); - showDiscoveryProgress(); + discoveryProgress.setVisibility(View.VISIBLE); } @Override public void onScanEnded() { - hideDiscoveryProgress(); discoveryPhase = DiscoveryPhase.PAUSED; discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000); - updateRefreshButtonLabel(); + discoveryProgress.setVisibility(View.GONE); handler.removeCallbacks(autoScanRunnable); handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS); } @@ -179,35 +167,73 @@ public class SenderActivity extends AppCompatActivity { @Override public void handleOnBackPressed() { if (CastActiveState.isSenderCasting()) { - long now = System.currentTimeMillis(); - if (now - lastBackPressMs < BACK_STOP_INTERVAL_MS) { - stopCastingAndExit(); - } else { - lastBackPressMs = now; - Toast.makeText(SenderActivity.this, - R.string.press_back_again_to_stop_cast, Toast.LENGTH_SHORT).show(); - } + Toast.makeText(SenderActivity.this, R.string.cast_in_progress_pin_locked, + Toast.LENGTH_SHORT).show(); } else { finish(); } } }); - refreshButton.setOnClickListener(v -> onRefreshClicked()); - findViewById(R.id.btn_start_cast).setOnClickListener(v -> + startButton.setOnClickListener(v -> PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); - findViewById(R.id.btn_manual_cast).setOnClickListener(v -> { - useManualConnect = true; - PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow); - }); } @Override protected void onResume() { super.onResume(); + castSettings.copyFrom(AppPreferences.loadSenderDefaults(this)); + muteSwitch.setChecked(!castSettings.isAudioRequested()); + pinLocked = CastActiveState.isSenderCasting(); + pinInput.setEnabled(!pinLocked); + startButton.setEnabled(!pinLocked); + muteSwitch.setEnabled(!pinLocked); + if (pinLocked) { + Toast.makeText(this, R.string.cast_in_progress_pin_locked, Toast.LENGTH_SHORT).show(); + } + refreshPreview(); PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled()); } + private void refreshPreview() { + if (capturePreview == null) { + return; + } + capturePreview.updateForMode(castSettings.getCaptureMode()); + } + + private void maybeRequestPreviewConsent() { + if (previewConsentDeclined || previewConsentInFlight) { + return; + } + CastSettings.CaptureMode mode = castSettings.getCaptureMode(); + if (!screenPreview.needsProjection(mode) || screenPreview.hasConsent()) { + return; + } + previewConsentInFlight = true; + MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); + startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_PREVIEW_PROJECTION); + } + + @Override + protected void onPause() { + if (capturePreview != null) { + capturePreview.pause(); + } + super.onPause(); + } + + @Override + protected void onDestroy() { + if (capturePreview != null) { + capturePreview.stop(); + } + if (screenPreview != null) { + screenPreview.releaseConsent(); + } + super.onDestroy(); + } + @Override protected void onStart() { super.onStart(); @@ -216,25 +242,14 @@ public class SenderActivity extends AppCompatActivity { @Override protected void onStop() { - handler.removeCallbacks(discoveryCountdownTick); handler.removeCallbacks(autoScanRunnable); if (discovery != null) { discovery.stop(); } discoveryPhase = DiscoveryPhase.IDLE; - updateRefreshButtonLabel(); - hideDiscoveryProgress(); super.onStop(); } - private void onRefreshClicked() { - if (discoveryPhase == DiscoveryPhase.SCANNING) { - return; - } - handler.removeCallbacks(autoScanRunnable); - beginScanPass(true); - } - private void beginScanPass(boolean userInitiated) { if (discovery != null && discovery.isBrowsing()) { return; @@ -254,42 +269,6 @@ public class SenderActivity extends AppCompatActivity { discovery.startBrowsing(); } - private void updateRefreshButtonLabel() { - if (refreshButton == null) { - return; - } - if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) { - refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft))); - } else { - refreshButton.setText(R.string.refresh); - } - } - - private void showDiscoveryProgress() { - discoveryProgress.setVisibility(View.VISIBLE); - discoveryProgress.bringToFront(); - Drawable indeterminate = discoveryProgress.getIndeterminateDrawable(); - if (indeterminate instanceof Animatable) { - ((Animatable) indeterminate).start(); - } - } - - private void hideDiscoveryProgress() { - discoveryProgress.setVisibility(View.GONE); - Drawable indeterminate = discoveryProgress.getIndeterminateDrawable(); - if (indeterminate instanceof Animatable) { - ((Animatable) indeterminate).stop(); - } - } - - private void stopCastingAndExit() { - Intent stop = new Intent(this, ScreenCastService.class); - stop.setAction(ScreenCastService.ACTION_STOP); - startService(stop); - CastActiveState.setSenderCasting(false); - finish(); - } - private void startCastFlow() { PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled()); if (!PermissionHelper.hasAll(this)) { @@ -299,62 +278,92 @@ public class SenderActivity extends AppCompatActivity { Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show(); return; } + String pin = pinInput.getText().toString().trim(); - if (TextUtils.isEmpty(pin)) { - Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show(); + if (!AppPreferences.getPin(this).equals(pin)) { + AppPreferences.setPin(this, pin); + } + + java.util.List selectedList = + deviceAdapter.getSelectedDevices(); + if (selectedList.isEmpty()) { + Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show(); + return; + } + for (DiscoveryManager.DiscoveredDevice d : selectedList) { + if (!d.pinMatches(pin)) { + showPinMismatchDialog(selectedList); + return; + } + } + + if (CastConfig.MULTI_RECEIVER_ENABLED && selectedList.size() > CastConfig.MAX_RECEIVERS) { + Toast.makeText(this, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS), + Toast.LENGTH_LONG).show(); return; } - if (useManualConnect) { - String host = manualHostInput.getText().toString().trim(); - String portStr = manualPortInput.getText().toString().trim(); - if (TextUtils.isEmpty(host) || TextUtils.isEmpty(portStr)) { - Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show(); + pendingTargets = new java.util.ArrayList<>(); + for (DiscoveryManager.DiscoveredDevice d : selectedList) { + if (!CastConfig.MULTI_RECEIVER_ENABLED + && !castSettings.getTransport().equals(d.transport)) { + Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show(); return; } - pendingHost = host; - try { - pendingPort = Integer.parseInt(portStr); - } catch (NumberFormatException e) { - Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show(); - return; - } - pendingDeviceName = host; - } else { - java.util.List selectedList = - deviceAdapter.getSelectedDevices(); - if (selectedList.isEmpty()) { - Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show(); - return; - } - if (CastConfig.MULTI_RECEIVER_ENABLED && selectedList.size() > CastConfig.MAX_RECEIVERS) { - Toast.makeText(this, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS), - Toast.LENGTH_LONG).show(); - return; - } - pendingTargets = new java.util.ArrayList<>(); - for (DiscoveryManager.DiscoveredDevice d : selectedList) { - if (!CastConfig.MULTI_RECEIVER_ENABLED - && !castSettings.getTransport().equals(d.transport)) { - Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show(); - return; - } - pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport, - d.videoCodecs, d.audioCodecs, d.placeholderId)); - } - DiscoveryManager.DiscoveredDevice first = selectedList.get(0); - pendingHost = first.host; - pendingPort = first.port; - pendingDeviceName = selectedList.size() > 1 - ? first.name + " +" + (selectedList.size() - 1) : first.name; + pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport, + d.videoCodecs, d.audioCodecs, d.placeholderId)); } - + DiscoveryManager.DiscoveredDevice first = selectedList.get(0); + pendingHost = first.host; + pendingPort = first.port; + pendingDeviceName = selectedList.size() > 1 + ? first.name + " +" + (selectedList.size() - 1) : first.name; pendingPin = pin; + + proceedCaptureConsent(); + } + + private void showPinMismatchDialog(java.util.List selectedList) { + final EditText input = new EditText(this); + input.setHint(R.string.pin_session_hint); + input.setInputType(android.text.InputType.TYPE_CLASS_NUMBER + | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD); + new AlertDialog.Builder(this) + .setTitle(R.string.pin_mismatch_title) + .setMessage(R.string.pin_mismatch_message) + .setView(input) + .setPositiveButton(android.R.string.ok, (d, w) -> { + String sessionPin = input.getText().toString().trim(); + pinInput.setText(sessionPin); + for (DiscoveryManager.DiscoveredDevice dev : selectedList) { + if (!dev.pinMatches(sessionPin)) { + Toast.makeText(this, R.string.pin_mismatch_title, Toast.LENGTH_LONG).show(); + return; + } + } + pendingPin = sessionPin; + pendingTargets = new java.util.ArrayList<>(); + for (DiscoveryManager.DiscoveredDevice dev : selectedList) { + pendingTargets.add(new CastReceiverTarget(dev.name, dev.host, dev.port, + dev.transport, dev.videoCodecs, dev.audioCodecs, dev.placeholderId)); + } + DiscoveryManager.DiscoveredDevice first = selectedList.get(0); + pendingHost = first.host; + pendingPort = first.port; + pendingDeviceName = selectedList.size() > 1 + ? first.name + " +" + (selectedList.size() - 1) : first.name; + proceedCaptureConsent(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void proceedCaptureConsent() { if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { launchCastService(null, Activity.RESULT_OK); return; } - if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) { + if (castSettings.isCameraCapture()) { if (androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) { androidx.core.app.ActivityCompat.requestPermissions(this, @@ -365,6 +374,11 @@ public class SenderActivity extends AppCompatActivity { launchCastService(null, Activity.RESULT_OK); return; } + if (screenPreview.hasConsent()) { + screenPreview.stop(); + launchCastService(screenPreview.getProjectionData(), screenPreview.getProjectionResultCode()); + return; + } MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION); } @@ -378,14 +392,6 @@ public class SenderActivity extends AppCompatActivity { return mgr.createScreenCaptureIntent(); } - private boolean pendingCameraLaunch; - - private String pendingPin; - private String pendingHost; - private int pendingPort; - private String pendingDeviceName; - private java.util.ArrayList pendingTargets; - @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); @@ -402,6 +408,17 @@ public class SenderActivity extends AppCompatActivity { @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_PREVIEW_PROJECTION) { + previewConsentInFlight = false; + if (resultCode == Activity.RESULT_OK && data != null) { + screenPreview.setProjectionResult(resultCode, data); + previewConsentDeclined = false; + } else { + previewConsentDeclined = true; + } + refreshPreview(); + return; + } if (requestCode != REQUEST_MEDIA_PROJECTION) { return; } @@ -409,14 +426,19 @@ public class SenderActivity extends AppCompatActivity { Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); return; } + screenPreview.setProjectionResult(resultCode, data); launchCastService(data, resultCode); } private void launchCastService(Intent projectionData, int resultCode) { - String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME); - if (deviceName == null) { - deviceName = Build.MODEL; + if (screenPreview != null) { + screenPreview.stop(); } + pinLocked = true; + pinInput.setEnabled(false); + startButton.setEnabled(false); + muteSwitch.setEnabled(false); + Intent serviceIntent = new Intent(this, ScreenCastService.class); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); if (projectionData != null) { @@ -424,8 +446,9 @@ public class SenderActivity extends AppCompatActivity { } serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost); serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort); - serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); - serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName); + serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin != null ? pendingPin : ""); + serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName); + serviceIntent.putExtra(ScreenCastService.EXTRA_SENDER_NAME, AppPreferences.getUsername(this)); serviceIntent.putExtra(CastSettings.EXTRA, castSettings); if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true); diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java new file mode 100644 index 0000000..976fd9d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java @@ -0,0 +1,274 @@ +package com.foxx.androidcast.sender; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Canvas; +import android.graphics.SurfaceTexture; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureRequest; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.util.Size; +import android.view.TextureView; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.R; +import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator; + +import java.util.Collections; + +/** Live preview of the configured capture source on the sender screen (not the cast pipeline). */ +public final class SenderCapturePreview { + private static final String TAG = "SenderCapturePreview"; + + private final TextureView textureView; + private final TextView labelView; + private final SenderScreenPreview screenPreview; + private HandlerThread thread; + private Handler handler; + private CameraDevice camera; + private android.hardware.camera2.CameraCaptureSession session; + private boolean useFront; + + public SenderCapturePreview(TextureView texture, TextView label, SenderScreenPreview screen) { + this.textureView = texture; + this.labelView = label; + this.screenPreview = screen; + } + + public SenderScreenPreview getScreenPreview() { + return screenPreview; + } + + public void updateForMode(CastSettings.CaptureMode mode) { + stopCamera(); + if (mode == CastSettings.CaptureMode.CAMERA) { + useFront = false; + startCameraPreview(); + } else if (mode == CastSettings.CaptureMode.CAMERA_FRONT) { + useFront = true; + startCameraPreview(); + } else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) { + showCalibrationPreview(); + } else if (screenPreview != null && screenPreview.needsProjection(mode)) { + if (screenPreview.hasProjection()) { + labelView.setVisibility(android.view.View.GONE); + textureView.setVisibility(android.view.View.VISIBLE); + screenPreview.attach(textureView, mode); + } else { + showLabel(R.string.preview_tap_for_screen); + } + } else { + showLabel(R.string.preview_placeholder); + } + } + + private void showLabel(int textRes) { + if (screenPreview != null) { + screenPreview.stop(); + } + textureView.setVisibility(android.view.View.GONE); + labelView.setVisibility(android.view.View.VISIBLE); + labelView.setText(labelView.getContext().getString(textRes)); + } + + private void showCalibrationPreview() { + if (screenPreview != null) { + screenPreview.stop(); + } + labelView.setVisibility(android.view.View.GONE); + textureView.setVisibility(android.view.View.VISIBLE); + textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + drawCalibrationFrame(); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { + drawCalibrationFrame(); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) {} + }); + if (textureView.isAvailable()) { + drawCalibrationFrame(); + } + } + + private void drawCalibrationFrame() { + int w = Math.max(320, textureView.getWidth()); + int h = Math.max(240, textureView.getHeight()); + Canvas canvas = textureView.lockCanvas(); + if (canvas == null) { + return; + } + try { + TvCalibrationGenerator.drawChart(canvas, w, h, textureView.getContext()); + } finally { + textureView.unlockCanvasAndPost(canvas); + } + } + + private void startCameraPreview() { + if (screenPreview != null) { + screenPreview.stop(); + } + Context ctx = textureView.getContext(); + if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + showLabel(R.string.capture_camera_not_implemented); + return; + } + labelView.setVisibility(android.view.View.GONE); + textureView.setVisibility(android.view.View.VISIBLE); + if (textureView.isAvailable()) { + openCamera(textureView.getSurfaceTexture()); + } else { + textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + openCamera(surface); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {} + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + stop(); + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) {} + }); + } + } + + private void openCamera(SurfaceTexture surface) { + Context ctx = textureView.getContext().getApplicationContext(); + thread = new HandlerThread("SenderPreviewCam"); + thread.start(); + handler = new Handler(thread.getLooper()); + try { + CameraManager mgr = ctx.getSystemService(CameraManager.class); + String id = chooseCamera(mgr, useFront); + if (id == null) { + showLabel(R.string.capture_camera_not_implemented); + return; + } + surface.setDefaultBufferSize(640, 480); + mgr.openCamera(id, new CameraDevice.StateCallback() { + @Override + public void onOpened(CameraDevice device) { + camera = device; + try { + android.view.Surface previewSurface = new android.view.Surface(surface); + CaptureRequest.Builder b = + device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); + b.addTarget(previewSurface); + device.createCaptureSession(Collections.singletonList(previewSurface), + new android.hardware.camera2.CameraCaptureSession.StateCallback() { + @Override + public void onConfigured( + android.hardware.camera2.CameraCaptureSession s) { + session = s; + try { + s.setRepeatingRequest(b.build(), null, handler); + } catch (CameraAccessException e) { + Log.w(TAG, "Preview request failed", e); + } + } + + @Override + public void onConfigureFailed( + android.hardware.camera2.CameraCaptureSession s) { + Log.w(TAG, "Preview session failed"); + } + }, handler); + } catch (CameraAccessException e) { + Log.w(TAG, "Preview session", e); + } + } + + @Override + public void onDisconnected(CameraDevice device) { + device.close(); + } + + @Override + public void onError(CameraDevice device, int error) { + device.close(); + } + }, handler); + } catch (CameraAccessException | SecurityException e) { + Log.w(TAG, "Open camera failed", e); + showLabel(R.string.capture_camera_not_implemented); + } + } + + private static String chooseCamera(CameraManager mgr, boolean front) throws CameraAccessException { + int want = front ? CameraCharacteristics.LENS_FACING_FRONT + : CameraCharacteristics.LENS_FACING_BACK; + for (String id : mgr.getCameraIdList()) { + Integer facing = mgr.getCameraCharacteristics(id) + .get(CameraCharacteristics.LENS_FACING); + if (facing != null && facing == want) { + return id; + } + } + String[] ids = mgr.getCameraIdList(); + return ids.length > 0 ? ids[0] : null; + } + + public void pause() { + stopCamera(); + if (screenPreview != null) { + screenPreview.pausePreview(); + } + } + + public void stop() { + stopCamera(); + if (screenPreview != null) { + screenPreview.stop(); + } + } + + private void stopCamera() { + if (session != null) { + try { + session.close(); + } catch (Exception ignored) { + } + session = null; + } + if (camera != null) { + try { + camera.close(); + } catch (Exception ignored) { + } + camera = null; + } + if (thread != null) { + thread.quitSafely(); + thread = null; + } + handler = null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java new file mode 100644 index 0000000..87d7619 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java @@ -0,0 +1,150 @@ +package com.foxx.androidcast.sender; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.SurfaceTexture; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.media.projection.MediaProjection; +import android.media.projection.MediaProjectionManager; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Surface; +import android.view.TextureView; +import android.view.WindowManager; + +import com.foxx.androidcast.CastSettings; + +/** + * Live screen preview via MediaProjection into a {@link TextureView} (prep only, not the cast encoder). + */ +public final class SenderScreenPreview { + private static final String TAG = "SenderScreenPreview"; + private static final int PREVIEW_DPI = 160; + + private final Context appContext; + private MediaProjection projection; + private VirtualDisplay virtualDisplay; + private int resultCode; + private Intent resultData; + + public SenderScreenPreview(Context context) { + appContext = context.getApplicationContext(); + } + + public boolean needsProjection(CastSettings.CaptureMode mode) { + return mode == CastSettings.CaptureMode.FULL_SCREEN + || mode == CastSettings.CaptureMode.USER_CHOICE; + } + + public void setProjectionResult(int resultCode, Intent data) { + this.resultCode = resultCode; + this.resultData = data != null ? new Intent(data) : null; + } + + public Intent getProjectionData() { + return resultData != null ? new Intent(resultData) : null; + } + + public int getProjectionResultCode() { + return resultCode; + } + + /** User granted capture consent (may not have an active {@link MediaProjection} after pause). */ + public boolean hasConsent() { + return resultCode == Activity.RESULT_OK && resultData != null; + } + + public boolean hasProjection() { + return hasConsent(); + } + + public void attach(TextureView textureView, CastSettings.CaptureMode mode) { + stop(); + if (!needsProjection(mode) || resultData == null) { + return; + } + Runnable start = () -> startOnTexture(textureView, mode); + if (textureView.isAvailable()) { + start.run(); + } else { + textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + startOnTexture(textureView, mode); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {} + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + stop(); + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) {} + }); + } + } + + private void startOnTexture(TextureView textureView, CastSettings.CaptureMode mode) { + try { + MediaProjectionManager mgr = + appContext.getSystemService(MediaProjectionManager.class); + projection = mgr.getMediaProjection(resultCode, resultData); + if (projection == null) { + return; + } + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(metrics); + int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 3); + int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 3); + SurfaceTexture st = textureView.getSurfaceTexture(); + if (st == null) { + return; + } + st.setDefaultBufferSize(w, h); + Surface surface = new Surface(st); + virtualDisplay = projection.createVirtualDisplay( + "CastPreview", + w, + h, + PREVIEW_DPI, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null); + Log.i(TAG, "Screen preview " + w + "x" + h); + } catch (Exception e) { + Log.w(TAG, "Screen preview failed: " + e.getMessage()); + stop(); + } + } + + /** Tear down preview display only; keep consent for resume or cast START. */ + public void pausePreview() { + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + } + + public void stop() { + pausePreview(); + if (projection != null) { + projection.stop(); + projection = null; + } + } + + /** Release consent and active capture (leaving sender screen). */ + public void releaseConsent() { + stop(); + resultCode = 0; + resultData = null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java index 434b4b7..4e8976c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java +++ b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java @@ -32,16 +32,17 @@ public final class CameraCastCapture { appContext = context.getApplicationContext(); } - public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException { + public void start(Surface encoderSurface, int targetWidth, int targetHeight, boolean useFrontCamera) + throws CameraAccessException { stop(); thread = new HandlerThread("CameraCast"); thread.start(); handler = new Handler(thread.getLooper()); CameraManager mgr = appContext.getSystemService(CameraManager.class); - cameraId = chooseCameraId(mgr); + cameraId = chooseCameraId(mgr, useFrontCamera); if (cameraId == null) { throw new CameraAccessException(CameraAccessException.CAMERA_ERROR, - "No back camera available"); + useFrontCamera ? "No front camera available" : "No back camera available"); } Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight); Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight()); @@ -97,11 +98,14 @@ public final class CameraCastCapture { }, handler); } - private static String chooseCameraId(CameraManager mgr) throws CameraAccessException { + private static String chooseCameraId(CameraManager mgr, boolean useFrontCamera) throws CameraAccessException { + int want = useFrontCamera + ? CameraCharacteristics.LENS_FACING_FRONT + : CameraCharacteristics.LENS_FACING_BACK; for (String id : mgr.getCameraIdList()) { CameraCharacteristics chars = mgr.getCameraCharacteristics(id); Integer facing = chars.get(CameraCharacteristics.LENS_FACING); - if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) { + if (facing != null && facing == want) { return id; } } diff --git a/app/src/main/res/drawable/bg_settings_section_header.xml b/app/src/main/res/drawable/bg_settings_section_header.xml new file mode 100644 index 0000000..f7ad3d5 --- /dev/null +++ b/app/src/main/res/drawable/bg_settings_section_header.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/activity_sender.xml b/app/src/main/res/layout-land/activity_sender.xml new file mode 100644 index 0000000..6fd74bd --- /dev/null +++ b/app/src/main/res/layout-land/activity_sender.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + +