feat: streaks reminder

- add streak indicator in social
- fix dialogs
- fix container global state
This commit is contained in:
rhunk 2023-08-24 02:15:06 +02:00
parent 7703d3f007
commit 3eb8c8f015
29 changed files with 305 additions and 90 deletions

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
@ -61,6 +62,8 @@
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true"
android:exported="true" />
<receiver android:name=".messaging.StreaksReminder" />
</application>
</manifest>

View File

@ -9,6 +9,7 @@ import androidx.activity.ComponentActivity
import androidx.documentfile.provider.DocumentFile
import coil.ImageLoader
import coil.decode.VideoFrameDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import kotlinx.coroutines.Dispatchers
import me.rhunk.snapenhance.bridge.BridgeService
@ -17,6 +18,7 @@ import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.download.DownloadTaskManager
import me.rhunk.snapenhance.messaging.ModDatabase
import me.rhunk.snapenhance.messaging.StreaksReminder
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
@ -39,6 +41,7 @@ class RemoteSideContext(
val mappings = MappingsWrapper()
val downloadTaskManager = DownloadTaskManager()
val modDatabase = ModDatabase(this)
val streaksReminder = StreaksReminder(this)
//used to load bitmoji selfies and download previews
val imageLoader by lazy {
@ -48,24 +51,30 @@ class RemoteSideContext(
MemoryCache.Builder(androidContext)
.maxSizePercent(0.25)
.build()
}.components { add(VideoFrameDecoder.Factory()) }.build()
}
init {
reload()
}
.diskCache {
DiskCache.Builder()
.directory(androidContext.cacheDir.resolve("coil-disk-cache"))
.maxSizeBytes(1024 * 1024 * 100) // 100MB
.build()
}
.components { add(VideoFrameDecoder.Factory()) }.build()
}
fun reload() {
runCatching {
config.loadFromContext(androidContext)
translation.userLocale = config.locale
translation.loadFromContext(androidContext)
translation.apply {
userLocale = config.locale
loadFromContext(androidContext)
}
mappings.apply {
loadFromContext(androidContext)
init(androidContext)
}
downloadTaskManager.init(androidContext)
modDatabase.init()
streaksReminder.init()
}.onFailure {
Logger.error("Failed to load RemoteSideContext", it)
}

View File

@ -9,6 +9,7 @@ object SharedContextHolder {
fun remote(context: Context): RemoteSideContext {
if (!::_remoteSideContext.isInitialized || _remoteSideContext.get() == null) {
_remoteSideContext = WeakReference(RemoteSideContext(context))
_remoteSideContext.get()?.reload()
}
return _remoteSideContext.get()!!

View File

@ -26,7 +26,9 @@ class BridgeService : Service() {
remoteSideContext = SharedContextHolder.remote(this).apply {
if (checkForRequirements()) return null
}
remoteSideContext.bridgeService = this
remoteSideContext.apply {
bridgeService = this@BridgeService
}
messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
return BridgeBinder()
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.bridge
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import me.rhunk.snapenhance.SharedContextHolder
class ForceStartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.getBooleanExtra("streaks_notification_action", false)) {
packageManager.getLaunchIntentForPackage("com.snapchat.android")?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(this)
}
SharedContextHolder.remote(this).streaksReminder.dismissAllNotifications()
}
finish()
}
}

View File

@ -140,8 +140,11 @@ class ModDatabase(
)
//sync streaks
if (friend.streakLength > 0) {
database.execSQL("INSERT OR REPLACE INTO streaks (userId, expirationTimestamp, length) VALUES (?, ?, ?)", arrayOf(
val streaks = getFriendStreaks(friend.userId!!)
database.execSQL("INSERT OR REPLACE INTO streaks (userId, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf(
friend.userId,
streaks?.notify ?: false,
friend.streakExpirationTimestamp,
friend.streakLength
))
@ -198,6 +201,7 @@ class ModDatabase(
fun deleteFriend(userId: String) {
executeAsync {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId))
database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(userId))
}
}

