many changes
new feature to record both sides while call recording(upstream commit), fix armv7 failed to initialize error & fix a9 issues
This commit is contained in:
Binary file not shown.
@@ -3,15 +3,19 @@ package me.eternal.purrfectsnap.bridge
|
||||
import android.content.Intent
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
import android.hardware.biometrics.BiometricPrompt
|
||||
import android.hardware.fingerprint.FingerprintManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.app.KeyguardManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class BiometricPromptActivity: ComponentActivity() {
|
||||
private val deviceCredentialRequestCode = 2201
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -21,16 +25,50 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
}
|
||||
|
||||
val remoteSideContext = SharedContextHolder.remote(this)
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val negativeText = remoteSideContext.translation.getOrNull("biometric_auth.cancel")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Cancel"
|
||||
val titleText = remoteSideContext.translation.getOrNull("biometric_auth.title")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Unlock"
|
||||
val subtitleText = remoteSideContext.translation.getOrNull("biometric_auth.subtitle")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Confirm your screen lock"
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
val fingerprintManager = getSystemService(FingerprintManager::class.java)
|
||||
val hasFingerprintSupport = runCatching {
|
||||
fingerprintManager?.isHardwareDetected == true && fingerprintManager.hasEnrolledFingerprints()
|
||||
}.getOrDefault(false)
|
||||
if (!hasFingerprintSupport) {
|
||||
val keyguardManager = getSystemService(KeyguardManager::class.java)
|
||||
val canUseDeviceCredential = keyguardManager?.isKeyguardSecure == true
|
||||
if (canUseDeviceCredential) {
|
||||
val intent = keyguardManager.createConfirmDeviceCredentialIntent(titleText, subtitleText)
|
||||
if (intent != null) {
|
||||
startActivityForResult(intent, deviceCredentialRequestCode)
|
||||
setContent {}
|
||||
return
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
setContent {}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
BiometricPrompt.Builder(this@BiometricPromptActivity)
|
||||
.setTitle(remoteSideContext.translation["biometric_auth.title"])
|
||||
.setSubtitle(remoteSideContext.translation["biometric_auth.subtitle"])
|
||||
.setTitle(titleText)
|
||||
.setSubtitle(subtitleText)
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
setDeviceCredentialAllowed(true)
|
||||
} else {
|
||||
setNegativeButton(negativeText, mainExecutor) { _, _ -> cancel() }
|
||||
}
|
||||
}
|
||||
.build().authenticate(
|
||||
@@ -39,7 +77,7 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
Executors.newSingleThreadExecutor(),
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
|
||||
cancel()
|
||||
@@ -54,4 +92,16 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
|
||||
setContent {}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode != deviceCredentialRequestCode) return
|
||||
if (resultCode == RESULT_OK) {
|
||||
setResult(RESULT_OK, Intent())
|
||||
} else {
|
||||
setResult(RESULT_CANCELED, Intent())
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
import me.eternal.purrfectsnap.bridge.snapclient.MessagingBridge
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
@@ -16,6 +17,7 @@ import me.eternal.purrfectsnap.common.ui.OverlayType
|
||||
import me.eternal.purrfectsnap.common.util.toParcelable
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.download.call.CallDownloadSessionImpl
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
@@ -248,5 +250,16 @@ class BridgeService : Service() {
|
||||
override fun getDebugProp(key: String, defaultValue: String?): String? {
|
||||
return remoteSideContext.sharedPreferences.all["debug_$key"]?.toString() ?: defaultValue
|
||||
}
|
||||
|
||||
override fun startCallDownload(
|
||||
startTimestamp: Long,
|
||||
author: String
|
||||
): CallDownloadSession {
|
||||
return CallDownloadSessionImpl(
|
||||
context = remoteSideContext,
|
||||
callStartTimestamp = startTimestamp,
|
||||
author = author
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ class FFMpegProcessor(
|
||||
pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})")
|
||||
}
|
||||
)
|
||||
|
||||
fun newFFMpegProcessor(context: RemoteSideContext, onStatistics: (Statistics) -> Unit = {}) = FFMpegProcessor(
|
||||
logManager = context.log,
|
||||
ffmpegOptions = context.config.root.downloader.ffmpegOptions,
|
||||
onStatistics = onStatistics
|
||||
)
|
||||
}
|
||||
enum class Action {
|
||||
DOWNLOAD_DASH,
|
||||
@@ -66,6 +72,7 @@ class FFMpegProcessor(
|
||||
CONVERSION,
|
||||
MERGE_MEDIA,
|
||||
DOWNLOAD_AUDIO_STREAM,
|
||||
MERGE_AUDIO_STREAMS,
|
||||
}
|
||||
|
||||
data class Request(
|
||||
@@ -76,6 +83,7 @@ class FFMpegProcessor(
|
||||
val startTime: Long? = null, //only for DOWNLOAD_DASH
|
||||
val duration: Long? = null, //only for DOWNLOAD_DASH
|
||||
val audioStreamFormat: AudioStreamFormat? = null, //only for DOWNLOAD_AUDIO_STREAM
|
||||
val inputDelayOffsets: Map<String, Long>? = null, // only for MERGE_AUDIO_STREAMS
|
||||
|
||||
var videoCodec: String? = null,
|
||||
var audioCodec: String? = null,
|
||||
@@ -225,6 +233,26 @@ class FFMpegProcessor(
|
||||
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
|
||||
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
|
||||
}
|
||||
Action.MERGE_AUDIO_STREAMS -> {
|
||||
inputArguments.clear()
|
||||
outputArguments.clear()
|
||||
val filterParts = StringBuilder()
|
||||
args.inputs.forEachIndexed { index, input ->
|
||||
inputArguments += "-i" to input
|
||||
val offset = args.inputDelayOffsets?.get(input) ?: 0L
|
||||
if (offset > 0) {
|
||||
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
|
||||
} else {
|
||||
filterParts.append("[$index:a]acopy[a$index];")
|
||||
}
|
||||
}
|
||||
args.inputs.indices.forEach { index ->
|
||||
filterParts.append("[a$index]")
|
||||
}
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
|
||||
outputArguments += "-filter_complex" to "\"$filterParts\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package me.eternal.purrfectsnap.download.call
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
import me.eternal.purrfectsnap.common.data.download.AudioStreamFormat
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
|
||||
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
|
||||
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.task.PendingTaskListener
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class CallDownloadSessionImpl(
|
||||
private val context: RemoteSideContext,
|
||||
private val callStartTimestamp: Long,
|
||||
private val author: String,
|
||||
): CallDownloadSession.Stub() {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private var callEnded: Boolean = false
|
||||
|
||||
private val streams = CopyOnWriteArrayList<CallStream>()
|
||||
private var mergeJob: Job? = null
|
||||
|
||||
init {
|
||||
context.log.verbose("Starting call callStartTimestamp=$callStartTimestamp")
|
||||
}
|
||||
|
||||
inner class CallStream(
|
||||
val startTimestampMillis: Long,
|
||||
private val audioStreamFormat: AudioStreamFormat
|
||||
) {
|
||||
val job: Job
|
||||
val writePfd: ParcelFileDescriptor
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
|
||||
if (exists()) delete()
|
||||
}
|
||||
|
||||
init {
|
||||
val pipe = ParcelFileDescriptor.createPipe()
|
||||
writePfd = pipe[1]
|
||||
|
||||
job = coroutineScope.launch {
|
||||
runCatching {
|
||||
FFMpegProcessor.newFFMpegProcessor(context).execute(
|
||||
FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
|
||||
inputs = listOf("/proc/self/fd/${pipe[0].fd}"),
|
||||
output = outputFile,
|
||||
audioStreamFormat = audioStreamFormat
|
||||
)
|
||||
)
|
||||
}.onFailure {
|
||||
context.log.error("Error converting call audio stream", it)
|
||||
runCatching {
|
||||
outputFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
pipe.forEach { runCatching { it.close() } }
|
||||
|
||||
context.log.verbose("Call stream ended startTimestampMillis=$startTimestampMillis")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun createStream(
|
||||
startTimestampMillis: Long,
|
||||
channels: Int,
|
||||
sampleRate: Int,
|
||||
encoding: Int
|
||||
): ParcelFileDescriptor? {
|
||||
if (callEnded) return null
|
||||
|
||||
context.log.verbose("createFileDescriptor startTimestampMillis=$startTimestampMillis, channels=$channels, sampleRate=$sampleRate, encoding=$encoding")
|
||||
val callStream = CallStream(
|
||||
startTimestampMillis = startTimestampMillis,
|
||||
audioStreamFormat = AudioStreamFormat(
|
||||
channels = channels,
|
||||
sampleRate = sampleRate,
|
||||
encoding = encoding
|
||||
)
|
||||
)
|
||||
|
||||
synchronized(streams) {
|
||||
streams.add(callStream)
|
||||
}
|
||||
|
||||
return callStream.writePfd
|
||||
}
|
||||
|
||||
override fun end() {
|
||||
if (callEnded) return
|
||||
callEnded = true
|
||||
|
||||
if (streams.isEmpty()) {
|
||||
context.log.verbose("No call chunks to merge")
|
||||
return
|
||||
}
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}_final.mp3")
|
||||
val pendingTask = context.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Call Recording $author",
|
||||
author = author,
|
||||
hash = UUID.randomUUID().toString()
|
||||
)
|
||||
).apply {
|
||||
addListener(PendingTaskListener(
|
||||
onCancel = {
|
||||
mergeJob?.cancel()
|
||||
outputFile.delete()
|
||||
streams.forEach { stream ->
|
||||
stream.outputFile.delete()
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
mergeJob = coroutineScope.launch {
|
||||
try {
|
||||
streams.forEach { stream ->
|
||||
stream.job.join()
|
||||
}
|
||||
|
||||
val sortedStreams = streams.filter { it.outputFile.exists() }.sortedBy { it.startTimestampMillis }
|
||||
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
|
||||
FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_AUDIO_STREAMS,
|
||||
inputs = sortedStreams.map { it.outputFile.absolutePath },
|
||||
output = outputFile,
|
||||
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp) }
|
||||
)
|
||||
)
|
||||
|
||||
DownloadProcessor(context, object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String) {
|
||||
context.log.verbose("Downloaded call $outputPath")
|
||||
}
|
||||
}).saveMediaToGallery(pendingTask, outputFile, DownloadMetadata(
|
||||
mediaIdentifier = UUID.randomUUID().toString(),
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
UUID.randomUUID().toString().hashCode().absoluteValue.toString(16),
|
||||
downloadSource = MediaDownloadSource.VOICE_CALL,
|
||||
mediaAuthor = author,
|
||||
creationTimestamp = System.currentTimeMillis()
|
||||
),
|
||||
mediaAuthor = author,
|
||||
downloadSource = MediaDownloadSource.VOICE_CALL.translate(context.translation),
|
||||
iconUrl = null
|
||||
))
|
||||
} finally {
|
||||
streams.forEach { stream ->
|
||||
stream.outputFile.delete()
|
||||
}
|
||||
outputFile.delete()
|
||||
}
|
||||
|
||||
context.log.verbose("ending call")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user