diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 5749e89378..2ae038dec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.payments.DataExportUtil import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter +import org.thoughtcrime.securesms.ringrtc.CameraFpsRanges import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.ConversationUtil import org.thoughtcrime.securesms.util.TextSecurePreferences @@ -100,6 +101,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter private lateinit var viewModel: InternalSettingsViewModel private var searchMenuItem: MenuItem? = null + private val cameraFpsRangeDescription: String? by lazy { CameraFpsRanges.captureCameraRangesDescription(requireContext()) } + private var scrollToPosition: Int = 0 private val layoutManager: LinearLayoutManager? get() = recyclerView?.layoutManager as? LinearLayoutManager @@ -836,6 +839,25 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + clickPref( + title = DSLSettingsText.from("Minimum Capture FPS"), + summary = DSLSettingsText.from( + buildString { + append(if (state.callingMinimumCaptureFps > 0) "${state.callingMinimumCaptureFps} fps" else "Default") + cameraFpsRangeDescription?.let { append("\n$it") } + } + ), + onClick = { + promptUserForInt( + title = "Minimum Capture FPS", + message = "Floor for the camera's capture framerate range. 0 for default logic. Applies on next call start.", + initialValue = state.callingMinimumCaptureFps.takeIf { it > 0 } + ) { minimumFps -> + viewModel.setInternalCallingMinimumCaptureFps(minimumFps ?: 0) + } + } + ) + dividerPref() // TODO [alex] -- db access on main thread! diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 6c455f5d03..5be9255b32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -26,6 +26,7 @@ data class InternalSettingsState( val callingUseSoftwareVp9Decode: Boolean, val callingEnableSvc: Boolean, val callingStatsIntervalSecs: Int, + val callingMinimumCaptureFps: Int, val useBuiltInEmojiSet: Boolean, val emojiVersion: EmojiFiles.Version?, val removeSenderKeyMinimium: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 0d436fdae4..4d4a7ae4d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -177,6 +177,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito refresh() } + fun setInternalCallingMinimumCaptureFps(minimumFps: Int) { + preferenceDataStore.putInt(InternalValues.CALLING_MINIMUM_CAPTURE_FPS, minimumFps) + refresh() + } + fun setUseConversationItemV2Media(enabled: Boolean) { SignalStore.internal.useConversationItemV2Media = enabled refresh() @@ -265,6 +270,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito callingUseSoftwareVp9Decode = SignalStore.internal.callingUseSoftwareVp9Decode, callingEnableSvc = SignalStore.internal.callingEnableSvc, callingStatsIntervalSecs = SignalStore.internal.callingStatsIntervalSecs, + callingMinimumCaptureFps = SignalStore.internal.callingMinimumCaptureFps, useBuiltInEmojiSet = SignalStore.internal.forceBuiltInEmoji, emojiVersion = null, removeSenderKeyMinimium = SignalStore.internal.removeSenderKeyMinimum, diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt index 65f4479e1a..c2f8beb334 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt @@ -33,6 +33,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal const val CALLING_USE_SOFTWARE_VP9_DECODE: String = "internal.calling_use_software_vp9_decode" const val CALLING_ENABLE_SVC: String = "internal.calling_enable_svc" const val CALLING_STATS_INTERVAL_SECS: String = "internal.calling_stats_interval_secs" + const val CALLING_MINIMUM_CAPTURE_FPS: String = "internal.calling_minimum_capture_fps" const val SHAKE_TO_REPORT: String = "internal.shake_to_report" const val DISABLE_STORAGE_SERVICE: String = "internal.disable_storage_service" const val LAST_SCROLL_POSITION: String = "internal.last_scroll_position" @@ -196,6 +197,11 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal */ var callingStatsIntervalSecs: Int by integerValue(CALLING_STATS_INTERVAL_SECS, 0).defaultForExternalUsers() + /** + * Minimum fps for the camera's capture framerate range. Zero means use default logic + */ + var callingMinimumCaptureFps: Int by integerValue(CALLING_MINIMUM_CAPTURE_FPS, 0).defaultForExternalUsers() + var lastScrollPosition: Int by integerValue(LAST_SCROLL_POSITION, 0).defaultForExternalUsers() var useConversationItemV2Media by booleanValue(CONVERSATION_ITEM_V2_MEDIA, false).defaultForExternalUsers() diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java index 748235e258..1363b5fce3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/Camera.java @@ -12,9 +12,11 @@ import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; import org.signal.ringrtc.CameraControl; import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper; +import org.thoughtcrime.securesms.service.webrtc.RingRtcDynamicConfiguration; import org.webrtc.Camera1Enumerator; import org.webrtc.Camera2Capturer; import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerationAndroid; import org.webrtc.CameraEnumerator; import org.webrtc.CameraVideoCapturer; import org.webrtc.CapturerObserver; @@ -209,6 +211,10 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa private void startCapture() { Log.i(TAG, "startCapture()"); try { + int minimumCaptureFps = RingRtcDynamicConfiguration.getMinimumCaptureFps(); + CameraEnumerationAndroid.setMinimumFramerateFps(minimumCaptureFps); + Log.i(TAG, "startCapture(): minimum capture fps: " + (minimumCaptureFps > 0 ? String.valueOf(minimumCaptureFps) : "default")); + capturer.startCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT, CAPTURE_FPS); capturing = true; } catch (Exception e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraFpsRanges.kt b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraFpsRanges.kt new file mode 100644 index 0000000000..7c433e6c96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ringrtc/CameraFpsRanges.kt @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.ringrtc + +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CameraMetadata +import android.util.Range +import androidx.core.content.ContextCompat +import org.signal.core.util.logging.Log + +/** + * Exposes [captureCameraRangesDescription], which lists the fps ranges of the front and back camera + * that are suitabe for video calls. + */ +object CameraFpsRanges { + + private val TAG = Log.tag(CameraFpsRanges::class) + + /** + * Descriptions of capture ranges, one camera per line, e.g. "Front: [7,30] [15,15] [30,30]". + * Only returns fps ranges for the first non-monochrome camera sensor hardware for each direction + */ + fun captureCameraRangesDescription(context: Context): String? { + return forCaptureCameras(context) + .takeIf { it.isNotEmpty() } + ?.joinToString("\n") { camera -> + "${camera.facing}: ${camera.ranges.joinToString(" ") { "[${it.lower},${it.upper}]" }}" + } + } + + private fun forCaptureCameras(context: Context): List { + val cameraManager: CameraManager = ContextCompat.getSystemService(context, CameraManager::class.java) ?: return emptyList() + + return try { + val candidates = cameraManager.cameraIdList.filterNot { id -> + cameraManager.characteristics(id) + ?.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + ?.contains(CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_MONOCHROME) + ?: false + } + + listOf( + "Front" to CameraMetadata.LENS_FACING_FRONT, + "Back" to CameraMetadata.LENS_FACING_BACK + ).mapNotNull { (label, facing) -> + candidates + .firstOrNull { cameraManager.characteristics(it)?.get(CameraCharacteristics.LENS_FACING) == facing } + ?.let { id -> + val ranges = cameraManager.characteristics(id) + ?.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) + ?.sortedWith(compareBy({ it.lower }, { it.upper })) + ?: emptyList() + CameraRanges(label, id, ranges) + } + ?.takeIf { it.ranges.isNotEmpty() } + } + } catch (e: Exception) { + Log.w(TAG, "Unable to read camera fps ranges", e) + emptyList() + } + } + + private fun CameraManager.characteristics(cameraId: String): CameraCharacteristics? { + return try { + getCameraCharacteristics(cameraId) + } catch (e: Exception) { + Log.w(TAG, "Unable to read characteristics for camera $cameraId", e) + null + } + } + + private data class CameraRanges(val facing: String, val cameraId: String, val ranges: List>) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/RingRtcDynamicConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/RingRtcDynamicConfiguration.kt index 8ae4816950..998f72520b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/RingRtcDynamicConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/RingRtcDynamicConfiguration.kt @@ -82,6 +82,11 @@ object RingRtcDynamicConfiguration { return if (secs != 0) secs else null } + @JvmStatic + fun getMinimumCaptureFps(): Int { + return SignalStore.internal.callingMinimumCaptureFps.coerceAtLeast(0) + } + private fun getSoCInfo(): Pair { // 1. Native API Method (Android 12 / API 31+) if (Build.VERSION.SDK_INT >= 31) {