View File

@ -0,0 +1,101 @@
package me.rhunk.snapenhance.messaging
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.ForceStartActivity
import me.rhunk.snapenhance.ui.util.ImageRequestHelper
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
class StreaksReminder(
private val remoteSideContext: RemoteSideContext? = null
): BroadcastReceiver() {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "streaks"
}
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private fun getNotificationManager(context: Context) = context.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Streaks",
NotificationManager.IMPORTANCE_HIGH
)
)
}
override fun onReceive(ctx: Context, intent: Intent) {
val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx)
if (remoteSideContext.config.root.streaksReminder.globalState != true) return
val notifyFriendList = remoteSideContext.modDatabase.getFriends()
.associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) }
.filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire() }
val notificationManager = getNotificationManager(ctx)
notifyFriendList.forEach { (streaks, friend) ->
coroutineScope.launch {
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D)
val bitmojiImage = remoteSideContext.imageLoader.execute(
ImageRequestHelper.newBitmojiImageRequest(ctx, bitmojiUrl)
)
val notificationBuilder = NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Streaks")
.setContentText("You will lose streaks with ${friend.displayName} in ${streaks?.hoursLeft() ?: 0} hours")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(PendingIntent.getActivity(
ctx,
0,
Intent(ctx, ForceStartActivity::class.java).apply {
putExtra("streaks_notification_action", true)
},
PendingIntent.FLAG_IMMUTABLE
))
.apply {
bitmojiImage.drawable?.let {
setLargeIcon(it.toBitmap())
setSmallIcon(R.drawable.streak_icon)
}
}
notificationManager.notify(friend.userId.hashCode(), notificationBuilder.build().apply {
flags = NotificationCompat.FLAG_ONLY_ALERT_ONCE
})
}
}
}
//TODO: ask for notifications permission for a13+
fun init() {
if (remoteSideContext == null) throw IllegalStateException("RemoteSideContext is null")
val reminderConfig = remoteSideContext.config.root.streaksReminder.also {
if (it.globalState != true) return
}
remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating(
AlarmManager.RTC_WAKEUP, 5000, reminderConfig.interval.get().toLong() * 60 * 60 * 1000,
PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java),
PendingIntent.FLAG_IMMUTABLE)
)
onReceive(remoteSideContext.androidContext, Intent())
}
fun dismissAllNotifications() = getNotificationManager(remoteSideContext!!.androidContext).cancelAll()
}

View File

