v1.6.1
This commit is contained in:
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
|
||||
}
|
||||
|
||||
// You can still set these for legacy use by submodules or scripts:
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.0").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("310").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.1").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("312").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
## v1.6.1
|
||||
- Fix: Custom Frame Rate
|
||||
- Fix: Conversation Sound Style
|
||||
- Fix: Auto Reactions when downloading stories(Removed story thumbnail feature)
|
||||
|
||||
## v1.6.0
|
||||
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
|
||||
- New: Call Metadata Notifier!
|
||||
|
||||
@@ -1300,10 +1300,6 @@
|
||||
"name": "Call Metadata Notifier",
|
||||
"description": "Shows a notification with captured call metadata after the call ends"
|
||||
},
|
||||
"conversation_sound_effects": {
|
||||
"name": "Conversation Sound Effects",
|
||||
"description": "Plays send and receive sounds inside an open conversation"
|
||||
},
|
||||
"conversation_sound_effects_style": {
|
||||
"name": "Conversation Sound Style",
|
||||
"description": "Choose the sound style used for in-conversation send and receive sounds"
|
||||
@@ -2529,6 +2525,7 @@
|
||||
"null": "Device default FPS"
|
||||
},
|
||||
"conversation_sound_effects_style": {
|
||||
"disabled": "Disabled",
|
||||
"imessage": "iMessage",
|
||||
"telegram": "Telegram",
|
||||
"whatsapp": "WhatsApp",
|
||||
|
||||
@@ -224,12 +224,12 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
|
||||
val blockCalls = boolean("block_calls") { requireRestart() }
|
||||
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val conversationSoundEffects = boolean("conversation_sound_effects") { requireRestart() }
|
||||
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "imessage", "telegram", "whatsapp", "subtle") {
|
||||
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "disabled", "imessage", "telegram", "whatsapp", "subtle") {
|
||||
requireRestart()
|
||||
customOptionTranslationPath = "conversation_sound_effects_style"
|
||||
addFlags(ConfigFlag.NO_TRANSLATE)
|
||||
}.apply { set("imessage") }
|
||||
addFlags(ConfigFlag.NO_DISABLE_KEY)
|
||||
}.apply { set("disabled") }
|
||||
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
|
||||
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
|
||||
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -30,14 +29,11 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -45,8 +41,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -125,11 +119,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
|
||||
var lastSeenMapParams: ParamMap? = null
|
||||
private set
|
||||
private val storyPreviewCache = mutableMapOf<String, MutableMap<Int, Bitmap>>()
|
||||
@Volatile
|
||||
private var pendingBatchDownloadIndices: MutableList<Int>? = null
|
||||
@Volatile
|
||||
private var batchForceAllowDuplicate: Boolean = false
|
||||
private val translations by lazy {
|
||||
context.translation.getCategory("download_processor")
|
||||
}
|
||||
@@ -267,68 +256,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
val tr = context.translation.getCategory("download_processor.story_snap_dialog")
|
||||
val cancelStr = context.translation["button.cancel"]
|
||||
val downloadStr = context.translation["button.download"]
|
||||
val previewCacheKey = buildString {
|
||||
append(paramMap["STORY_ID"]?.toString() ?: "story")
|
||||
append("|")
|
||||
append(paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: "user")
|
||||
append("|")
|
||||
append(totalCount)
|
||||
}
|
||||
context.runOnUiThread {
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
val selected = remember { mutableStateListOf<Int>().apply { add(currentIndex) } }
|
||||
val previewBitmaps = remember { mutableStateMapOf<Int, Bitmap?>() }
|
||||
val previewLoading = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!selected.contains(currentIndex)) selected.add(currentIndex)
|
||||
mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri?.let { currentUri ->
|
||||
previewLoading[currentIndex] = true
|
||||
previewBitmaps[currentIndex] = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(currentUri) }
|
||||
previewLoading[currentIndex] = false
|
||||
}
|
||||
synchronized(storyPreviewCache) {
|
||||
storyPreviewCache[previewCacheKey]?.forEach { (index, bitmap) ->
|
||||
previewBitmaps[index] = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(previewCacheKey) {
|
||||
val overlay = context.feature(OperaStoryOverlay::class)
|
||||
val cachedIndices = synchronized(storyPreviewCache) {
|
||||
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }.keys.toSet()
|
||||
}
|
||||
val indicesToScan = (0 until totalCount).filter { it != currentIndex && it !in cachedIndices }
|
||||
|
||||
try {
|
||||
for (targetIndex in indicesToScan) {
|
||||
val jumped = withContext(Dispatchers.Main) {
|
||||
overlay.requestJumpToSnap(targetIndex, totalCount)
|
||||
}
|
||||
if (!jumped) continue
|
||||
|
||||
val reached = waitForStoryIndex(targetIndex)
|
||||
if (!reached) continue
|
||||
|
||||
val uri = lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri ?: continue
|
||||
previewLoading[targetIndex] = true
|
||||
val bitmap = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(uri) }
|
||||
previewLoading[targetIndex] = false
|
||||
if (bitmap != null) {
|
||||
previewBitmaps[targetIndex] = bitmap
|
||||
synchronized(storyPreviewCache) {
|
||||
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }[targetIndex] = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
overlay.requestJumpToSnap(currentIndex, totalCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PurrfectGlassCard(
|
||||
title = tr["title"],
|
||||
@@ -362,35 +293,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
},
|
||||
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val rowBitmap = previewBitmaps[index]
|
||||
val rowLoading = previewLoading[index] == true
|
||||
when {
|
||||
rowBitmap != null -> Image(
|
||||
bitmap = rowBitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
.background(Color.Transparent, RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
rowLoading -> CircularProgressIndicator(
|
||||
color = PurrfectOverlayPalette.glowPrimary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Outlined.Image,
|
||||
contentDescription = null,
|
||||
tint = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -435,10 +337,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (selected.isNotEmpty()) {
|
||||
startBatchDownload(selected.sorted().toMutableList(), allowDuplicate)
|
||||
alertDialog.dismiss()
|
||||
if (!selected.contains(currentIndex)) return@Button
|
||||
context.executeAsync {
|
||||
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) }
|
||||
.onFailure {
|
||||
context.log.error("Story download failed", it)
|
||||
context.shortToast(translations["failed_generic_toast"])
|
||||
}
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -457,124 +364,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForStoryIndex(targetIndex: Int, timeoutMs: Long = 3000L): Boolean {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||
if (lastSeenMapParams?.getStorySnapIndex() == targetIndex) return true
|
||||
kotlinx.coroutines.delay(60L)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun loadStoryPreviewBitmap(uriString: String): Bitmap? {
|
||||
return runCatching {
|
||||
val uri = Uri.parse(uriString)
|
||||
when (uri.scheme?.lowercase()) {
|
||||
"content" -> context.androidContext.contentResolver.openInputStream(uri)?.use(BitmapFactory::decodeStream)
|
||||
"file", null -> BitmapFactory.decodeFile(uri.path)
|
||||
"http", "https" -> {
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(uriString).build()).execute().use { response ->
|
||||
response.body?.byteStream()?.use { stream -> BitmapFactory.decodeStream(stream) }
|
||||
}
|
||||
}.getOrNull() ?: run {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(uriString, emptyMap())
|
||||
retriever.frameAtTime
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
} ?: run {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(context.androidContext, uri)
|
||||
retriever.frameAtTime
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun startBatchDownload(indices: MutableList<Int>, allowDuplicate: Boolean) {
|
||||
if (indices.isEmpty()) return
|
||||
val paramMap = lastSeenMapParams ?: return
|
||||
val mediaInfoMap = lastSeenMediaInfoMap ?: return
|
||||
|
||||
pendingBatchDownloadIndices = indices
|
||||
batchForceAllowDuplicate = allowDuplicate
|
||||
|
||||
val currentIndex = paramMap.getStorySnapIndex() ?: 0
|
||||
val targetIndex = indices.first()
|
||||
val totalCount = paramMap.getStorySnapTotal()
|
||||
|
||||
if (currentIndex == targetIndex) {
|
||||
processNextBatchDownload(paramMap, mediaInfoMap)
|
||||
} else {
|
||||
val jumped = context.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount)
|
||||
if (!jumped) {
|
||||
pendingBatchDownloadIndices = null
|
||||
context.shortToast(translations["batch_download_jump_failed_toast"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
|
||||
context.executeAsync {
|
||||
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, batchForceAllowDuplicate) }
|
||||
.onFailure {
|
||||
context.log.error("Batch download failed", it)
|
||||
context.shortToast(translations["failed_generic_toast"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
|
||||
val queue = pendingBatchDownloadIndices ?: return
|
||||
if (queue.isEmpty()) {
|
||||
flushPendingMergeAndComplete()
|
||||
return
|
||||
}
|
||||
|
||||
val currentIndex = paramMap.getStorySnapIndex() ?: -1
|
||||
if (currentIndex != queue.first()) return
|
||||
|
||||
queue.removeAt(0)
|
||||
downloadSingleSnap(paramMap, mediaInfoMap)
|
||||
|
||||
if (queue.isEmpty()) {
|
||||
flushPendingMergeAndComplete()
|
||||
} else {
|
||||
val totalCount = paramMap.getStorySnapTotal()
|
||||
context.runOnUiThread {
|
||||
fun tryJump(retryCount: Int = 0) {
|
||||
val delayMs = if (retryCount == 0) 120L else 220L
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
val jumped = runCatching {
|
||||
context.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount)
|
||||
}.getOrNull() == true
|
||||
if (!jumped && retryCount < 1) {
|
||||
tryJump(retryCount + 1)
|
||||
} else if (!jumped) {
|
||||
pendingBatchDownloadIndices = null
|
||||
context.shortToast(translations["batch_download_jump_failed_toast"])
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
tryJump()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushPendingMergeAndComplete() {
|
||||
pendingBatchDownloadIndices = null
|
||||
context.shortToast(translations["batch_download_complete_toast"])
|
||||
}
|
||||
|
||||
fun showLastOperaDebugMediaInfo() {
|
||||
if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return
|
||||
|
||||
@@ -1063,15 +852,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
lastSeenMapParams = mediaParamMap
|
||||
lastSeenMediaInfoMap = mediaInfoMap
|
||||
|
||||
if (pendingBatchDownloadIndices != null) {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
if (pendingBatchDownloadIndices != null) {
|
||||
processNextBatchDownload(mediaParamMap, mediaInfoMap)
|
||||
}
|
||||
}, 80L)
|
||||
return@onOperaViewStateCallback
|
||||
}
|
||||
|
||||
if (!shouldAutoDownload) {
|
||||
return@onOperaViewStateCallback
|
||||
}
|
||||
|
||||
@@ -1,87 +1,244 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import android.media.AudioTrack
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
|
||||
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
|
||||
private val seenIncomingMessageIds = LinkedHashSet<Long>()
|
||||
private val maxTrackedMessages = 512
|
||||
|
||||
private data class ToneStep(
|
||||
val tone: Int,
|
||||
private data class BubbleSpec(
|
||||
val durationMs: Int,
|
||||
val startFreqHz: Double,
|
||||
val endFreqHz: Double,
|
||||
val overtoneFreqHz: Double,
|
||||
val amplitude: Double
|
||||
)
|
||||
|
||||
private data class BubbleStep(
|
||||
val spec: BubbleSpec,
|
||||
val pauseAfterMs: Long = 0L
|
||||
)
|
||||
|
||||
private data class ToneSpec(
|
||||
val sendPattern: List<ToneStep>,
|
||||
val receivePattern: List<ToneStep>
|
||||
private val iMessageSendBubble = BubbleSpec(
|
||||
durationMs = 78,
|
||||
startFreqHz = 1160.0,
|
||||
endFreqHz = 690.0,
|
||||
overtoneFreqHz = 1820.0,
|
||||
amplitude = 0.50
|
||||
)
|
||||
|
||||
private val iMessageReceiveBubble = BubbleSpec(
|
||||
durationMs = 92,
|
||||
startFreqHz = 1040.0,
|
||||
endFreqHz = 640.0,
|
||||
overtoneFreqHz = 1680.0,
|
||||
amplitude = 0.46
|
||||
)
|
||||
|
||||
private val whatsappSendBubble = BubbleSpec(
|
||||
durationMs = 86,
|
||||
startFreqHz = 860.0,
|
||||
endFreqHz = 520.0,
|
||||
overtoneFreqHz = 1410.0,
|
||||
amplitude = 0.52
|
||||
)
|
||||
|
||||
private val whatsappReceiveBubble = BubbleSpec(
|
||||
durationMs = 94,
|
||||
startFreqHz = 920.0,
|
||||
endFreqHz = 560.0,
|
||||
overtoneFreqHz = 1520.0,
|
||||
amplitude = 0.50
|
||||
)
|
||||
|
||||
private val telegramSendPrimary = BubbleSpec(
|
||||
durationMs = 58,
|
||||
startFreqHz = 1110.0,
|
||||
endFreqHz = 820.0,
|
||||
overtoneFreqHz = 1710.0,
|
||||
amplitude = 0.42
|
||||
)
|
||||
|
||||
private val telegramSendAccent = BubbleSpec(
|
||||
durationMs = 34,
|
||||
startFreqHz = 1360.0,
|
||||
endFreqHz = 980.0,
|
||||
overtoneFreqHz = 2060.0,
|
||||
amplitude = 0.22
|
||||
)
|
||||
|
||||
private val telegramReceivePrimary = BubbleSpec(
|
||||
durationMs = 72,
|
||||
startFreqHz = 1080.0,
|
||||
endFreqHz = 780.0,
|
||||
overtoneFreqHz = 1680.0,
|
||||
amplitude = 0.44
|
||||
)
|
||||
|
||||
private val telegramReceiveAccent = BubbleSpec(
|
||||
durationMs = 42,
|
||||
startFreqHz = 1280.0,
|
||||
endFreqHz = 940.0,
|
||||
overtoneFreqHz = 1940.0,
|
||||
amplitude = 0.18
|
||||
)
|
||||
|
||||
private val subtleSendBubble = BubbleSpec(
|
||||
durationMs = 60,
|
||||
startFreqHz = 760.0,
|
||||
endFreqHz = 520.0,
|
||||
overtoneFreqHz = 1180.0,
|
||||
amplitude = 0.26
|
||||
)
|
||||
|
||||
private val subtleReceiveBubble = BubbleSpec(
|
||||
durationMs = 66,
|
||||
startFreqHz = 800.0,
|
||||
endFreqHz = 560.0,
|
||||
overtoneFreqHz = 1260.0,
|
||||
amplitude = 0.24
|
||||
)
|
||||
|
||||
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
|
||||
|
||||
private fun styleSpec(): ToneSpec {
|
||||
return when (context.config.messaging.conversationSoundEffectsStyle.get()) {
|
||||
"telegram" -> ToneSpec(
|
||||
sendPattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_PROP_BEEP, 35),
|
||||
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 45, 25)
|
||||
),
|
||||
receivePattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 70),
|
||||
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 35, 20)
|
||||
)
|
||||
)
|
||||
"whatsapp" -> ToneSpec(
|
||||
sendPattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_PROP_ACK, 55)
|
||||
),
|
||||
receivePattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 85),
|
||||
ToneStep(ToneGenerator.TONE_PROP_ACK, 35, 15)
|
||||
)
|
||||
)
|
||||
"subtle" -> ToneSpec(
|
||||
sendPattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 22)
|
||||
),
|
||||
receivePattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_PROP_ACK, 28)
|
||||
)
|
||||
)
|
||||
else -> ToneSpec(
|
||||
sendPattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 40),
|
||||
ToneStep(ToneGenerator.TONE_PROP_BEEP, 28, 18)
|
||||
),
|
||||
receivePattern = listOf(
|
||||
ToneStep(ToneGenerator.TONE_PROP_ACK, 55),
|
||||
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 40, 22)
|
||||
)
|
||||
private fun buildBubblePcm(spec: BubbleSpec, sampleRate: Int = 44_100): ByteArray {
|
||||
val sampleCount = (sampleRate * (spec.durationMs / 1000.0)).toInt().coerceAtLeast(1)
|
||||
val pcm = ByteArray(sampleCount * 2)
|
||||
for (i in 0 until sampleCount) {
|
||||
val progress = i.toDouble() / sampleCount.toDouble()
|
||||
val envelope = exp(-4.8 * progress) * (1.0 - exp(-20.0 * progress))
|
||||
val freq = spec.startFreqHz + (spec.endFreqHz - spec.startFreqHz) * progress
|
||||
val t = i.toDouble() / sampleRate.toDouble()
|
||||
val fundamental = sin(2.0 * PI * freq * t)
|
||||
val overtone = 0.18 * sin(2.0 * PI * spec.overtoneFreqHz * t)
|
||||
val airyTail = 0.08 * sin(2.0 * PI * (freq * 0.48) * t)
|
||||
val warmth = 0.14 * sin(2.0 * PI * (freq * 0.24) * t)
|
||||
val sample = ((fundamental + overtone + airyTail + warmth) * envelope * spec.amplitude)
|
||||
.coerceIn(-1.0, 1.0)
|
||||
val shortValue = (sample * Short.MAX_VALUE).toInt().toShort()
|
||||
pcm[i * 2] = (shortValue.toInt() and 0xFF).toByte()
|
||||
pcm[i * 2 + 1] = ((shortValue.toInt() shr 8) and 0xFF).toByte()
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
|
||||
private fun playBubble(spec: BubbleSpec) {
|
||||
if (context.isMainActivityPaused) return
|
||||
context.executeAsync {
|
||||
val sampleRate = 44_100
|
||||
val pcm = buildBubblePcm(spec, sampleRate)
|
||||
val audioTrack = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
pcm.size,
|
||||
AudioTrack.MODE_STATIC,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE
|
||||
)
|
||||
runCatching {
|
||||
audioTrack.write(pcm, 0, pcm.size)
|
||||
audioTrack.play()
|
||||
delay(spec.durationMs.toLong() + 24L)
|
||||
}.also {
|
||||
runCatching {
|
||||
audioTrack.stop()
|
||||
audioTrack.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playPattern(pattern: List<ToneStep>) {
|
||||
private fun playBubbleSequence(steps: List<BubbleStep>) {
|
||||
if (context.isMainActivityPaused) return
|
||||
context.executeAsync {
|
||||
var toneGenerator: ToneGenerator? = null
|
||||
runCatching {
|
||||
toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 55)
|
||||
pattern.forEach { step ->
|
||||
toneGenerator?.startTone(step.tone, step.durationMs)
|
||||
if (step.pauseAfterMs > 0) delay(step.pauseAfterMs)
|
||||
steps.forEach { step ->
|
||||
val sampleRate = 44_100
|
||||
val pcm = buildBubblePcm(step.spec, sampleRate)
|
||||
val audioTrack = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
pcm.size,
|
||||
AudioTrack.MODE_STATIC,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE
|
||||
)
|
||||
runCatching {
|
||||
audioTrack.write(pcm, 0, pcm.size)
|
||||
audioTrack.play()
|
||||
delay(step.spec.durationMs.toLong() + step.pauseAfterMs + 18L)
|
||||
}.also {
|
||||
runCatching {
|
||||
audioTrack.stop()
|
||||
audioTrack.release()
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
runCatching { toneGenerator?.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playStyledSend() {
|
||||
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
|
||||
"imessage" -> playBubble(iMessageSendBubble)
|
||||
"whatsapp" -> playBubble(whatsappSendBubble)
|
||||
"telegram" -> playBubbleSequence(
|
||||
listOf(
|
||||
BubbleStep(telegramSendPrimary, pauseAfterMs = 16L),
|
||||
BubbleStep(telegramSendAccent)
|
||||
)
|
||||
)
|
||||
else -> playBubble(subtleSendBubble)
|
||||
}
|
||||
}
|
||||
|
||||
private fun playStyledReceive() {
|
||||
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
|
||||
"imessage" -> playBubble(iMessageReceiveBubble)
|
||||
"whatsapp" -> playBubbleSequence(
|
||||
listOf(
|
||||
BubbleStep(whatsappReceiveBubble, pauseAfterMs = 12L),
|
||||
BubbleStep(
|
||||
whatsappReceiveBubble.copy(
|
||||
durationMs = 42,
|
||||
startFreqHz = 1210.0,
|
||||
endFreqHz = 860.0,
|
||||
overtoneFreqHz = 1980.0,
|
||||
amplitude = 0.20
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
"telegram" -> playBubbleSequence(
|
||||
listOf(
|
||||
BubbleStep(telegramReceivePrimary, pauseAfterMs = 14L),
|
||||
BubbleStep(telegramReceiveAccent)
|
||||
)
|
||||
)
|
||||
else -> playBubble(subtleReceiveBubble)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markSeen(messageId: Long): Boolean {
|
||||
synchronized(seenIncomingMessageIds) {
|
||||
val added = seenIncomingMessageIds.add(messageId)
|
||||
@@ -93,15 +250,14 @@ class ConversationSoundEffects : Feature("Conversation Sound Effects") {
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.messaging.conversationSoundEffects.get()) return
|
||||
if (context.config.messaging.conversationSoundEffectsStyle.get() == "disabled") return
|
||||
|
||||
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
|
||||
val activeConversationId = currentConversationId() ?: return@subscribe
|
||||
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
|
||||
|
||||
event.addCallbackResult("onSuccess") {
|
||||
val spec = styleSpec()
|
||||
playPattern(spec.sendPattern)
|
||||
playStyledSend()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +266,6 @@ class ConversationSoundEffects : Feature("Conversation Sound Effects") {
|
||||
if (event.conversationId != activeConversationId) return@subscribe
|
||||
|
||||
val myUserId = context.database.myUserId ?: return@subscribe
|
||||
val spec = styleSpec()
|
||||
|
||||
event.messages
|
||||
.asSequence()
|
||||
@@ -119,7 +274,7 @@ class ConversationSoundEffects : Feature("Conversation Sound Effects") {
|
||||
.filter { markSeen(it) }
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
playPattern(spec.receivePattern)
|
||||
playStyledReceive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,11 +121,6 @@ class CameraTweaks : Feature("Camera Tweaks") {
|
||||
param.setArg(1, captureResolutionConfig[1])
|
||||
}
|
||||
|
||||
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
|
||||
val key = param.arg<CaptureRequest.Key<*>>(0)
|
||||
if (key == CaptureRequest.CONTROL_ZOOM_RATIO) return@hook
|
||||
}
|
||||
|
||||
CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param ->
|
||||
val key = param.argNullable<Key<*>>(0) ?: return@hook
|
||||
|
||||
@@ -151,6 +146,14 @@ class CameraTweaks : Feature("Camera Tweaks") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
|
||||
val isFrontCamera = param.invokeOriginal(
|
||||
arrayOf(CameraCharacteristics.LENS_FACING)
|
||||
) == CameraCharacteristics.LENS_FACING_FRONT
|
||||
val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook
|
||||
param.setResult(arrayOf(Range(customFrameRate, customFrameRate)))
|
||||
}
|
||||
}
|
||||
|
||||
if (config.blackPhotos.get()) {
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.6.0
|
||||
APP_VERSION_CODE=310
|
||||
APP_VERSION_NAME=1.6.1
|
||||
APP_VERSION_CODE=312
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
Reference in New Issue
Block a user