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:
particle-box
2026-01-19 01:52:26 +05:30
parent 1ba7e411ca
commit 9fa733f98d
27 changed files with 812 additions and 319 deletions

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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 {

View File

@@ -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")
}
}
}