@ -10,21 +10,23 @@ import androidx.compose.runtime.remember
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.ui.AppMaterialTheme
class MainActivity : ComponentActivity() {
private lateinit var sections: Map<EnumSection, Section>
private lateinit var navController: NavHostController
private lateinit var managerContext: RemoteSideContext
override fun onPostResume() {
super.onPostResume()
sections.values.forEach { it.onResumed() }
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent?.getStringExtra("route")?.let { route ->
intent.getStringExtra("route")?.let { route ->
navController.popBackStack()
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id){
@ -38,7 +40,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
val startDestination = intent.getStringExtra("route")?.let { EnumSection.fromRoute(it) } ?: EnumSection.HOME
val managerContext = SharedContextHolder.remote(this).apply {
managerContext = SharedContextHolder.remote(this).apply {
activity = this@MainActivity
checkForRequirements()
}

View File

@ -268,9 +268,9 @@ class FeaturesSection : Section() {
navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
}
if (container.globalState == null) return
if (!container.hasGlobalState) return
var state by remember { mutableStateOf(container.globalState!!) }
var state by remember { mutableStateOf(container.globalState ?: false) }
Box(
modifier = Modifier
@ -453,6 +453,22 @@ class FeaturesSection : Section() {
if (showSearchBar) return
var showExportDropdownMenu by remember { mutableStateOf(false) }
var showResetConfirmationDialog by remember { mutableStateOf(false) }
if (showResetConfirmationDialog) {
Dialog(onDismissRequest = { showResetConfirmationDialog = false }) {
alertDialogs.ConfirmDialog(
title = "Reset config",
message = "Are you sure you want to reset the config?",
onConfirm = {
context.config.reset()
context.shortToast("Config successfully reset!")
},
onDismiss = { showResetConfirmationDialog = false }
)
}
}
val actions = remember {
mapOf(
"Export" to {
@ -477,10 +493,7 @@ class FeaturesSection : Section() {
}
}
},
"Reset" to {
context.config.reset()
context.shortToast("Config successfully reset!")
}
"Reset" to { showResetConfirmationDialog = true }
)
}

View File

@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -29,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@ -53,31 +55,34 @@ class AddFriendDialog(
private val section: SocialSection,
) {
@Composable
private fun ListCardEntry(name: String, exists: Boolean, stateChanged: (state: Boolean) -> Unit = { }) {
var state by remember { mutableStateOf(exists) }
private fun ListCardEntry(name: String, currentState: () -> Boolean, onState: (Boolean) -> Unit = {}) {
var currentState by remember { mutableStateOf(currentState()) }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
state = !state
stateChanged(state)
currentState = !currentState
onState(currentState)
}
.padding(4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = name,
fontSize = 15.sp,
modifier = Modifier
.weight(1f)
.onGloballyPositioned {
currentState = currentState()
}
)
androidx.compose.material3.Checkbox(
checked = state,
Checkbox(
checked = currentState,
onCheckedChange = {
state = it
stateChanged(state)
currentState = it
onState(currentState)
}
)
}
@ -227,13 +232,12 @@ class AddFriendDialog(
items(filteredGroups.size) {
val group = filteredGroups[it]
ListCardEntry(
name = group.name,
exists = remember { context.modDatabase.getGroupInfo(group.conversationId) != null }
currentState = { context.modDatabase.getGroupInfo(group.conversationId) != null }
) { state ->
if (state) {
context.bridgeService.triggerGroupSync(cachedGroups!![it].conversationId)
context.bridgeService.triggerGroupSync(group.conversationId)
} else {
context.modDatabase.deleteGroup(group.conversationId)
}
@ -257,10 +261,10 @@ class AddFriendDialog(
ListCardEntry(
name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername,
exists = remember { context.modDatabase.getFriendInfo(friend.userId) != null }
currentState = { context.modDatabase.getFriendInfo(friend.userId) != null }
) { state ->
if (state) {
context.bridgeService.triggerFriendSync(cachedFriends!![it].userId)
context.bridgeService.triggerFriendSync(friend.userId)
} else {
context.modDatabase.deleteFriend(friend.userId)
}

View File

@ -177,14 +177,11 @@ class ScopeContent(
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val bitmojiUrl = (friend.selfieId to friend.bitmojiId).let { (selfieId, bitmojiId) ->
if (selfieId == null || bitmojiId == null) return@let null
BitmojiSelfie.getBitmojiSelfie(
selfieId,
bitmojiId,
BitmojiSelfie.BitmojiSelfieType.THREE_D
)
}
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(
friend.selfieId,
friend.bitmojiId,
BitmojiSelfie.BitmojiSelfieType.THREE_D
)
BitmojiImage(context = context, url = bitmojiUrl, size = 100)
Spacer(modifier = Modifier.height(16.dp))
Text(
@ -218,13 +215,13 @@ class ScopeContent(
Column(
modifier = Modifier.weight(1f),
) {
Text(text = "Count: ${streaks.length}", maxLines = 1)
Text(text = "Length: ${streaks.length}", maxLines = 1)
Text(text = "Expires in: ${computeStreakETA(streaks.expirationTimestamp)}", maxLines = 1)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Notify Expiration", maxLines = 1)
Text(text = "Reminder", maxLines = 1, modifier = Modifier.padding(end = 10.dp))
Switch(checked = shouldNotify, onCheckedChange = {
context.modDatabase.setFriendStreaksNotify(id, it)
shouldNotify = it

View File

@ -18,7 +18,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -36,6 +35,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -45,6 +46,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.messaging.SocialScope
@ -175,24 +177,41 @@ class SocialSection : Section() {
}
SocialScope.FRIEND -> {
val friend = friendList[index]
val streaks = remember { context.modDatabase.getFriendStreaks(friend.userId) }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
val bitmojiUrl = (friend.selfieId to friend.bitmojiId).let { (selfieId, bitmojiId) ->
if (selfieId == null || bitmojiId == null) return@let null
BitmojiSelfie.getBitmojiSelfie(selfieId, bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D)
}
BitmojiImage(context = context, url = bitmojiUrl)
BitmojiImage(
context = context,
url = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D)
)
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
.weight(1f)
) {
Text(text = friend.displayName ?: friend.mutableUsername, maxLines = 1, fontWeight = FontWeight.Bold)
Text(text = friend.userId, maxLines = 1, fontSize = 12.sp)
Text(text = friend.mutableUsername, maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (streaks != null && streaks.notify) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon),
contentDescription = null,
modifier = Modifier.height(40.dp),
tint = if (streaks.isAboutToExpire())
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
)
Text(text = "${streaks.hoursLeft()}h", maxLines = 1, fontWeight = FontWeight.Bold)
}
}
}
}

View File

@ -28,9 +28,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.core.config.DataProcessors
import me.rhunk.snapenhance.core.config.PropertyPair
@ -57,21 +59,22 @@ class AlertDialogs(
@Composable
fun ConfirmDialog(
title: String,
data: String? = null,
message: String? = null,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
DefaultDialogCard {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 10.dp)
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 5.dp, bottom = 10.dp)
)
if (data != null) {
if (message != null) {
Text(
text = data,
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 10.dp)
modifier = Modifier.padding(bottom = 15.dp)
)
}
Row(
@ -91,20 +94,21 @@ class AlertDialogs(
@Composable
fun InfoDialog(
title: String,
data: String? = null,
message: String? = null,
onDismiss: () -> Unit,
) {
DefaultDialogCard {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 10.dp)
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 5.dp, bottom = 10.dp)
)
if (data != null) {
if (message != null) {
Text(
text = data,
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 10.dp)
modifier = Modifier.padding(bottom = 15.dp)
)
}
Row(

View File

@ -36,18 +36,23 @@ fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size
)
}
fun ImageRequest.Builder.cacheKey(key: String?) = apply {
memoryCacheKey(key)
diskCacheKey(key)
}
object ImageRequestHelper {
fun newBitmojiImageRequest(context: Context, url: String?) = ImageRequest.Builder(context)
.data(url)
.fallback(R.drawable.bitmoji_blank)
.precision(Precision.INEXACT)
.crossfade(true)
.memoryCacheKey(url)
.cacheKey(url)
.build()
fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context)
.data(filePath)
.memoryCacheKey(filePath)
.cacheKey(filePath)
.crossfade(true)
.build()
}

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M224.78,560q0,59.18 26.11,111.45 26.11,52.27 73.28,87.6 -4,-10.99 -6,-22.68 -2,-11.69 -2,-22.97 1.2,-30.8 13.31,-57.61 12.12,-26.8 34.4,-49.09L480,492.35l116.35,114.35q22.28,22.28 34.4,49.09 12.12,26.81 13.08,57.61 0,11.28 -2,22.97 -2,11.69 -5.76,22.68 46.7,-35.33 73.04,-87.6Q735.46,619.18 735.46,560q0,-52.09 -22.07,-102.06 -22.07,-49.97 -63.35,-91.96 -21,14.28 -43.84,22.42 -22.84,8.14 -45.21,8.14 -60.27,0 -100.63,-39.95Q420,316.65 417.37,254.61v-20q-44.36,32.26 -79.91,71.32 -35.55,39.05 -60.59,81.36 -25.04,42.3 -38.57,86.61 -13.52,44.31 -13.52,86.11ZM480,587.83l-68.31,67.56q-13.82,13.57 -20.96,29.92 -7.14,16.34 -7.14,35.64 0,39.5 28.09,66.89 28.09,27.38 68.37,27.38 40.28,0 68.33,-27.44 28.04,-27.44 28.04,-66.97 0,-19.05 -7.13,-35.4 -7.13,-16.35 -20.63,-29.93L480,587.83ZM483.59,114.26L483.59,252q0,32.48 22.45,54.44 22.45,21.97 54.94,21.97 17.21,0 32.04,-7.14 14.83,-7.14 26.35,-21.66l19.67,-24.39q76.21,43.41 120.38,119.74 44.17,76.33 44.17,164.97 0,135.53 -94.05,229.59 -94.05,94.06 -229.56,94.06 -135.51,0 -229.54,-94.04 -94.03,-94.04 -94.03,-229.55 0,-129.19 87.79,-249.61Q332,189.98 483.59,114.26Z"
tools:ignore="VectorPath" />
</vector>

View File

@ -309,6 +309,16 @@
}
}
},
"streaks_reminder": {
"name": "Streaks Reminder",
"description": "Reminds you to keep your streaks",
"properties": {
"interval": {
"name": "Interval",
"description": "The interval between each reminder (in hours)"
}
}
},
"experimental": {
"name": "Experimental",
"description": "Experimental features",

View File

@ -35,14 +35,14 @@ class BridgeClient(
fun start(callback: (Boolean) -> Unit) {
this.future = CompletableFuture()
//TODO: randomize package name
with(context.androidContext) {
//ensure the remote process is running
startActivity(Intent()
.setClassName(BuildConfig.APPLICATION_ID, ForceStartActivity::class.java.name)
.setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.bridge.ForceStartActivity")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
)
//TODO: randomize package name
val intent = Intent()
.setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.bridge.BridgeService")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

View File

@ -1,11 +0,0 @@
package me.rhunk.snapenhance.bridge
import android.app.Activity
import android.os.Bundle
class ForceStartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
finish()
}
}

View File

@ -6,10 +6,11 @@ import kotlin.reflect.KProperty
typealias ConfigParamsBuilder = ConfigParams.() -> Unit
open class ConfigContainer(
var globalState: Boolean? = null
val hasGlobalState: Boolean = false
) {
var parentContainerKey: PropertyKey<*>? = null
val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>()
var globalState: Boolean? = null
private inline fun <T> registerProperty(
key: String,

View File

@ -13,6 +13,8 @@ class Global : ConfigContainer() {
val disableGooglePlayDialogs = boolean("disable_google_play_dialogs")
val forceMediaSourceQuality = boolean("force_media_source_quality")
val betterNotifications = multiple("better_notifications", "snap", "chat", "reply_button", "download_button")
val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray())
val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) {
customOptionTranslationPath = "features.options.notifications"
}
val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR) }
}

View File

@ -16,7 +16,9 @@ class MessagingTweaks : ConfigContainer() {
"EXTERNAL_MEDIA",
"STICKER"
)
val preventMessageSending = multiple("prevent_message_sending", *NotificationType.getOutgoingValues().map { it.key }.toTypedArray())
val preventMessageSending = multiple("prevent_message_sending", *NotificationType.getOutgoingValues().map { it.key }.toTypedArray()) {
customOptionTranslationPath = "features.options.notifications"
}
val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) }
val galleryMediaSendOverride = boolean("gallery_media_send_override")
val messagePreviewLength = integer("message_preview_length", defaultValue = 20)

View File

@ -9,6 +9,7 @@ class RootConfig : ConfigContainer() {
val global = container("global", Global()) { icon = "MiscellaneousServices" }
val rules = container("rules", Rules()) { icon = "Rule" }
val camera = container("camera", Camera()) { icon = "Camera"}
val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" }
val experimental = container("experimental", Experimental()) { icon = "Science" }
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" }
}

View File

@ -3,13 +3,13 @@ package me.rhunk.snapenhance.core.config.impl
import me.rhunk.snapenhance.core.config.ConfigContainer
class Spoof : ConfigContainer() {
inner class Location : ConfigContainer(globalState = false) {
inner class Location : ConfigContainer(hasGlobalState = true) {
val latitude = float("location_latitude")
val longitude = float("location_longitude")
}
val location = container("location", Location())
inner class Device : ConfigContainer(globalState = false) {
inner class Device : ConfigContainer(hasGlobalState = true) {
val fingerprint = string("device_fingerprint")
val androidId = string("device_android_id")
}

View File

@ -0,0 +1,7 @@
package me.rhunk.snapenhance.core.config.impl
import me.rhunk.snapenhance.core.config.ConfigContainer
class StreaksReminderConfig : ConfigContainer(hasGlobalState = true) {
val interval = integer("interval", 2)
}

View File

@ -1,6 +1,7 @@
package me.rhunk.snapenhance.core.messaging
import me.rhunk.snapenhance.util.SerializableDataObject
import kotlin.time.Duration.Companion.hours
enum class RuleState(
@ -45,8 +46,16 @@ data class FriendStreaks(
val notify: Boolean,
val expirationTimestamp: Long,
val length: Int
) : SerializableDataObject()
) : SerializableDataObject() {
companion object {
//TODO: config
val EXPIRE_THRESHOLD = 12.hours
}
fun hoursLeft() = (expirationTimestamp - System.currentTimeMillis()) / 1000 / 60 / 60
fun isAboutToExpire() = expirationTimestamp - System.currentTimeMillis() < EXPIRE_THRESHOLD.inWholeMilliseconds
}
data class MessagingGroupInfo(
val conversationId: String,

View File

@ -61,11 +61,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
): DownloadManagerClient {
val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "")
val iconUrl = friendInfo?.takeIf {
it.bitmojiAvatarId != null && it.bitmojiSelfieId != null
}?.let {
BitmojiSelfie.getBitmojiSelfie(it.bitmojiSelfieId!!, it.bitmojiAvatarId!!, BitmojiSelfie.BitmojiSelfieType.THREE_D)
}
val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.THREE_D)
val downloadLogging by context.config.downloader.logging
if (downloadLogging.contains("started")) {

View File

@ -50,7 +50,7 @@ object ViewAppearanceHelper {
}
}
val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", "com.snapchat.android")
val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", Constants.SNAPCHAT_PACKAGE_NAME)
val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400
with(component) {

View File

@ -53,7 +53,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
profile.bitmojiSelfieId.toString(),
profile.bitmojiAvatarId.toString(),
BitmojiSelfie.BitmojiSelfieType.THREE_D
)
)!!
)
}
} catch (e: Throwable) {

View File

@ -6,7 +6,10 @@ object BitmojiSelfie {
THREE_D
}
fun getBitmojiSelfie(selfieId: String, avatarId: String, type: BitmojiSelfieType): String {
fun getBitmojiSelfie(selfieId: String?, avatarId: String?, type: BitmojiSelfieType): String? {
if (selfieId.isNullOrEmpty() || avatarId.isNullOrEmpty()) {
return null
}
return when (type) {
BitmojiSelfieType.STANDARD -> "https://sdk.bitmoji.com/render/panel/$selfieId-$avatarId-v1.webp?transparent=1"
BitmojiSelfieType.THREE_D -> "https://images.bitmoji.com/3d/render/$selfieId-$avatarId-v1.webp?trim=circle"