diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index d3637f4b8..8554236e2 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -11,8 +11,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
+ - name: Checkout repo
+ uses: actions/checkout@v3
+ with:
+ submodules: 'recursive'
+
- - uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml
new file mode 100644
index 000000000..fa5aae0dc
--- /dev/null
+++ b/.github/workflows/beta.yml
@@ -0,0 +1,89 @@
+name: Beta CI
+on:
+ workflow_dispatch:
+ inputs:
+ ci_upload:
+ description: 'Upload to CI channel'
+ required: false
+ type: boolean
+
+jobs:
+ build:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v3
+ with:
+ submodules: 'recursive'
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Clean Gradle Cache
+ run: ./gradlew clean
+
+ - name: Build all
+ run: ./gradlew assembleDebug
+
+ - name: Build Version
+ run: ./gradlew getVersion
+
+ - name: Set Environment Variables
+ id: version-env
+ run: |
+ echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
+ echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+ - name: Git branch name
+ id: git-branch-name
+ uses: EthanSK/git-branch-name-action@v1
+
+ - name: Rename APK files
+ run: |
+ mv app/build/outputs/apk/armv8/debug/*.apk app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${{ steps.version-env.outputs.sha_short }}.apk
+ mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${{ steps.version-env.outputs.sha_short }}.apk
+ mv app/build/outputs/apk/all/debug/*.apk app/build/outputs/apk/all/debug/snapenhance-${{ env.version }}-universal-${{ steps.version-env.outputs.sha_short }}.apk
+
+ - name: Upload manager
+ uses: actions/upload-artifact@v3.1.2
+ with:
+ name: manager
+ path: manager/build/outputs/apk/debug/*.apk
+
+ - name: Upload core
+ uses: actions/upload-artifact@v3.1.2
+ with:
+ name: core
+ path: app/build/outputs/apk/core/debug/*.apk
+
+ - name: Upload armv8
+ uses: actions/upload-artifact@v3.1.2
+ with:
+ name: snapenhance-armv8-debug
+ path: app/build/outputs/apk/armv8/debug/*
+
+ - name: Upload armv7
+ uses: actions/upload-artifact@v3.1.2
+ with:
+ name: snapenhance-armv7-debug
+ path: app/build/outputs/apk/armv7/debug/*
+
+ - name: Upload universal
+ uses: actions/upload-artifact@v3.1.2
+ with:
+ name: snapenhance-universal-debug
+ path: app/build/outputs/apk/all/debug/*.apk
+
+ - name: CI Upload armv8
+ if: ${{ inputs.ci_upload }}
+ run: node ./.github/workflows/upload.js -t "${{ secrets.TELEGRAM_BOT_TOKEN }}" -f "app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${{ steps.version-env.outputs.sha_short }}.apk" --caption "A new commit has been pushed to the ${{ env.GIT_BRANCH_NAME }} branch! ${{ steps.version-env.outputs.sha_short }}" --chatid "${{ secrets.TELEGRAM_CHAT_ID }}"
+
+ - name: CI Upload armv7
+ if: ${{ inputs.ci_upload }}
+ run: node ./.github/workflows/upload.js -t "${{ secrets.TELEGRAM_BOT_TOKEN }}" -f "app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${{ steps.version-env.outputs.sha_short }}.apk" --chatid "${{ secrets.TELEGRAM_CHAT_ID }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a13d9bacb..22f2ea1bb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,7 +5,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - name: Checkout repo
+ uses: actions/checkout@v3
+ with:
+ submodules: 'recursive'
- name: set up JDK 17
uses: actions/setup-java@v3
diff --git a/README.md b/README.md
index e5f20d7c6..70745f035 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,6 @@ SnapEnhance is an Xposed mod that enhances your Snapchat experience.
Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues, we encourage you to report them. To do this simply visit our [issues](https://github.com/rhunk/SnapEnhance/issues) page and create an issue, make sure to follow the guidelines.
-## Download
-To Download the latest stable release, please visit the [Releases](https://github.com/rhunk/SnapEnhance/releases) page.
-You can also download the latest debug build from the [Actions](https://github.com/rhunk/SnapEnhance/actions) section.
-We no longer offer official LSPatch binaries for obvious reasons. However, you're welcome to patch them yourself, as they should theoretically work without any issues.
-
## Quick Start
Requirements:
- Rooted using Magisk or KernelSU
@@ -25,6 +20,11 @@ Although using this in an unrooted enviroment using something like LSPatch shoul
3. Force Stop Snapchat
4. Open the menu by clicking the [Settings Gear Icon](https://i.imgur.com/2grm8li.png)
+## Download
+To Download the latest stable release, please visit the [Releases](https://github.com/rhunk/SnapEnhance/releases) page.
+You can also download the latest debug build from the [Actions](https://github.com/rhunk/SnapEnhance/actions) section.
+We no longer offer official LSPatch binaries for obvious reasons. However, you're welcome to patch them yourself, as they should theoretically work without any issues.
+
## Features
Spying & Privacy
@@ -57,7 +57,7 @@ Although using this in an unrooted enviroment using something like LSPatch shoul
UI & Tweaks
- Disable Camera
- - Immersive Camera Preview (Fix Snapchat's camera bug)
+ - Immersive Camera Preview (Fix Snapchats camera bug)
- Hide certain UI Elements
- Show Streak Expiration Info
- Disable Snap Splitting
@@ -82,22 +82,102 @@ Although using this in an unrooted enviroment using something like LSPatch shoul
- Chat Export (HTML, JSON and TXT)
-## License
-The [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license is a free, open-source software license that grants users the right to modify, share, and redistribute the software.
-By using this software, you agree to make the source code freely available, along with any modifications, additions, or derivatives.
-When redistributing the software, it must remain under the same [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license, and any modifications should be clearly indicated as such.
+## FAQ
+
+ AI wallpapers and the Snapchat+ badge aren't working!
+
+ - Yeah, they're server-sided and will probably never work.
+
-## Donate
-- LTC: LbBnT9GxgnFhwy891EdDKqGmpn7XtduBdE
-- BCH: qpu57a05kqljjadvpgjc6t894apprvth9slvlj4vpj
-- BTC: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92
-- ETH: 0x0760987491e9de53A73fd87F092Bd432a227Ee92
+
+ Can you add this feature, please?
+
+ - Open an issue on our Github repo.
+
+
+
+ When will this feature become available or finish?
+
+ - At some point.
+
+
+
+ Can I get banned with this?
+
+ - Obviously, however, the risk is very low, and we have no reported cases of anyone ever getting banned while using the mod.
+
+
+
+ Can I PM the developers?
+
+ - No.
+
+
+
+ This doesn't work!
+
+ - Open an issue.
+
+
+
+ My phone isn't rooted; how do I use this?
+
+ - You can use LSPatch in combination with SnapEnhance to run this on an unrooted device, however this is unrecommended and not considered safe.
+
+
+
+ Where can I download the latest stable build?
+
+ - https://github.com/rhunk/snapenhance/releases
+
+
+
+ Can I use HideMyApplist with this?
+
+ - No, this will cause some severe issues, and the mod will not be able to inject.
+
+
+## Privacy
+We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies.
+
+ Permissions
+
+ - [android.permission.INTERNET](https://developer.android.com/reference/android/Manifest.permission#INTERNET)
+ - [android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS](https://developer.android.com/reference/android/Manifest.permission.html#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
+ - [android.permission.POST_NOTIFICATIONS](https://developer.android.com/reference/android/Manifest.permission.html#POST_NOTIFICATIONS)
+ - [android.permission.SYSTEM_ALERT_WINDOW](https://developer.android.com/reference/android/Manifest.permission#SYSTEM_ALERT_WINDOW)
+
+
+
+ Third-party libraries used
+
+ - [libxposed](https://github.com/libxposed/api)
+ - [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit)
+ - [osmdroid](https://github.com/osmdroid/osmdroid)
+ - [coil](https://github.com/coil-kt/coil)
+ - [Dobby](https://github.com/jmpews/Dobby)
+ - [rhino](https://github.com/mozilla/rhino)
+ - [libsu](https://github.com/topjohnwu/libsu)
+
## Contributors
+Thanks to everyone involved including the [third-party libraries](https://github.com/rhunk/SnapEnhance?tab=readme-ov-file#privacy) used!
- [rathmerdominik](https://github.com/rathmerdominik)
- [Flole998](https://github.com/Flole998)
- [authorisation](https://github.com/authorisation/)
- [RevealedSoulEven](https://github.com/revealedsouleven)
- [iBasim](https://github.com/ibasim)
- [xerta555](https://github.com/xerta555)
-- [TheVisual](https://github.com/TheVisual)
+- [TheVisual](https://github.com/TheVisual)
+
+
+## Donate
+- LTC: LbBnT9GxgnFhwy891EdDKqGmpn7XtduBdE
+- BCH: qpu57a05kqljjadvpgjc6t894apprvth9slvlj4vpj
+- BTC: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92
+- ETH: 0x0760987491e9de53A73fd87F092Bd432a227Ee92
+
+## License
+The [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license is a free, open-source software license that grants users the right to modify, share, and redistribute the software.
+By using this software, you agree to make the source code freely available, along with any modifications, additions, or derivatives.
+When redistributing the software, it must remain under the same [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license, and any modifications should be clearly indicated as such.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 43b8b847a..b5f316c97 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,6 +1,6 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
+import org.gradle.configurationcache.extensions.capitalized
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
@@ -16,11 +16,13 @@ android {
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.4.8"
+ kotlinCompilerExtensionVersion = "1.5.2"
}
defaultConfig {
applicationId = rootProject.ext["applicationId"].toString()
+ versionCode = rootProject.ext["appVersionCode"].toString().toInt()
+ versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
targetSdk = 34
multiDexEnabled = true
@@ -32,8 +34,12 @@ android {
proguardFiles += file("proguard-rules.pro")
}
debug {
- isDebuggable = true
- isMinifyEnabled = false
+ (properties["debug_flavor"] == null).also {
+ isDebuggable = !it
+ isMinifyEnabled = it
+ isShrinkResources = it
+ }
+ proguardFiles += file("proguard-rules.pro")
}
}
@@ -54,6 +60,11 @@ android {
excludes += "META-INF/*.kotlin_module"
}
}
+
+ create("core") {
+ dimension = "abi"
+ }
+
create("armv8") {
ndk {
abiFilters += "arm64-v8a"
@@ -67,15 +78,25 @@ android {
}
dimension = "abi"
}
+
+ create("all") {
+ ndk {
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a")
+ }
+ dimension = "abi"
+ }
}
properties["debug_flavor"]?.let {
- android.productFlavors[it.toString()].setIsDefault(true)
+ android.productFlavors.find { it.name == it.toString()}?.setIsDefault(true)
}
applicationVariants.all {
- outputs.map { it as BaseVariantOutputImpl }.forEach { variant ->
- variant.outputFileName = "app-${rootProject.ext["appVersionName"]}-${variant.name}.apk"
+ outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant ->
+ outputVariant.outputFileName = when {
+ name.startsWith("core") -> "core.apk"
+ else -> "snapenhance_${rootProject.ext["appVersionName"]}-${outputVariant.name}.apk"
+ }
}
}
@@ -89,37 +110,55 @@ android {
}
}
+androidComponents {
+ onVariants(selector().withFlavor("abi", "core")) {
+ it.packaging.jniLibs.apply {
+ pickFirsts.set(listOf("**/lib${rootProject.ext["buildHash"]}.so"))
+ excludes.set(listOf("**/*.so"))
+ }
+ }
+}
+
dependencies {
+ fun fullImplementation(dependencyNotation: Any) {
+ compileOnly(dependencyNotation)
+ for (flavorName in listOf("armv8", "armv7", "all")) {
+ dependencies.add("${flavorName}Implementation", dependencyNotation)
+ }
+ }
+
implementation(project(":core"))
- implementation(libs.androidx.material.icons.core)
- implementation(libs.androidx.material.ripple)
- implementation(libs.androidx.material.icons.extended)
- implementation(libs.androidx.material3)
- implementation(libs.androidx.activity.ktx)
- implementation(libs.androidx.navigation.compose)
+ implementation(project(":common"))
implementation(libs.androidx.documentfile)
implementation(libs.gson)
- implementation(libs.coil.compose)
- implementation(libs.coil.video)
+ implementation(libs.ffmpeg.kit)
implementation(libs.osmdroid.android)
-
- debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
- implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
- implementation(kotlin("reflect"))
+ implementation(libs.rhino)
+ implementation(libs.androidx.activity.ktx)
+ fullImplementation(platform(libs.androidx.compose.bom))
+ fullImplementation(libs.bcprov.jdk18on)
+ fullImplementation(libs.androidx.navigation.compose)
+ fullImplementation(libs.androidx.material.icons.core)
+ fullImplementation(libs.androidx.material.ripple)
+ fullImplementation(libs.androidx.material.icons.extended)
+ fullImplementation(libs.androidx.material3)
+ fullImplementation(libs.coil.compose)
+ fullImplementation(libs.coil.video)
+ fullImplementation(libs.androidx.ui.tooling.preview)
+ properties["debug_flavor"]?.let {
+ debugImplementation(libs.androidx.ui.tooling)
+ }
}
afterEvaluate {
- properties["debug_assemble_task"]?.let { tasks.named(it.toString()) }?.orNull?.doLast {
+ properties["debug_flavor"]?.toString()?.let { tasks.findByName("install${it.capitalized()}Debug") }?.doLast {
runCatching {
- val apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == properties["debug_flavor"] }?.outputs?.first()?.outputFile ?: return@doLast
- exec {
- commandLine("adb", "shell", "am", "force-stop", "com.snapchat.android")
- }
exec {
- commandLine("adb", "install", "-r", "-d", apkDebugFile.absolutePath)
+ commandLine("adb", "shell", "am", "force-stop", properties["debug_package_name"])
}
+ Thread.sleep(1000L)
exec {
- commandLine("adb", "shell", "am", "start", "com.snapchat.android")
+ commandLine("adb", "shell", "am", "start", properties["debug_package_name"])
}
}
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 09acd6cf7..7f04e727a 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,2 +1,10 @@
-dontwarn de.robv.android.xposed.**
--keep class me.rhunk.** { *; }
\ No newline at end of file
+-dontwarn org.mozilla.javascript.**
+
+-keep enum * { *; }
+
+-keep class org.jf.dexlib2.** { *; }
+-keep class org.mozilla.javascript.** { *; }
+-keep class androidx.compose.material.icons.** { *; }
+-keep class androidx.navigation.** { *; }
+-keep class me.rhunk.snapenhance.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c4cb17857..91ed83d4f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,9 +4,8 @@
-
-
+
@@ -14,7 +13,6 @@
+ android:value="SnapEnhance by rhunk" />
+ android:value="com.snapchat.android" />
-
()
+ var lineCount = queryLineCount()
+
+ private fun readLogLine(): LogLine? {
+ val lines = StringBuilder()
+ val lastPointer = randomAccessFile.filePointer
+ var lastChar: Int = -1
+ var bufferLength = 0
+ while (true) {
+ val char = randomAccessFile.read()
+ if (char == -1) {
+ randomAccessFile.seek(lastPointer)
+ return null
+ }
+ if ((char == '|'.code && lastChar == '\n'.code) || bufferLength > 4096) {
+ break
+ }
+ lines.append(char.toChar())
+ bufferLength++
+ lastChar = char
+ }
+
+ return LogLine.fromString(lines.trimEnd().toString())
+ ?: LogLine(LogLevel.ERROR, "1970-01-01 00:00:00", "LogReader", "Failed to parse log line: $lines")
+ }
+
+ fun incrementLineCount() {
+ randomAccessFile.seek(randomAccessFile.length())
+ startLineIndexes.add(randomAccessFile.filePointer)
+ lineCount++
+ }
+
+ private fun queryLineCount(): Int {
+ randomAccessFile.seek(0)
+ var lines = 0
+ var lastIndex: Long
+ while (true) {
+ lastIndex = randomAccessFile.filePointer
+ readLogLine() ?: break
+ startLineIndexes.add(lastIndex)
+ lines++
+ }
+ return lines
+ }
+
+ private fun getLine(index: Int): String? {
+ if (index <= 0 || index > lineCount) return null
+ randomAccessFile.seek(startLineIndexes[index])
+ return readLogLine()?.toString()
+ }
+
+ fun getLogLine(index: Int): LogLine? {
+ return getLine(index)?.let { LogLine.fromString(it) }
+ }
+}
+
+
+class LogManager(
+ private val remoteSideContext: RemoteSideContext
+): AbstractLogger(LogChannel.MANAGER) {
+ companion object {
+ private const val TAG = "SnapEnhanceManager"
+ private val LOG_LIFETIME = 24.hours
+ }
+
+ private val anonymizeLogs by lazy { !remoteSideContext.config.root.scripting.disableLogAnonymization.get() }
+
+ var lineAddListener = { _: LogLine -> }
+
+ private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs")
+ private var logFile: File
+
+ init {
+ if (!logFolder.exists()) {
+ logFolder.mkdirs()
+ }
+ logFile = remoteSideContext.sharedPreferences.getString("log_file", null)?.let { File(it) }?.takeIf { it.exists() } ?: run {
+ newLogFile()
+ logFile
+ }
+
+ if (System.currentTimeMillis() - remoteSideContext.sharedPreferences.getLong("last_created", 0) > LOG_LIFETIME.inWholeMilliseconds) {
+ newLogFile()
+ }
+ }
+
+ private fun getCurrentDateTime(pathSafe: Boolean = false): String {
+ return DateTimeFormatter.ofPattern(if (pathSafe) "yyyy-MM-dd_HH-mm-ss" else "yyyy-MM-dd HH:mm:ss").format(
+ java.time.LocalDateTime.now()
+ )
+ }
+
+ private fun newLogFile() {
+ val currentTime = System.currentTimeMillis()
+ logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also {
+ it.createNewFile()
+ }
+ remoteSideContext.sharedPreferences.edit().putString("log_file", logFile.absolutePath).putLong("last_created", currentTime).apply()
+ }
+
+ fun clearLogs() {
+ logFolder.listFiles()?.forEach { it.delete() }
+ newLogFile()
+ }
+
+ fun exportLogsToZip(outputStream: OutputStream) {
+ val zipOutputStream = ZipOutputStream(outputStream)
+ //add logFolder to zip
+ logFolder.walk().forEach {
+ if (it.isFile) {
+ zipOutputStream.putNextEntry(ZipEntry(it.name))
+ it.inputStream().copyTo(zipOutputStream)
+ zipOutputStream.closeEntry()
+ }
+ }
+
+ //add device info to zip
+ zipOutputStream.putNextEntry(ZipEntry("device_info.json"))
+ val gson = GsonBuilder().setPrettyPrinting().create()
+ zipOutputStream.write(gson.toJson(remoteSideContext.installationSummary).toByteArray())
+ zipOutputStream.closeEntry()
+
+ zipOutputStream.close()
+ }
+
+ fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile).also {
+ lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) }
+ }
+
+ override fun debug(message: Any?, tag: String) {
+ internalLog(tag, LogLevel.DEBUG, message)
+ }
+
+ override fun error(message: Any?, tag: String) {
+ internalLog(tag, LogLevel.ERROR, message)
+ }
+
+ override fun error(message: Any?, throwable: Throwable, tag: String) {
+ internalLog(tag, LogLevel.ERROR, message)
+ internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString())
+ }
+
+ override fun info(message: Any?, tag: String) {
+ internalLog(tag, LogLevel.INFO, message)
+ }
+
+ override fun verbose(message: Any?, tag: String) {
+ internalLog(tag, LogLevel.VERBOSE, message)
+ }
+
+ override fun warn(message: Any?, tag: String) {
+ internalLog(tag, LogLevel.WARN, message)
+ }
+
+ override fun assert(message: Any?, tag: String) {
+ internalLog(tag, LogLevel.ASSERT, message)
+ }
+
+ fun internalLog(tag: String, logLevel: LogLevel, message: Any?) {
+ runCatching {
+ val line = LogLine(
+ logLevel = logLevel,
+ dateTime = getCurrentDateTime(),
+ tag = tag,
+ message = message.toString().let {
+ if (remoteSideContext.config.isInitialized() && anonymizeLogs)
+ it.replace(Regex("[0-9a-f]{8}-[0-9a-f]{4}-{3}[0-9a-f]{12}", RegexOption.MULTILINE), "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
+ else it
+ }
+ )
+ logFile.appendText("|$line\n", Charsets.UTF_8)
+ lineAddListener(line)
+ Log.println(logLevel.priority, tag, message.toString())
+ }.onFailure {
+ Log.println(Log.ERROR, tag, "Failed to log message: $message")
+ Log.println(Log.ERROR, tag, it.stackTraceToString())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
index a523196b3..cb4eaee43 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
@@ -3,45 +3,73 @@ package me.rhunk.snapenhance
import android.app.Activity
import android.content.Context
import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
import android.net.Uri
+import android.os.Build
import android.widget.Toast
import androidx.activity.ComponentActivity
+import androidx.core.app.CoreComponentFactory
import androidx.documentfile.provider.DocumentFile
import coil.ImageLoader
import coil.decode.VideoFrameDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import me.rhunk.snapenhance.bridge.BridgeService
-import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
-import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper
-import me.rhunk.snapenhance.core.config.ModConfig
-import me.rhunk.snapenhance.download.DownloadTaskManager
+import me.rhunk.snapenhance.common.BuildConfig
+import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
+import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
+import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
+import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper
+import me.rhunk.snapenhance.common.config.ModConfig
+import me.rhunk.snapenhance.e2ee.E2EEImplementation
import me.rhunk.snapenhance.messaging.ModDatabase
import me.rhunk.snapenhance.messaging.StreaksReminder
+import me.rhunk.snapenhance.scripting.RemoteScriptManager
+import me.rhunk.snapenhance.task.TaskManager
+import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
-import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo
+import me.rhunk.snapenhance.ui.manager.data.ModInfo
+import me.rhunk.snapenhance.ui.manager.data.PlatformInfo
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
+import me.rhunk.snapenhance.ui.overlay.SettingsOverlay
import me.rhunk.snapenhance.ui.setup.Requirements
import me.rhunk.snapenhance.ui.setup.SetupActivity
+import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import kotlin.time.Duration.Companion.days
+
class RemoteSideContext(
val androidContext: Context
) {
+ val coroutineScope = CoroutineScope(Dispatchers.IO)
+
private var _activity: WeakReference? = null
- lateinit var bridgeService: BridgeService
+ var bridgeService: BridgeService? = null
var activity: ComponentActivity?
get() = _activity?.get()
set(value) { _activity?.clear(); _activity = WeakReference(value) }
- val config = ModConfig()
+ val sharedPreferences: SharedPreferences get() = androidContext.getSharedPreferences("prefs", 0)
+ val config = ModConfig(androidContext)
val translation = LocaleWrapper()
val mappings = MappingsWrapper()
- val downloadTaskManager = DownloadTaskManager()
+ val taskManager = TaskManager(this)
val modDatabase = ModDatabase(this)
val streaksReminder = StreaksReminder(this)
+ val log = LogManager(this)
+ val scriptManager = RemoteScriptManager(this)
+ val settingsOverlay = SettingsOverlay(this)
+ val e2eeImplementation = E2EEImplementation(this)
+ val messageLogger by lazy { MessageLoggerWrapper(androidContext.getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)) }
//used to load bitmoji selfies and download previews
val imageLoader by lazy {
@@ -61,7 +89,10 @@ class RemoteSideContext(
.components { add(VideoFrameDecoder.Factory()) }.build()
}
+ val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() }
+
fun reload() {
+ log.verbose("Loading RemoteSideContext")
runCatching {
config.loadFromContext(androidContext)
translation.apply {
@@ -72,46 +103,80 @@ class RemoteSideContext(
loadFromContext(androidContext)
init(androidContext)
}
- downloadTaskManager.init(androidContext)
+ taskManager.init()
modDatabase.init()
streaksReminder.init()
+ scriptManager.init()
+ messageLogger.init()
}.onFailure {
- Logger.error("Failed to load RemoteSideContext", it)
+ log.error("Failed to load RemoteSideContext", it)
+ }
+
+ scriptManager.runtime.eachModule {
+ callFunction("module.onManagerLoad", androidContext)
}
}
- fun getInstallationSummary() = InstallationSummary(
- snapchatInfo = mappings.getSnapchatPackageInfo()?.let {
- SnapchatAppInfo(
- version = it.versionName,
- versionCode = it.longVersionCode
- )
- },
- mappingsInfo = if (mappings.isMappingsLoaded()) {
- ModMappingsInfo(
- generatedSnapchatVersion = mappings.getGeneratedBuildNumber(),
- isOutdated = mappings.isMappingsOutdated()
+ val installationSummary by lazy {
+ InstallationSummary(
+ snapchatInfo = mappings.getSnapchatPackageInfo()?.let {
+ SnapchatAppInfo(
+ packageName = it.packageName,
+ version = it.versionName,
+ versionCode = it.longVersionCode,
+ isLSPatched = it.applicationInfo.appComponentFactory != CoreComponentFactory::class.java.name,
+ isSplitApk = it.splitNames?.isNotEmpty() ?: false
+ )
+ },
+ modInfo = ModInfo(
+ loaderPackageName = MainActivity::class.java.`package`?.name,
+ buildPackageName = androidContext.packageName,
+ buildVersion = BuildConfig.VERSION_NAME,
+ buildVersionCode = BuildConfig.VERSION_CODE.toLong(),
+ buildIssuer = androidContext.packageManager.getPackageInfo(androidContext.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ ?.signingInfo?.apkContentsSigners?.firstOrNull()?.let {
+ val certFactory = CertificateFactory.getInstance("X509")
+ val cert = certFactory.generateCertificate(ByteArrayInputStream(it.toByteArray())) as X509Certificate
+ cert.issuerDN.toString()
+ } ?: throw Exception("Failed to get certificate info"),
+ isDebugBuild = BuildConfig.DEBUG,
+ mappingVersion = mappings.getGeneratedBuildNumber(),
+ mappingsOutdated = mappings.isMappingsOutdated()
+ ),
+ platformInfo = PlatformInfo(
+ device = Build.DEVICE,
+ androidVersion = Build.VERSION.RELEASE,
+ systemAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
)
- } else null
- )
+ )
+ }
fun longToast(message: Any) {
androidContext.mainExecutor.execute {
Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show()
}
- Logger.debug(message.toString())
+ log.debug(message.toString())
}
fun shortToast(message: Any) {
androidContext.mainExecutor.execute {
Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show()
}
- Logger.debug(message.toString())
+ log.debug(message.toString())
}
+ fun hasMessagingBridge() = bridgeService != null && bridgeService?.messagingBridge != null
+
fun checkForRequirements(overrideRequirements: Int? = null): Boolean {
var requirements = overrideRequirements ?: 0
+ if(BuildConfig.DEBUG) {
+ if(System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP > 16.days.inWholeMilliseconds) {
+ Toast.makeText(androidContext, "This SnapEnhance build has expired. More info on t.me/snapenhance_ci", Toast.LENGTH_LONG).show();
+ throw RuntimeException("This build has expired. This crash is intentional.")
+ }
+ }
+
if (!config.wasPresent) {
requirements = requirements or Requirements.FIRST_RUN
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt
index 7da09d393..af6bf3a8c 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt
@@ -1,5 +1,6 @@
package me.rhunk.snapenhance
+import android.app.Activity
import android.content.Context
import java.lang.ref.WeakReference
@@ -8,7 +9,9 @@ object SharedContextHolder {
fun remote(context: Context): RemoteSideContext {
if (!::_remoteSideContext.isInitialized || _remoteSideContext.get() == null) {
- _remoteSideContext = WeakReference(RemoteSideContext(context))
+ _remoteSideContext = WeakReference(RemoteSideContext(context.let {
+ if (it is Activity) it.applicationContext else it
+ }))
_remoteSideContext.get()?.reload()
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
index 56cec538d..b7a9ab397 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
@@ -3,24 +3,32 @@ package me.rhunk.snapenhance.bridge
import android.app.Service
import android.content.Intent
import android.os.IBinder
-import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
-import me.rhunk.snapenhance.bridge.types.BridgeFileType
-import me.rhunk.snapenhance.bridge.types.FileActionType
-import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
-import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
-import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
-import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
-import me.rhunk.snapenhance.database.objects.FriendInfo
+import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
+import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
+import me.rhunk.snapenhance.common.bridge.types.FileActionType
+import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
+import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper
+import me.rhunk.snapenhance.common.data.MessagingFriendInfo
+import me.rhunk.snapenhance.common.data.MessagingGroupInfo
+import me.rhunk.snapenhance.common.data.SocialScope
+import me.rhunk.snapenhance.common.database.impl.FriendInfo
+import me.rhunk.snapenhance.common.logger.LogLevel
+import me.rhunk.snapenhance.common.util.SerializableDataObject
import me.rhunk.snapenhance.download.DownloadProcessor
-import me.rhunk.snapenhance.util.SerializableDataObject
import kotlin.system.measureTimeMillis
class BridgeService : Service() {
- private lateinit var messageLoggerWrapper: MessageLoggerWrapper
private lateinit var remoteSideContext: RemoteSideContext
lateinit var syncCallback: SyncCallback
+ var messagingBridge: MessagingBridge? = null
+
+ override fun onDestroy() {
+ if (::remoteSideContext.isInitialized) {
+ remoteSideContext.bridgeService = null
+ }
+ }
override fun onBind(intent: Intent): IBinder? {
remoteSideContext = SharedContextHolder.remote(this).apply {
@@ -29,39 +37,55 @@ class BridgeService : Service() {
remoteSideContext.apply {
bridgeService = this@BridgeService
}
- messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
return BridgeBinder()
}
- fun triggerFriendSync(friendId: String) {
- val syncedFriend = syncCallback.syncFriend(friendId)
- if (syncedFriend == null) {
- Logger.error("Failed to sync friend $friendId")
- return
- }
- SerializableDataObject.fromJson(syncedFriend).let {
- remoteSideContext.modDatabase.syncFriend(it)
- }
- }
+ fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
+ runCatching {
+ val modDatabase = remoteSideContext.modDatabase
+ val syncedObject = when (scope) {
+ SocialScope.FRIEND -> {
+ if (updateOnly && modDatabase.getFriendInfo(id) == null) return
+ syncCallback.syncFriend(id)
+ }
+ SocialScope.GROUP -> {
+ if (updateOnly && modDatabase.getGroupInfo(id) == null) return
+ syncCallback.syncGroup(id)
+ }
+ else -> null
+ }
- fun triggerGroupSync(groupId: String) {
- val syncedGroup = syncCallback.syncGroup(groupId)
- if (syncedGroup == null) {
- Logger.error("Failed to sync group $groupId")
- return
- }
- SerializableDataObject.fromJson(syncedGroup).let {
- remoteSideContext.modDatabase.syncGroupInfo(it)
+ if (syncedObject == null) {
+ remoteSideContext.log.error("Failed to sync $scope $id")
+ return
+ }
+
+ when (scope) {
+ SocialScope.FRIEND -> {
+ SerializableDataObject.fromJson(syncedObject).let {
+ modDatabase.syncFriend(it)
+ }
+ }
+ SocialScope.GROUP -> {
+ SerializableDataObject.fromJson(syncedObject).let {
+ modDatabase.syncGroupInfo(it)
+ }
+ }
+ }
+ }.onFailure {
+ remoteSideContext.log.error("Failed to sync $scope $id", it)
}
}
inner class BridgeBinder : BridgeInterface.Stub() {
+ override fun broadcastLog(tag: String, level: String, message: String) {
+ remoteSideContext.log.internalLog(tag, LogLevel.fromShortName(level) ?: LogLevel.INFO, message)
+ }
+
override fun fileOperation(action: Int, fileType: Int, content: ByteArray?): ByteArray {
- val resolvedFile by lazy {
- BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
- }
+ val resolvedFile = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
- return when (FileActionType.values()[action]) {
+ return when (FileActionType.entries[action]) {
FileActionType.CREATE_AND_READ -> {
resolvedFile?.let {
if (!it.exists()) {
@@ -95,21 +119,6 @@ class BridgeService : Service() {
}
}
- override fun getLoggedMessageIds(conversationId: String, limit: Int) =
- messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray()
-
- override fun getMessageLoggerMessage(conversationId: String, id: Long) =
- messageLoggerWrapper.getMessage(conversationId, id).second
-
- override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) {
- messageLoggerWrapper.addMessage(conversationId, id, message)
- }
-
- override fun deleteMessageLoggerMessage(conversationId: String, id: Long) =
- messageLoggerWrapper.deleteMessage(conversationId, id)
-
- override fun clearMessageLogger() = messageLoggerWrapper.clearMessages()
-
override fun getApplicationApkPath(): String = applicationInfo.publicSourceDir
override fun fetchLocales(userLocale: String) =
@@ -128,42 +137,70 @@ class BridgeService : Service() {
return remoteSideContext.modDatabase.getRules(uuid).map { it.key }
}
+ override fun getRuleIds(type: String): MutableList {
+ return remoteSideContext.modDatabase.getRuleIds(type)
+ }
+
override fun setRule(uuid: String, rule: String, state: Boolean) {
remoteSideContext.modDatabase.setRule(uuid, rule, state)
}
override fun sync(callback: SyncCallback) {
- Logger.debug("Syncing remote")
syncCallback = callback
measureTimeMillis {
remoteSideContext.modDatabase.getFriends().map { it.userId } .forEach { friendId ->
- runCatching {
- triggerFriendSync(friendId)
- }.onFailure {
- Logger.error("Failed to sync friend $friendId", it)
- }
+ triggerScopeSync(SocialScope.FRIEND, friendId, true)
}
remoteSideContext.modDatabase.getGroups().map { it.conversationId }.forEach { groupId ->
- runCatching {
- triggerGroupSync(groupId)
- }.onFailure {
- Logger.error("Failed to sync group $groupId", it)
- }
+ triggerScopeSync(SocialScope.GROUP, groupId, true)
}
}.also {
- Logger.debug("Syncing remote took $it ms")
+ remoteSideContext.log.verbose("Syncing remote took $it ms")
}
}
+ override fun triggerSync(scope: String, id: String) {
+ remoteSideContext.log.verbose("trigger sync for $scope $id")
+ triggerScopeSync(SocialScope.getByName(scope), id, true)
+ }
+
override fun passGroupsAndFriends(
groups: List,
friends: List
) {
- Logger.debug("Received ${groups.size} groups and ${friends.size} friends")
+ remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.modDatabase.receiveMessagingDataCallback(
friends.map { SerializableDataObject.fromJson(it) },
groups.map { SerializableDataObject.fromJson(it) }
)
}
+
+ override fun getScriptingInterface() = remoteSideContext.scriptManager
+
+ override fun getE2eeInterface() = remoteSideContext.e2eeImplementation
+ override fun getMessageLogger() = remoteSideContext.messageLogger
+ override fun registerMessagingBridge(bridge: MessagingBridge) {
+ messagingBridge = bridge
+ }
+
+ override fun openSettingsOverlay() {
+ runCatching {
+ remoteSideContext.settingsOverlay.show()
+ }.onFailure {
+ remoteSideContext.log.error("Failed to open settings overlay", it)
+ }
+ }
+
+ override fun closeSettingsOverlay() {
+ runCatching {
+ remoteSideContext.settingsOverlay.close()
+ }.onFailure {
+ remoteSideContext.log.error("Failed to close settings overlay", it)
+ }
+ }
+
+ override fun registerConfigStateListener(listener: ConfigStateListener) {
+ remoteSideContext.config.configStateListener = listener
+ }
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt
index ed99d3937..1aa4d3f03 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt
@@ -4,12 +4,13 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import me.rhunk.snapenhance.SharedContextHolder
+import me.rhunk.snapenhance.common.Constants
class ForceStartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.getBooleanExtra("streaks_notification_action", false)) {
- packageManager.getLaunchIntentForPackage("com.snapchat.android")?.apply {
+ packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(this)
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt
index cd937e471..17605a069 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt
@@ -8,36 +8,35 @@ import android.net.Uri
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import me.rhunk.snapenhance.Constants
-import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.DownloadCallback
-import me.rhunk.snapenhance.data.FileType
-import me.rhunk.snapenhance.download.data.DownloadMediaType
-import me.rhunk.snapenhance.download.data.DownloadMetadata
-import me.rhunk.snapenhance.download.data.DownloadObject
-import me.rhunk.snapenhance.download.data.DownloadRequest
-import me.rhunk.snapenhance.download.data.DownloadStage
-import me.rhunk.snapenhance.download.data.InputMedia
-import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair
-import me.rhunk.snapenhance.util.download.RemoteMediaResolver
-import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
+import me.rhunk.snapenhance.common.Constants
+import me.rhunk.snapenhance.common.ReceiversConfig
+import me.rhunk.snapenhance.common.data.FileType
+import me.rhunk.snapenhance.common.data.download.DownloadMediaType
+import me.rhunk.snapenhance.common.data.download.DownloadMetadata
+import me.rhunk.snapenhance.common.data.download.DownloadRequest
+import me.rhunk.snapenhance.common.data.download.InputMedia
+import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
+import me.rhunk.snapenhance.common.util.ktx.longHashCode
+import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
+import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
+import me.rhunk.snapenhance.task.PendingTask
+import me.rhunk.snapenhance.task.PendingTaskListener
+import me.rhunk.snapenhance.task.Task
+import me.rhunk.snapenhance.task.TaskStatus
+import me.rhunk.snapenhance.task.TaskType
import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
-import java.util.zip.ZipInputStream
-import javax.crypto.Cipher
-import javax.crypto.CipherInputStream
-import javax.crypto.spec.IvParameterSpec
-import javax.crypto.spec.SecretKeySpec
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
@@ -45,6 +44,7 @@ import javax.xml.transform.stream.StreamResult
import kotlin.coroutines.coroutineContext
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlin.math.absoluteValue
data class DownloadedFile(
val file: File,
@@ -64,9 +64,7 @@ class DownloadProcessor (
remoteSideContext.translation.getCategory("download_processor")
}
- private val gson by lazy {
- GsonBuilder().setPrettyPrinting().create()
- }
+ private val gson by lazy { GsonBuilder().setPrettyPrinting().create() }
private fun fallbackToast(message: Any) {
android.os.Handler(remoteSideContext.androidContext.mainLooper).post {
@@ -92,34 +90,16 @@ class DownloadProcessor (
fallbackToast(it)
}
- private fun extractZip(inputStream: InputStream): List {
- val files = mutableListOf()
- val zipInputStream = ZipInputStream(inputStream)
- var entry = zipInputStream.nextEntry
-
- while (entry != null) {
- createMediaTempFile().also { file ->
- file.outputStream().use { outputStream ->
- zipInputStream.copyTo(outputStream)
- }
- files += file
- }
- entry = zipInputStream.nextEntry
+ private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor(
+ logManager = remoteSideContext.log,
+ ffmpegOptions = remoteSideContext.config.root.downloader.ffmpegOptions,
+ onStatistics = {
+ pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})")
}
-
- return files
- }
-
- private fun decryptInputStream(inputStream: InputStream, encryption: MediaEncryptionKeyPair): InputStream {
- val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
- val key = Base64.UrlSafe.decode(encryption.key)
- val iv = Base64.UrlSafe.decode(encryption.iv)
- cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
- return CipherInputStream(inputStream, cipher)
- }
+ )
@SuppressLint("UnspecifiedRegisterReceiverFlag")
- private suspend fun saveMediaToGallery(inputFile: File, downloadObject: DownloadObject) {
+ private suspend fun saveMediaToGallery(pendingTask: PendingTask, inputFile: File, metadata: DownloadMetadata) {
if (coroutineContext.job.isCancelled) return
runCatching {
@@ -127,6 +107,7 @@ class DownloadProcessor (
if (fileType == FileType.UNKNOWN) {
callbackOnFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null)
+ pendingTask.fail("Unknown media type")
return
}
@@ -140,20 +121,20 @@ class DownloadProcessor (
else -> throw Exception("Invalid image format")
}
- val outputStream = inputFile.outputStream()
- bitmap.compress(compressFormat, 100, outputStream)
- outputStream.close()
-
+ pendingTask.updateProgress("Converting image to $format")
+ inputFile.outputStream().use {
+ bitmap.compress(compressFormat, 100, it)
+ }
fileType = FileType.fromFile(inputFile)
}
}
- val fileName = downloadObject.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
+ val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get()))
?: throw Exception("Failed to open output folder")
- val outputFileFolder = downloadObject.metadata.outputPath.let {
+ val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name) ?: folder.createDirectory(name)!!
@@ -164,30 +145,32 @@ class DownloadProcessor (
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
- val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!
- inputFile.inputStream().use { inputStream ->
- inputStream.copyTo(outputStream)
+ pendingTask.updateProgress("Saving media to gallery")
+ remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream ->
+ inputFile.inputStream().use { inputStream ->
+ inputStream.copyTo(outputStream)
+ }
}
- downloadObject.outputFile = outputFile.uri.toString()
- downloadObject.downloadStage = DownloadStage.SAVED
+ pendingTask.task.extra = outputFile.uri.toString()
+ pendingTask.success()
runCatching {
val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE")
mediaScanIntent.setData(outputFile.uri)
remoteSideContext.androidContext.sendBroadcast(mediaScanIntent)
}.onFailure {
- Logger.error("Failed to scan media file", it)
+ remoteSideContext.log.error("Failed to scan media file", it)
callbackOnFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message)
}
- Logger.debug("download complete")
+ remoteSideContext.log.verbose("download complete")
callbackOnSuccess(fileName)
}.onFailure { exception ->
- Logger.error(exception)
+ remoteSideContext.log.error("Failed to save media to gallery", exception)
callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message)
- downloadObject.downloadStage = DownloadStage.FAILED
+ pendingTask.fail("Failed to save media to gallery")
}
}
@@ -195,21 +178,44 @@ class DownloadProcessor (
return File.createTempFile("media", ".tmp")
}
- private fun downloadInputMedias(downloadRequest: DownloadRequest) = runBlocking {
+ private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
val jobs = mutableListOf()
val downloadedMedias = mutableMapOf()
+ var totalSize = 1L
+ val inputMediaDownloadedBytes = mutableMapOf()
+ val inputMediaProgress = ConcurrentHashMap()
+
+ fun updateDownloadProgress() {
+ pendingTask.updateProgress(
+ inputMediaProgress.values.joinToString("\n"),
+ progress = (inputMediaDownloadedBytes.values.sum() * 100 / totalSize).toInt().coerceIn(0, 100)
+ )
+ }
downloadRequest.inputMedias.forEach { inputMedia ->
- fun handleInputStream(inputStream: InputStream) {
+ fun setProgress(progress: String) {
+ inputMediaProgress[inputMedia] = progress
+ updateDownloadProgress()
+ }
+
+ fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
- if (inputMedia.encryption != null) {
- decryptInputStream(inputStream,
- inputMedia.encryption!!
- ).use { decryptedInputStream ->
- decryptedInputStream.copyTo(outputStream())
+ val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var read: Int
+ var totalRead = 0L
+ var lastTotalRead = 0L
+
+ outputStream().use { outputStream ->
+ while (decryptedInputStream.read(buffer).also { read = it } != -1) {
+ outputStream.write(buffer, 0, read)
+ totalRead += read
+ inputMediaDownloadedBytes[inputMedia] = totalRead
+ if (totalRead - lastTotalRead > 1024 * 1024) {
+ setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
+ lastTotalRead = totalRead
+ }
}
- } else {
- inputStream.copyTo(outputStream())
}
}.also { downloadedMedias[inputMedia] = it }
}
@@ -217,24 +223,30 @@ class DownloadProcessor (
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
- RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content))?.let { inputStream ->
- handleInputStream(inputStream)
- }
- }
- DownloadMediaType.DIRECT_MEDIA -> {
- val decoded = Base64.UrlSafe.decode(inputMedia.content)
- createMediaTempFile().apply {
- writeBytes(decoded)
- }.also { downloadedMedias[inputMedia] = it }
+ RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
+ totalSize += length
+ inputStream.use {
+ handleInputStream(it, estimatedSize = length)
+ }
+ })
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
- handleInputStream(inputStream)
+ totalSize += contentLength.toLong()
+ inputStream.use {
+ handleInputStream(it, estimatedSize = contentLength.toLong())
+ }
}
}
+ DownloadMediaType.DIRECT_MEDIA -> {
+ val decoded = Base64.UrlSafe.decode(inputMedia.content)
+ createMediaTempFile().apply {
+ writeBytes(decoded)
+ }.also { downloadedMedias[inputMedia] = it }
+ }
else -> {
downloadedMedias[inputMedia] = File(inputMedia.content)
}
@@ -246,13 +258,28 @@ class DownloadProcessor (
downloadedMedias
}
- private suspend fun downloadRemoteMedia(downloadObjectObject: DownloadObject, downloadedMedias: Map, downloadRequest: DownloadRequest) {
+ private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
if (!downloadRequest.isDashPlaylist) {
- saveMediaToGallery(media.file, downloadObjectObject)
+ if (inputMedia.attachmentType == "NOTE") {
+ remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format ->
+ val outputFile = File.createTempFile("voice_note", ".$format")
+ newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
+ action = FFMpegProcessor.Action.AUDIO_CONVERSION,
+ input = media.file,
+ output = outputFile
+ ))
+ media.file.delete()
+ saveMediaToGallery(pendingTask, outputFile, metadata)
+ outputFile.delete()
+ return
+ }
+ }
+
+ saveMediaToGallery(pendingTask, media.file, metadata)
media.file.delete()
return
}
@@ -270,23 +297,26 @@ class DownloadProcessor (
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
- val xmlData = dashPlaylistFile.outputStream()
- TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData))
+ dashPlaylistFile.outputStream().use {
+ TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
+ }
callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension))
val outputFile = File.createTempFile("dash", ".mp4")
runCatching {
- MediaDownloaderHelper.downloadDashChapterFile(
- dashPlaylist = dashPlaylistFile,
+ newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
+ action = FFMpegProcessor.Action.DOWNLOAD_DASH,
+ input = dashPlaylistFile,
output = outputFile,
startTime = dashOptions.offsetTime,
- duration = dashOptions.duration)
- saveMediaToGallery(outputFile, downloadObjectObject)
+ duration = dashOptions.duration
+ ))
+ saveMediaToGallery(pendingTask, outputFile, metadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
- Logger.error(exception)
+ remoteSideContext.log.error("Failed to download dash media", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
- downloadObjectObject.downloadStage = DownloadStage.FAILED
+ pendingTask.fail("Failed to download dash media")
}
dashPlaylistFile.delete()
@@ -302,83 +332,99 @@ class DownloadProcessor (
}
fun onReceive(intent: Intent) {
- CoroutineScope(Dispatchers.IO).launch {
- val downloadMetadata = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
- val downloadRequest = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
+ remoteSideContext.coroutineScope.launch {
+ val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
+ val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
+
+ remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task ->
+ remoteSideContext.log.debug("already queued or downloaded")
- remoteSideContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage ->
- translation[if (downloadStage.isFinalStage) {
- "already_downloaded_toast"
+ if (task.status.isFinalStage()) {
+ if (task.status != TaskStatus.SUCCESS) return@let
+ callbackOnFailure(translation["already_downloaded_toast"], null)
} else {
- "already_queued_toast"
- }].let {
- callbackOnFailure(it, null)
+ callbackOnFailure(translation["already_queued_toast"], null)
}
return@launch
}
- val downloadObjectObject = DownloadObject(
- metadata = downloadMetadata
- ).apply { downloadTaskManager = remoteSideContext.downloadTaskManager }
-
- downloadObjectObject.also {
- remoteSideContext.downloadTaskManager.addTask(it)
- }.apply {
- job = coroutineContext.job
- downloadStage = DownloadStage.DOWNLOADING
+ remoteSideContext.log.debug("downloading media")
+ val pendingTask = remoteSideContext.taskManager.createPendingTask(
+ Task(
+ type = TaskType.DOWNLOAD,
+ title = downloadMetadata.downloadSource + " (" + downloadMetadata.mediaAuthor + ")",
+ hash = downloadMetadata.mediaIdentifier
+ )
+ ).apply {
+ status = TaskStatus.RUNNING
+ addListener(PendingTaskListener(onCancel = {
+ coroutineContext.job.cancel()
+ }))
+ updateProgress("Downloading...")
}
runCatching {
//first download all input medias into cache
- val downloadedMedias = downloadInputMedias(downloadRequest).map {
+ val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
}.toMap().toMutableMap()
- Logger.debug("downloaded ${downloadedMedias.size} medias")
+ remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
- downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { entry ->
- val extractedMedias = extractZip(entry.file.inputStream()).map {
- InputMedia(
- type = DownloadMediaType.LOCAL_MEDIA,
- content = it.absolutePath
- ) to DownloadedFile(it, FileType.fromFile(it))
+ downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
+ val oldDownloadedMedias = downloadedMedias.toMap()
+ downloadedMedias.clear()
+
+ zipFile.file.inputStream().use { zipFileInputStream ->
+ MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
+ createMediaTempFile().apply {
+ outputStream().use {
+ inputStream.copyTo(it)
+ }
+ }.also {
+ downloadedMedias[InputMedia(
+ type = DownloadMediaType.LOCAL_MEDIA,
+ content = it.absolutePath,
+ isOverlay = type == SplitMediaAssetType.OVERLAY
+ )] = DownloadedFile(it, FileType.fromFile(it))
+ }
+ }
}
- downloadedMedias.values.removeIf {
- it.file.delete()
- true
+ oldDownloadedMedias.forEach { (_, value) ->
+ value.file.delete()
}
- downloadedMedias.putAll(extractedMedias)
shouldMergeOverlay = true
}
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
- val media = downloadedMedias.values.first { it.fileType.isVideo }
- val overlayMedia = downloadedMedias.values.first { it.fileType.isImage }
+ //TODO: convert "mp4 images" into real images
+ val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
+ val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media.file, media.fileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
- val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension)
+ val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
- callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension))
- downloadObjectObject.downloadStage = DownloadStage.MERGING
+ callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
- MediaDownloaderHelper.mergeOverlayFile(
- media = renamedMedia,
- overlay = renamedOverlayMedia,
- output = mergedOverlay
- )
+ newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
+ action = FFMpegProcessor.Action.MERGE_OVERLAY,
+ input = renamedMedia,
+ output = mergedOverlay,
+ overlay = renamedOverlayMedia
+ ))
- saveMediaToGallery(mergedOverlay, downloadObjectObject)
+ saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
- Logger.error(exception)
+ remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
- downloadObjectObject.downloadStage = DownloadStage.MERGE_FAILED
+ pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
@@ -387,10 +433,10 @@ class DownloadProcessor (
return@launch
}
- downloadRemoteMedia(downloadObjectObject, downloadedMedias, downloadRequest)
+ downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
- downloadObjectObject.downloadStage = DownloadStage.FAILED
- Logger.error(exception)
+ pendingTask.fail("Failed to download media")
+ remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt
new file mode 100644
index 000000000..58ad1988a
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt
@@ -0,0 +1,140 @@
+package me.rhunk.snapenhance.download
+
+import com.arthenica.ffmpegkit.FFmpegKit
+import com.arthenica.ffmpegkit.FFmpegSession
+import com.arthenica.ffmpegkit.Level
+import com.arthenica.ffmpegkit.Statistics
+import kotlinx.coroutines.suspendCancellableCoroutine
+import me.rhunk.snapenhance.LogManager
+import me.rhunk.snapenhance.common.config.impl.DownloaderConfig
+import me.rhunk.snapenhance.common.logger.LogLevel
+import java.io.File
+import java.util.concurrent.Executors
+
+
+class ArgumentList : LinkedHashMap>() {
+ operator fun plusAssign(stringPair: Pair) {
+ val (key, value) = stringPair
+ if (this.containsKey(key)) {
+ this[key]!!.add(value)
+ } else {
+ this[key] = mutableListOf(value)
+ }
+ }
+
+ operator fun plusAssign(key: String) {
+ this[key] = mutableListOf().apply {
+ this += ""
+ }
+ }
+
+ operator fun minusAssign(key: String) {
+ this.remove(key)
+ }
+}
+
+
+class FFMpegProcessor(
+ private val logManager: LogManager,
+ private val ffmpegOptions: DownloaderConfig.FFMpegOptions,
+ private val onStatistics: (Statistics) -> Unit = {}
+) {
+ companion object {
+ private const val TAG = "ffmpeg-processor"
+ }
+ enum class Action {
+ DOWNLOAD_DASH,
+ MERGE_OVERLAY,
+ AUDIO_CONVERSION,
+ }
+
+ data class Request(
+ val action: Action,
+ val input: File,
+ val output: File,
+ val overlay: File? = null, //only for MERGE_OVERLAY
+ val startTime: Long? = null, //only for DOWNLOAD_DASH
+ val duration: Long? = null //only for DOWNLOAD_DASH
+ )
+
+
+ private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine {
+ val stringBuilder = StringBuilder()
+ arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
+ argumentList.forEach { (key, values) ->
+ values.forEach valueForEach@{ value ->
+ if (value.isEmpty()) {
+ stringBuilder.append("$key ")
+ return@valueForEach
+ }
+ stringBuilder.append("$key $value ")
+ }
+ }
+ }
+
+ logManager.debug("arguments: $stringBuilder", "FFMpegProcessor")
+
+ FFmpegKit.executeAsync(stringBuilder.toString(),
+ { session ->
+ it.resumeWith(
+ if (session.returnCode.isValueSuccess) {
+ Result.success(session)
+ } else {
+ Result.failure(Exception(session.output))
+ }
+ )
+ }, logFunction@{ log ->
+ logManager.internalLog(TAG, when (log.level) {
+ Level.AV_LOG_ERROR, Level.AV_LOG_FATAL -> LogLevel.ERROR
+ Level.AV_LOG_WARNING -> LogLevel.WARN
+ Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
+ else -> return@logFunction
+ }, log.message)
+ }, { onStatistics(it) }, Executors.newSingleThreadExecutor())
+ }
+
+ suspend fun execute(args: Request) {
+ // load ffmpeg native sync to avoid native crash
+ synchronized(this) { FFmpegKit.listSessions() }
+ val globalArguments = ArgumentList().apply {
+ this += "-y"
+ this += "-threads" to ffmpegOptions.threads.get().toString()
+ }
+
+ val inputArguments = ArgumentList().apply {
+ this += "-i" to args.input.absolutePath
+ }
+
+ val outputArguments = ArgumentList().apply {
+ this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
+ this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
+ this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
+ this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
+ this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
+ this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
+ }
+
+ when (args.action) {
+ Action.DOWNLOAD_DASH -> {
+ outputArguments += "-ss" to "'${args.startTime}ms'"
+ if (args.duration != null) {
+ outputArguments += "-t" to "'${args.duration}ms'"
+ }
+ }
+ Action.MERGE_OVERLAY -> {
+ inputArguments += "-i" to args.overlay!!.absolutePath
+ outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
+ }
+ Action.AUDIO_CONVERSION -> {
+ if (ffmpegOptions.customAudioCodec.isEmpty()) {
+ outputArguments -= "-c:a"
+ }
+ if (ffmpegOptions.customVideoCodec.isEmpty()) {
+ outputArguments -= "-c:v"
+ }
+ }
+ }
+ outputArguments += args.output.absolutePath
+ newFFMpegTask(globalArguments, inputArguments, outputArguments)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt
new file mode 100644
index 000000000..588dff36e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt
@@ -0,0 +1,171 @@
+package me.rhunk.snapenhance.e2ee
+
+import me.rhunk.snapenhance.RemoteSideContext
+import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
+import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult
+import org.bouncycastle.pqc.crypto.crystals.kyber.*
+import java.io.File
+import java.security.MessageDigest
+import java.security.SecureRandom
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+
+class E2EEImplementation (
+ private val context: RemoteSideContext
+) : E2eeInterface.Stub() {
+ private val kyberDefaultParameters = KyberParameters.kyber1024_aes
+ private val secureRandom = SecureRandom()
+
+ private val e2eeFolder by lazy { File(context.androidContext.filesDir, "e2ee").also {
+ if (!it.exists()) it.mkdirs()
+ }}
+ private val pairingFolder by lazy { File(context.androidContext.cacheDir, "e2ee-pairing").also {
+ if (!it.exists()) it.mkdirs()
+ } }
+
+ fun storeSharedSecretKey(friendId: String, key: ByteArray) {
+ File(e2eeFolder, "$friendId.key").writeBytes(key)
+ }
+
+ fun getSharedSecretKey(friendId: String): ByteArray? {
+ return runCatching {
+ File(e2eeFolder, "$friendId.key").readBytes()
+ }.onFailure {
+ context.log.error("Failed to read shared secret key", it)
+ }.getOrNull()
+ }
+
+ fun deleteSharedSecretKey(friendId: String) {
+ File(e2eeFolder, "$friendId.key").delete()
+ }
+
+
+ override fun createKeyExchange(friendId: String): ByteArray? {
+ val keyPairGenerator = KyberKeyPairGenerator()
+ keyPairGenerator.init(
+ KyberKeyGenerationParameters(secureRandom, kyberDefaultParameters)
+ )
+ val keyPair = keyPairGenerator.generateKeyPair()
+ val publicKey = keyPair.public as KyberPublicKeyParameters
+ val privateKey = keyPair.private as KyberPrivateKeyParameters
+ runCatching {
+ File(pairingFolder, "$friendId.private").writeBytes(privateKey.encoded)
+ File(pairingFolder, "$friendId.public").writeBytes(publicKey.encoded)
+ }.onFailure {
+ context.log.error("Failed to write private key to file", it)
+ return null
+ }
+ return publicKey.encoded
+ }
+
+ override fun acceptPairingRequest(friendId: String, publicKey: ByteArray): ByteArray? {
+ val kemGen = KyberKEMGenerator(secureRandom)
+ val encapsulatedSecret = runCatching {
+ kemGen.generateEncapsulated(
+ KyberPublicKeyParameters(
+ kyberDefaultParameters,
+ publicKey
+ )
+ )
+ }.onFailure {
+ context.log.error("Failed to generate encapsulated secret", it)
+ return null
+ }.getOrThrow()
+
+ runCatching {
+ storeSharedSecretKey(friendId, encapsulatedSecret.secret)
+ }.onFailure {
+ context.log.error("Failed to store shared secret key", it)
+ return null
+ }
+ return encapsulatedSecret.encapsulation
+ }
+
+ override fun acceptPairingResponse(friendId: String, encapsulatedSecret: ByteArray): Boolean {
+ val privateKey = runCatching {
+ val secretKey = File(pairingFolder, "$friendId.private").readBytes()
+ object: KyberPrivateKeyParameters(kyberDefaultParameters, null, null, null, null, null) {
+ override fun getEncoded() = secretKey
+ }
+ }.onFailure {
+ context.log.error("Failed to read private key from file", it)
+ return false
+ }.getOrThrow()
+
+ val kemExtractor = KyberKEMExtractor(privateKey)
+ val sharedSecret = runCatching {
+ kemExtractor.extractSecret(encapsulatedSecret)
+ }.onFailure {
+ context.log.error("Failed to extract shared secret", it)
+ return false
+ }.getOrThrow()
+
+ runCatching {
+ storeSharedSecretKey(friendId, sharedSecret)
+ }.onFailure {
+ context.log.error("Failed to store shared secret key", it)
+ return false
+ }
+
+ return true
+ }
+
+ override fun friendKeyExists(friendId: String): Boolean {
+ return File(e2eeFolder, "$friendId.key").exists()
+ }
+
+ override fun getSecretFingerprint(friendId: String): String? {
+ val sharedSecretKey = runCatching {
+ File(e2eeFolder, "$friendId.key").readBytes()
+ }.onFailure {
+ context.log.error("Failed to read shared secret key", it)
+ return null
+ }.getOrThrow()
+
+ return MessageDigest.getInstance("SHA-256")
+ .digest(sharedSecretKey)
+ .joinToString("") { "%02x".format(it) }
+ .chunked(5)
+ .joinToString(" ")
+ }
+
+ override fun encryptMessage(friendId: String, message: ByteArray): EncryptionResult? {
+ val encryptionKey = runCatching {
+ File(e2eeFolder, "$friendId.key").readBytes()
+ }.onFailure {
+ context.log.error("Failed to read shared secret key", it)
+ }.getOrNull()
+
+ return runCatching {
+ val iv = ByteArray(16).apply { secureRandom.nextBytes(this) }
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv))
+ EncryptionResult().apply {
+ this.iv = iv
+ this.ciphertext = cipher.doFinal(message)
+ }
+ }.onFailure {
+ context.log.error("Failed to encrypt message for $friendId", it)
+ }.getOrNull()
+ }
+
+ override fun decryptMessage(friendId: String, message: ByteArray, iv: ByteArray): ByteArray? {
+ val encryptionKey = runCatching {
+ File(e2eeFolder, "$friendId.key").readBytes()
+ }.onFailure {
+ context.log.error("Failed to read shared secret key", it)
+ return null
+ }.getOrNull()
+
+ return runCatching {
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv))
+ cipher.doFinal(message)
+ }.onFailure {
+ context.log.error("Failed to decrypt message from $friendId", it)
+ return null
+ }.getOrNull()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt
new file mode 100644
index 000000000..4d1756e72
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt
@@ -0,0 +1,126 @@
+package me.rhunk.snapenhance.messaging
+
+import androidx.compose.runtime.MutableIntState
+import kotlinx.coroutines.delay
+import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
+import me.rhunk.snapenhance.bridge.snapclient.types.Message
+import me.rhunk.snapenhance.common.data.ContentType
+import kotlin.random.Random
+
+
+enum class MessagingTaskType(
+ val key: String
+) {
+ SAVE("SAVE"),
+ UNSAVE("UNSAVE"),
+ DELETE("ERASE"),
+ READ("READ"),
+}
+
+typealias MessagingTaskConstraint = Message.() -> Boolean
+
+object MessagingConstraints {
+ val USER_ID: (String) -> MessagingTaskConstraint = { userId: String ->
+ {
+ this.senderId == userId
+ }
+ }
+ val NO_USER_ID: (String) -> MessagingTaskConstraint = { userId: String ->
+ {
+ this.senderId != userId
+ }
+ }
+ val MY_USER_ID: (messagingBridge: MessagingBridge) -> MessagingTaskConstraint = {
+ val myUserId = it.myUserId
+ {
+ this.senderId == myUserId
+ }
+ }
+ val CONTENT_TYPE: (Array) -> MessagingTaskConstraint = {
+ val contentTypes = it.map { type -> type.id };
+ {
+ contentTypes.contains(this.contentType)
+ }
+ }
+}
+
+class MessagingTask(
+ private val messagingBridge: MessagingBridge,
+ private val conversationId: String,
+ val taskType: MessagingTaskType,
+ val constraints: List,
+ private val processedMessageCount: MutableIntState,
+ val onSuccess: (message: Message) -> Unit = {},
+ private val onFailure: (message: Message, reason: String) -> Unit = { _, _ -> },
+ private val overrideClientMessageIds: List? = null,
+ private val amountToProcess: Int? = null,
+) {
+ private suspend fun processMessages(
+ messages: List
+ ) {
+ messages.forEach { message ->
+ if (constraints.any { !it(message) }) {
+ return@forEach
+ }
+
+ val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, taskType.key)
+ error?.takeIf { error != "DUPLICATE_REQUEST" }?.let {
+ onFailure(message, error)
+ }
+ onSuccess(message)
+ processedMessageCount.intValue++
+ delay(Random.nextLong(20, 50))
+ }
+ }
+
+ fun hasFixedGoal() = overrideClientMessageIds?.takeIf { it.isNotEmpty() } != null || amountToProcess?.takeIf { it > 0 } != null
+
+ suspend fun run() {
+ var processedOverrideMessages = 0
+ var lastMessageId = Long.MAX_VALUE
+
+ do {
+ val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated(
+ conversationId,
+ 100,
+ lastMessageId
+ ) ?: return
+
+ if (fetchedMessages.isEmpty()) {
+ break
+ }
+
+ lastMessageId = fetchedMessages.first().clientMessageId
+
+ overrideClientMessageIds?.let { ids ->
+ fetchedMessages.retainAll { message ->
+ ids.contains(message.clientMessageId)
+ }
+ }
+
+ amountToProcess?.let { amount ->
+ while (processedMessageCount.intValue + fetchedMessages.size > amount) {
+ fetchedMessages.removeLastOrNull()
+ }
+ }
+
+ processMessages(fetchedMessages.reversed())
+
+ overrideClientMessageIds?.let { ids ->
+ processedOverrideMessages += fetchedMessages.count { message ->
+ ids.contains(message.clientMessageId)
+ }
+
+ if (processedOverrideMessages >= ids.size) {
+ return
+ }
+ }
+
+ amountToProcess?.let { amount ->
+ if (processedMessageCount.intValue >= amount) {
+ return
+ }
+ }
+ } while (true)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt
index e87c6535d..85ed56cc0 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt
@@ -1,17 +1,17 @@
package me.rhunk.snapenhance.messaging
import android.database.sqlite.SQLiteDatabase
-import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
-import me.rhunk.snapenhance.core.messaging.FriendStreaks
-import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
-import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
-import me.rhunk.snapenhance.core.messaging.MessagingRuleType
-import me.rhunk.snapenhance.database.objects.FriendInfo
-import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
-import me.rhunk.snapenhance.util.ktx.getInteger
-import me.rhunk.snapenhance.util.ktx.getLongOrNull
-import me.rhunk.snapenhance.util.ktx.getStringOrNull
+import me.rhunk.snapenhance.common.data.FriendStreaks
+import me.rhunk.snapenhance.common.data.MessagingFriendInfo
+import me.rhunk.snapenhance.common.data.MessagingGroupInfo
+import me.rhunk.snapenhance.common.data.MessagingRuleType
+import me.rhunk.snapenhance.common.database.impl.FriendInfo
+import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
+import me.rhunk.snapenhance.common.util.ktx.getInteger
+import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
+import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import java.util.concurrent.Executors
@@ -28,7 +28,7 @@ class ModDatabase(
runCatching {
block()
}.onFailure {
- Logger.error("Failed to execute async block", it)
+ context.log.error("Failed to execute async block", it)
}
}
}
@@ -61,17 +61,12 @@ class ModDatabase(
"expirationTimestamp BIGINT",
"length INTEGER"
),
- "analytics_config" to listOf(
- "userId VARCHAR PRIMARY KEY",
- "modes VARCHAR"
- ),
- "analytics" to listOf(
- "hash VARCHAR PRIMARY KEY",
- "userId VARCHAR",
- "conversationId VARCHAR",
- "timestamp BIGINT",
- "eventName VARCHAR",
- "eventData VARCHAR"
+ "scripts" to listOf(
+ "name VARCHAR PRIMARY KEY",
+ "version VARCHAR NOT NULL",
+ "description VARCHAR",
+ "author VARCHAR NOT NULL",
+ "enabled BOOLEAN"
)
))
}
@@ -80,11 +75,13 @@ class ModDatabase(
return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
val groups = mutableListOf()
while (cursor.moveToNext()) {
- groups.add(MessagingGroupInfo(
+ groups.add(
+ MessagingGroupInfo(
conversationId = cursor.getStringOrNull("conversationId")!!,
name = cursor.getStringOrNull("name")!!,
participantsCount = cursor.getInteger("participantsCount")
- ))
+ )
+ )
}
groups
}
@@ -95,15 +92,17 @@ class ModDatabase(
val friends = mutableListOf()
while (cursor.moveToNext()) {
runCatching {
- friends.add(MessagingFriendInfo(
+ friends.add(
+ MessagingFriendInfo(
userId = cursor.getStringOrNull("userId")!!,
displayName = cursor.getStringOrNull("displayName"),
mutableUsername = cursor.getStringOrNull("mutableUsername")!!,
bitmojiId = cursor.getStringOrNull("bitmojiId"),
selfieId = cursor.getStringOrNull("selfieId")
- ))
+ )
+ )
}.onFailure {
- Logger.error("Failed to parse friend", it)
+ context.log.error("Failed to parse friend", it)
}
}
friends
@@ -144,7 +143,7 @@ class ModDatabase(
database.execSQL("INSERT OR REPLACE INTO streaks (userId, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf(
friend.userId,
- streaks?.notify ?: false,
+ streaks?.notify ?: true,
friend.streakExpirationTimestamp,
friend.streakLength
))
@@ -163,7 +162,11 @@ class ModDatabase(
)).use { cursor ->
val rules = mutableListOf()
while (cursor.moveToNext()) {
- rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!))
+ runCatching {
+ rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching)
+ }.onFailure {
+ context.log.error("Failed to parse rule", it)
+ }
}
rules
}
@@ -202,12 +205,14 @@ class ModDatabase(
executeAsync {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId))
database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(userId))
+ database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId))
}
}
fun deleteGroup(conversationId: String) {
executeAsync {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId))
+ database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId))
}
}
@@ -242,4 +247,74 @@ class ModDatabase(
))
}
}
+
+ fun getRuleIds(type: String): MutableList {
+ return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
+ val ruleIds = mutableListOf()
+ while (cursor.moveToNext()) {
+ ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
+ }
+ ruleIds
+ }
+ }
+
+ fun getScripts(): List {
+ return database.rawQuery("SELECT * FROM scripts", null).use { cursor ->
+ val scripts = mutableListOf()
+ while (cursor.moveToNext()) {
+ scripts.add(
+ ModuleInfo(
+ name = cursor.getStringOrNull("name")!!,
+ version = cursor.getStringOrNull("version")!!,
+ description = cursor.getStringOrNull("description"),
+ author = cursor.getStringOrNull("author"),
+ grantPermissions = null
+ )
+ )
+ }
+ scripts
+ }
+ }
+
+ fun setScriptEnabled(name: String, enabled: Boolean) {
+ executeAsync {
+ database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf(
+ if (enabled) 1 else 0,
+ name
+ ))
+ }
+ }
+
+ fun isScriptEnabled(name: String): Boolean {
+ return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor ->
+ if (!cursor.moveToFirst()) return@use false
+ cursor.getInteger("enabled") == 1
+ }
+ }
+
+ fun syncScripts(availableScripts: List) {
+ executeAsync {
+ val enabledScripts = getScripts()
+ val enabledScriptPaths = enabledScripts.map { it.name }
+ val availableScriptPaths = availableScripts.map { it.name }
+
+ enabledScripts.forEach { script ->
+ if (!availableScriptPaths.contains(script.name)) {
+ database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name))
+ }
+ }
+
+ availableScripts.forEach { script ->
+ if (!enabledScriptPaths.contains(script.name)) {
+ database.execSQL("INSERT OR REPLACE INTO scripts (name, version, description, author, enabled) VALUES (?, ?, ?, ?, ?)", arrayOf(
+ script.name,
+ script.version,
+ script.description,
+ script.author,
+ 0
+ ))
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt
index 6a3731da9..0c1ba5e49 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt
@@ -9,15 +9,16 @@ 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.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.util.ImageRequestHelper
-import me.rhunk.snapenhance.util.snap.BitmojiSelfie
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.minutes
class StreaksReminder(
private val remoteSideContext: RemoteSideContext? = null
@@ -26,8 +27,6 @@ class StreaksReminder(
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(
@@ -40,25 +39,56 @@ class StreaksReminder(
override fun onReceive(ctx: Context, intent: Intent) {
val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx)
- if (remoteSideContext.config.root.streaksReminder.globalState != true) return
+ val streaksReminderConfig = remoteSideContext.config.root.streaksReminder
+ val sharedPreferences = remoteSideContext.sharedPreferences
+
+ if (streaksReminderConfig.globalState != true) return
+
+ val interval = streaksReminderConfig.interval.get()
+ val remainingHours = streaksReminderConfig.remainingHours.get()
+
+ if (sharedPreferences.getLong("lastStreaksReminder", 0).milliseconds + interval.hours - 10.minutes > System.currentTimeMillis().milliseconds) return
+ sharedPreferences.edit().putLong("lastStreaksReminder", System.currentTimeMillis()).apply()
+
+ remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating(
+ AlarmManager.RTC_WAKEUP, 5000, interval.toLong() * 60 * 60 * 1000,
+ PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java),
+ PendingIntent.FLAG_IMMUTABLE)
+ )
val notifyFriendList = remoteSideContext.modDatabase.getFriends()
.associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) }
- .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire() }
+ .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) }
val notificationManager = getNotificationManager(ctx)
+ val streaksReminderTranslation = remoteSideContext.translation.getCategory("streaks_reminder")
+
+ if (streaksReminderConfig.groupNotifications.get() && notifyFriendList.isNotEmpty()) {
+ notificationManager.notify(0, NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ .setGroup("streaks")
+ .setGroupSummary(true)
+ .setSmallIcon(R.drawable.streak_icon)
+ .build())
+ }
notifyFriendList.forEach { (streaks, friend) ->
- coroutineScope.launch {
+ remoteSideContext.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")
+ .setContentTitle(streaksReminderTranslation["notification_title"])
+ .setContentText(streaksReminderTranslation.format("notification_text",
+ "friend" to (friend.displayName ?: friend.mutableUsername),
+ "hoursLeft" to (streaks?.hoursLeft() ?: 0).toString()
+ ))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setAutoCancel(true)
+ .setGroup("streaks")
.setContentIntent(PendingIntent.getActivity(
ctx,
0,
@@ -68,12 +98,16 @@ class StreaksReminder(
PendingIntent.FLAG_IMMUTABLE
))
.apply {
+ setSmallIcon(R.drawable.streak_icon)
bitmojiImage.drawable?.let {
setLargeIcon(it.toBitmap())
- setSmallIcon(R.drawable.streak_icon)
}
}
+ if (streaksReminderConfig.groupNotifications.get()) {
+ notificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
+ }
+
notificationManager.notify(friend.userId.hashCode(), notificationBuilder.build().apply {
flags = NotificationCompat.FLAG_ONLY_ALERT_ONCE
})
@@ -81,18 +115,9 @@ class StreaksReminder(
}
}
- //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)
- )
+ if (remoteSideContext.config.root.streaksReminder.globalState != true) return
onReceive(remoteSideContext.androidContext, Intent())
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt
new file mode 100644
index 000000000..fc2241929
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt
@@ -0,0 +1,41 @@
+package me.rhunk.snapenhance.scripting
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class AutoReloadHandler(
+ private val coroutineScope: CoroutineScope,
+ private val onReload: (DocumentFile) -> Unit,
+) {
+ private val files = mutableListOf()
+ private val lastModifiedMap = mutableMapOf()
+
+ fun addFile(file: DocumentFile) {
+ files.add(file)
+ lastModifiedMap[file.uri] = file.lastModified()
+ }
+
+ fun start() {
+ coroutineScope.launch(Dispatchers.IO) {
+ while (true) {
+ files.forEach { file ->
+ val lastModified = lastModifiedMap[file.uri] ?: return@forEach
+ runCatching {
+ val newLastModified = file.lastModified()
+ if (newLastModified > lastModified) {
+ lastModifiedMap[file.uri] = newLastModified
+ onReload(file)
+ }
+ }.onFailure {
+ it.printStackTrace()
+ }
+ }
+ delay(1000)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
new file mode 100644
index 000000000..ac3a027d6
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
@@ -0,0 +1,170 @@
+package me.rhunk.snapenhance.scripting
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import me.rhunk.snapenhance.RemoteSideContext
+import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener
+import me.rhunk.snapenhance.bridge.scripting.IPCListener
+import me.rhunk.snapenhance.bridge.scripting.IScripting
+import me.rhunk.snapenhance.common.scripting.ScriptRuntime
+import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
+import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType
+import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import me.rhunk.snapenhance.scripting.impl.IPCListeners
+import me.rhunk.snapenhance.scripting.impl.RemoteManagerIPC
+import me.rhunk.snapenhance.scripting.impl.RemoteScriptConfig
+import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager
+import java.io.File
+import java.io.InputStream
+import kotlin.system.exitProcess
+
+class RemoteScriptManager(
+ val context: RemoteSideContext,
+) : IScripting.Stub() {
+ val runtime = ScriptRuntime(context.androidContext, context.log)
+
+ private var autoReloadListener: AutoReloadListener? = null
+ private val autoReloadHandler by lazy {
+ AutoReloadHandler(context.coroutineScope) {
+ runCatching {
+ autoReloadListener?.restartApp()
+ if (context.config.root.scripting.autoReload.getNullable() == "all") {
+ exitProcess(1)
+ }
+ }.onFailure {
+ context.log.warn("Failed to restart app")
+ autoReloadListener = null
+ }
+ }.apply {
+ start()
+ }
+ }
+
+ private val cachedModuleInfo = mutableMapOf()
+ private val ipcListeners = IPCListeners()
+
+ fun sync() {
+ getScriptFileNames().forEach { name ->
+ runCatching {
+ getScriptInputStream(name) { stream ->
+ runtime.getModuleInfo(stream!!).also { info ->
+ cachedModuleInfo[name] = info
+ }
+ }
+ }.onFailure {
+ context.log.error("Failed to load module info for $name", it)
+ }
+ }
+
+ context.modDatabase.syncScripts(cachedModuleInfo.values.toList())
+ }
+
+ fun init() {
+ runtime.buildModuleObject = { module ->
+ module.extras["ipc"] = RemoteManagerIPC(module.moduleInfo, context.log, ipcListeners)
+ module.extras["im"] = InterfaceManager(module.moduleInfo, context.log)
+ module.extras["config"] = RemoteScriptConfig(this@RemoteScriptManager, module.moduleInfo, context.log).also {
+ it.load()
+ }
+ }
+
+ sync()
+ enabledScripts.forEach { name ->
+ loadScript(name)
+ }
+ }
+
+ fun loadScript(name: String) {
+ val content = getScriptContent(name) ?: return
+ if (context.config.root.scripting.autoReload.getNullable() != null) {
+ autoReloadHandler.addFile(getScriptsFolder()?.findFile(name) ?: return)
+ }
+ runtime.load(name, content)
+ }
+
+ private fun getScriptInputStream(name: String, callback: (InputStream?) -> R): R {
+ val file = getScriptsFolder()?.findFile(name) ?: return callback(null)
+ return context.androidContext.contentResolver.openInputStream(file.uri)?.use(callback) ?: callback(null)
+ }
+
+ fun getModuleDataFolder(moduleFileName: String): File {
+ return context.androidContext.filesDir.resolve("modules").resolve(moduleFileName).also {
+ if (!it.exists()) {
+ it.mkdirs()
+ }
+ }
+ }
+
+ fun getScriptsFolder() = runCatching {
+ DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get()))
+ }.onFailure {
+ context.log.warn("Failed to get scripts folder")
+ }.getOrNull()
+
+ private fun getScriptFileNames(): List {
+ return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! }
+ }
+
+ override fun getEnabledScripts(): List {
+ return runCatching {
+ getScriptFileNames().filter {
+ context.modDatabase.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false)
+ }
+ }.onFailure {
+ context.log.error("Failed to get enabled scripts", it)
+ }.getOrDefault(emptyList())
+ }
+
+ override fun getScriptContent(moduleName: String): String? {
+ return getScriptInputStream(moduleName) { it?.bufferedReader()?.readText() }
+ }
+
+ override fun registerIPCListener(channel: String, eventName: String, listener: IPCListener) {
+ ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(listener)
+ }
+
+ override fun sendIPCMessage(channel: String, eventName: String, args: Array) {
+ runCatching {
+ ipcListeners[channel]?.get(eventName)?.toList()?.forEach {
+ it.onMessage(args)
+ }
+ }.onFailure {
+ context.log.error("Failed to send message for $eventName", it)
+ }
+ }
+
+ override fun configTransaction(
+ module: String?,
+ action: String,
+ key: String?,
+ value: String?,
+ save: Boolean
+ ): String? {
+ val scriptConfig = runtime.getModuleByName(module ?: return null)?.extras?.get("config") as? ConfigInterface ?: return null.also {
+ context.log.warn("Failed to get config interface for $module")
+ }
+ val transactionType = ConfigTransactionType.fromKey(action)
+
+ return runCatching {
+ scriptConfig.run {
+ if (transactionType == ConfigTransactionType.GET) {
+ return get(key ?: return@runCatching null, value)
+ }
+ when (transactionType) {
+ ConfigTransactionType.SET -> set(key ?: return@runCatching null, value, save)
+ ConfigTransactionType.SAVE -> save()
+ ConfigTransactionType.LOAD -> load()
+ ConfigTransactionType.DELETE -> delete()
+ else -> {}
+ }
+ null
+ }
+ }.onFailure {
+ context.log.error("Failed to perform config transaction", it)
+ }.getOrDefault("")
+ }
+
+ override fun registerAutoReloadListener(listener: AutoReloadListener?) {
+ autoReloadListener = listener
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteManagerIPC.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteManagerIPC.kt
new file mode 100644
index 000000000..32860f1b3
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteManagerIPC.kt
@@ -0,0 +1,55 @@
+package me.rhunk.snapenhance.scripting.impl
+
+import android.os.DeadObjectException
+import me.rhunk.snapenhance.bridge.scripting.IPCListener
+import me.rhunk.snapenhance.common.logger.AbstractLogger
+import me.rhunk.snapenhance.common.scripting.impl.IPCInterface
+import me.rhunk.snapenhance.common.scripting.impl.Listener
+import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import java.util.concurrent.ConcurrentHashMap
+
+typealias IPCListeners = ConcurrentHashMap>> // channel, eventName -> listeners
+
+class RemoteManagerIPC(
+ private val moduleInfo: ModuleInfo,
+ private val logger: AbstractLogger,
+ private val ipcListeners: IPCListeners = ConcurrentHashMap(),
+) : IPCInterface() {
+ companion object {
+ private const val TAG = "RemoteManagerIPC"
+ }
+
+ override fun on(eventName: String, listener: Listener) {
+ onBroadcast(moduleInfo.name, eventName, listener)
+ }
+
+ override fun emit(eventName: String, vararg args: String?) {
+ emit(moduleInfo.name, eventName, *args)
+ }
+
+ override fun onBroadcast(channel: String, eventName: String, listener: Listener) {
+ ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(object: IPCListener.Stub() {
+ override fun onMessage(args: Array) {
+ try {
+ listener(args)
+ } catch (doe: DeadObjectException) {
+ ipcListeners[channel]?.get(eventName)?.remove(this)
+ } catch (t: Throwable) {
+ logger.error("Failed to receive message for channel: $channel, event: $eventName", t, TAG)
+ }
+ }
+ })
+ }
+
+ override fun broadcast(channel: String, eventName: String, vararg args: String?) {
+ ipcListeners[channel]?.get(eventName)?.toList()?.forEach {
+ try {
+ it.onMessage(args)
+ } catch (doe: DeadObjectException) {
+ ipcListeners[channel]?.get(eventName)?.remove(it)
+ } catch (t: Throwable) {
+ logger.error("Failed to send message for channel: $channel, event: $eventName", t, TAG)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteScriptConfig.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteScriptConfig.kt
new file mode 100644
index 000000000..a9776585e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteScriptConfig.kt
@@ -0,0 +1,57 @@
+package me.rhunk.snapenhance.scripting.impl
+
+import com.google.gson.JsonObject
+import me.rhunk.snapenhance.common.logger.AbstractLogger
+import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
+import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import me.rhunk.snapenhance.scripting.RemoteScriptManager
+import java.io.File
+
+class RemoteScriptConfig(
+ private val remoteScriptManager: RemoteScriptManager,
+ moduleInfo: ModuleInfo,
+ private val logger: AbstractLogger,
+) : ConfigInterface() {
+ private val configFile = File(remoteScriptManager.getModuleDataFolder(moduleInfo.name), "config.json")
+ private var config = JsonObject()
+
+ override fun get(key: String, defaultValue: Any?): String? {
+ return config[key]?.asString ?: defaultValue?.toString()
+ }
+
+ override fun set(key: String, value: Any?, save: Boolean) {
+ when (value) {
+ is Int -> config.addProperty(key, value)
+ is Double -> config.addProperty(key, value)
+ is Boolean -> config.addProperty(key, value)
+ is Long -> config.addProperty(key, value)
+ is Float -> config.addProperty(key, value)
+ is Byte -> config.addProperty(key, value)
+ is Short -> config.addProperty(key, value)
+ else -> config.addProperty(key, value?.toString())
+ }
+
+ if (save) save()
+ }
+
+ override fun save() {
+ configFile.writeText(config.toString())
+ }
+
+ override fun load() {
+ runCatching {
+ if (!configFile.exists()) {
+ save()
+ return@runCatching
+ }
+ config = remoteScriptManager.context.gson.fromJson(configFile.readText(), JsonObject::class.java)
+ }.onFailure {
+ logger.error("Failed to load config file", it)
+ save()
+ }
+ }
+
+ override fun delete() {
+ configFile.delete()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt
new file mode 100644
index 000000000..e4c2e4fa5
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt
@@ -0,0 +1,99 @@
+package me.rhunk.snapenhance.scripting.impl.ui
+
+import me.rhunk.snapenhance.common.logger.AbstractLogger
+import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import me.rhunk.snapenhance.scripting.impl.ui.components.Node
+import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType
+import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionNode
+import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionType
+import me.rhunk.snapenhance.scripting.impl.ui.components.impl.RowColumnNode
+import org.mozilla.javascript.Context
+import org.mozilla.javascript.Function
+import org.mozilla.javascript.annotations.JSFunction
+
+
+class InterfaceBuilder {
+ val nodes = mutableListOf()
+ var onDisposeCallback: (() -> Unit)? = null
+
+ private fun createNode(type: NodeType, block: Node.() -> Unit): Node {
+ return Node(type).apply(block).also { nodes.add(it) }
+ }
+
+ fun onDispose(block: () -> Unit) {
+ nodes.add(ActionNode(ActionType.DISPOSE, callback = block))
+ }
+
+ fun onLaunched(block: () -> Unit) {
+ onLaunched(Unit, block)
+ }
+
+ fun onLaunched(key: Any, block: () -> Unit) {
+ nodes.add(ActionNode(ActionType.LAUNCHED, key, block))
+ }
+
+ fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply {
+ children.addAll(InterfaceBuilder().apply(block).nodes)
+ }.also { nodes.add(it) }
+
+ fun column(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.COLUMN).apply {
+ children.addAll(InterfaceBuilder().apply(block).nodes)
+ }.also { nodes.add(it) }
+
+ fun text(text: String) = createNode(NodeType.TEXT) {
+ label(text)
+ }
+
+ fun switch(state: Boolean?, callback: (Boolean) -> Unit) = createNode(NodeType.SWITCH) {
+ attributes["state"] = state
+ attributes["callback"] = callback
+ }
+
+ fun button(label: String, callback: () -> Unit) = createNode(NodeType.BUTTON) {
+ label(label)
+ attributes["callback"] = callback
+ }
+
+ fun slider(min: Int, max: Int, step: Int, value: Int, callback: (Int) -> Unit) = createNode(
+ NodeType.SLIDER
+ ) {
+ attributes["value"] = value
+ attributes["min"] = min
+ attributes["max"] = max
+ attributes["step"] = step
+ attributes["callback"] = callback
+ }
+
+ fun list(label: String, items: List, callback: (String) -> Unit) = createNode(NodeType.LIST) {
+ label(label)
+ attributes["items"] = items
+ attributes["callback"] = callback
+ }
+}
+
+
+
+class InterfaceManager(
+ private val moduleInfo: ModuleInfo,
+ private val logger: AbstractLogger
+) {
+ private val interfaces = mutableMapOf InterfaceBuilder?>()
+
+ fun buildInterface(name: String): InterfaceBuilder? {
+ return interfaces[name]?.invoke()
+ }
+
+ @JSFunction fun create(name: String, callback: Function) {
+ interfaces[name] = {
+ val interfaceBuilder = InterfaceBuilder()
+ runCatching {
+ Context.enter()
+ callback.call(Context.getCurrentContext(), callback, callback, arrayOf(interfaceBuilder))
+ Context.exit()
+ interfaceBuilder
+ }.onFailure {
+ logger.error("Failed to create interface $name for ${moduleInfo.name}", it)
+ }.getOrNull()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/Node.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/Node.kt
new file mode 100644
index 000000000..e127c26b9
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/Node.kt
@@ -0,0 +1,52 @@
+package me.rhunk.snapenhance.scripting.impl.ui.components
+
+open class Node(
+ val type: NodeType,
+) {
+ lateinit var uiChangeDetection: (key: String, value: Any?) -> Unit
+
+ val children = mutableListOf()
+ val attributes = object: HashMap() {
+ override fun put(key: String, value: Any?): Any? {
+ return super.put(key, value).also {
+ if (::uiChangeDetection.isInitialized) {
+ uiChangeDetection(key, value)
+ }
+ }
+ }
+ }
+
+ fun setAttribute(key: String, value: Any?) {
+ attributes[key] = value
+ }
+
+ fun fillMaxWidth(): Node {
+ attributes["fillMaxWidth"] = true
+ return this
+ }
+
+ fun fillMaxHeight(): Node {
+ attributes["fillMaxHeight"] = true
+ return this
+ }
+
+ fun label(text: String): Node {
+ attributes["label"] = text
+ return this
+ }
+
+ fun padding(padding: Int): Node {
+ attributes["padding"] = padding
+ return this
+ }
+
+ fun fontSize(size: Int): Node {
+ attributes["fontSize"] = size
+ return this
+ }
+
+ fun color(color: Long): Node {
+ attributes["color"] = color
+ return this
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt
new file mode 100644
index 000000000..d3dde3723
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt
@@ -0,0 +1,11 @@
+package me.rhunk.snapenhance.scripting.impl.ui.components
+enum class NodeType {
+ ROW,
+ COLUMN,
+ TEXT,
+ SWITCH,
+ BUTTON,
+ SLIDER,
+ LIST,
+ ACTION
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt
new file mode 100644
index 000000000..dd2cc9ba0
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt
@@ -0,0 +1,15 @@
+package me.rhunk.snapenhance.scripting.impl.ui.components.impl
+
+import me.rhunk.snapenhance.scripting.impl.ui.components.Node
+import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType
+
+enum class ActionType {
+ LAUNCHED,
+ DISPOSE
+}
+
+class ActionNode(
+ val actionType: ActionType,
+ val key: Any = Unit,
+ val callback: () -> Unit
+): Node(NodeType.ACTION)
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/RowColumnNode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/RowColumnNode.kt
new file mode 100644
index 000000000..ce6bb8612
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/RowColumnNode.kt
@@ -0,0 +1,47 @@
+package me.rhunk.snapenhance.scripting.impl.ui.components.impl
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.ui.Alignment
+import me.rhunk.snapenhance.scripting.impl.ui.components.Node
+import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType
+
+
+class RowColumnNode(
+ type: NodeType,
+) : Node(type) {
+ companion object {
+ private val arrangements = mapOf(
+ "start" to Arrangement.Start,
+ "end" to Arrangement.End,
+ "top" to Arrangement.Top,
+ "bottom" to Arrangement.Bottom,
+ "center" to Arrangement.Center,
+ "spaceBetween" to Arrangement.SpaceBetween,
+ "spaceAround" to Arrangement.SpaceAround,
+ "spaceEvenly" to Arrangement.SpaceEvenly,
+ )
+ private val alignments = mapOf(
+ "start" to Alignment.Start,
+ "end" to Alignment.End,
+ "top" to Alignment.Top,
+ "bottom" to Alignment.Bottom,
+ "centerVertically" to Alignment.CenterVertically,
+ "centerHorizontally" to Alignment.CenterHorizontally,
+ )
+ }
+
+ fun arrangement(arrangement: String): RowColumnNode {
+ attributes["arrangement"] = arrangements[arrangement] ?: throw IllegalArgumentException("Invalid arrangement")
+ return this
+ }
+
+ fun alignment(alignment: String): RowColumnNode {
+ attributes["alignment"] = alignments[alignment] ?: throw IllegalArgumentException("Invalid alignment")
+ return this
+ }
+
+ fun spacedBy(spacing: Int): RowColumnNode {
+ attributes["spacing"] = spacing
+ return this
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt
new file mode 100644
index 000000000..1c7f8d66e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt
@@ -0,0 +1,134 @@
+package me.rhunk.snapenhance.task
+
+
+enum class TaskType(
+ val key: String
+) {
+ DOWNLOAD("download"),
+ CHAT_ACTION("chat_action");
+
+ companion object {
+ fun fromKey(key: String): TaskType {
+ return entries.find { it.key == key } ?: throw IllegalArgumentException("Invalid key $key")
+ }
+ }
+}
+
+enum class TaskStatus(
+ val key: String
+) {
+ PENDING("pending"),
+ RUNNING("running"),
+ SUCCESS("success"),
+ FAILURE("failure"),
+ CANCELLED("cancelled");
+
+ fun isFinalStage(): Boolean {
+ return this == SUCCESS || this == FAILURE || this == CANCELLED
+ }
+
+ companion object {
+ fun fromKey(key: String): TaskStatus {
+ return entries.find { it.key == key } ?: throw IllegalArgumentException("Invalid key $key")
+ }
+ }
+}
+
+data class PendingTaskListener(
+ val onSuccess: () -> Unit = {},
+ val onCancel: () -> Unit = {},
+ val onProgress: (label: String?, progress: Int) -> Unit = { _, _ -> },
+ val onStateChange: (status: TaskStatus) -> Unit = {},
+)
+
+data class Task(
+ val type: TaskType,
+ val title: String,
+ val hash: String
+) {
+ var changeListener: () -> Unit = {}
+
+ var extra: String? = null
+ set(value) {
+ field = value
+ changeListener()
+ }
+ var status: TaskStatus = TaskStatus.PENDING
+ set(value) {
+ field = value
+ changeListener()
+ }
+}
+
+class PendingTask(
+ val taskId: Long,
+ val task: Task
+) {
+ private val listeners = mutableListOf()
+
+ fun addListener(listener: PendingTaskListener) {
+ synchronized(listeners) { listeners.add(listener) }
+ }
+
+ fun removeListener(listener: PendingTaskListener) {
+ synchronized(listeners) { listeners.remove(listener) }
+ }
+
+ var status
+ get() = task.status;
+ set(value) {
+ task.status = value;
+ synchronized(listeners) {
+ listeners.forEach { it.onStateChange(value) }
+ }
+ }
+
+ var progressLabel: String? = null
+ set(value) {
+ field = value
+ synchronized(listeners) {
+ listeners.forEach { it.onProgress(value, progress) }
+ }
+ }
+
+ private var _progress = 0
+ set(value) {
+ assert(value in 0..100 || value == -1)
+ field = value
+ }
+
+ var progress get() = _progress
+ set(value) {
+ _progress = value
+ synchronized(listeners) {
+ listeners.forEach { it.onProgress(progressLabel, value) }
+ }
+ }
+
+ fun updateProgress(label: String, progress: Int = -1) {
+ _progress = progress
+ progressLabel = label
+ }
+
+ fun fail(reason: String) {
+ status = TaskStatus.FAILURE
+ synchronized(listeners) {
+ listeners.forEach { it.onCancel() }
+ }
+ updateProgress(reason)
+ }
+
+ fun success() {
+ status = TaskStatus.SUCCESS
+ synchronized(listeners) {
+ listeners.forEach { it.onSuccess() }
+ }
+ }
+
+ fun cancel() {
+ status = TaskStatus.CANCELLED
+ synchronized(listeners) {
+ listeners.forEach { it.onCancel() }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt
new file mode 100644
index 000000000..62b8e2a1e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt
@@ -0,0 +1,134 @@
+package me.rhunk.snapenhance.task
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import me.rhunk.snapenhance.RemoteSideContext
+import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
+import me.rhunk.snapenhance.common.util.ktx.getLong
+import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
+import java.util.concurrent.Executors
+import kotlin.coroutines.suspendCoroutine
+
+class TaskManager(
+ private val remoteSideContext: RemoteSideContext
+) {
+ private lateinit var taskDatabase: SQLiteDatabase
+ private val queueExecutor = Executors.newSingleThreadExecutor()
+
+ fun init() {
+ taskDatabase = remoteSideContext.androidContext.openOrCreateDatabase("tasks", Context.MODE_PRIVATE, null).apply {
+ SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf(
+ "tasks" to listOf(
+ "id INTEGER PRIMARY KEY AUTOINCREMENT",
+ "hash VARCHAR UNIQUE",
+ "title VARCHAR(255) NOT NULL",
+ "type VARCHAR(255) NOT NULL",
+ "status VARCHAR(255) NOT NULL",
+ "extra TEXT"
+ )
+ ))
+ }
+ }
+
+ private val activeTasks = mutableMapOf()
+
+ private fun readTaskFromCursor(cursor: android.database.Cursor): Task {
+ val task = Task(TaskType.fromKey(cursor.getStringOrNull("type")!!), cursor.getStringOrNull("title")!!, cursor.getStringOrNull("hash")!!)
+ task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
+ task.extra = cursor.getStringOrNull("extra")
+ task.changeListener = {
+ updateTask(cursor.getLong("id"), task)
+ }
+ return task
+ }
+
+ private fun putNewTask(task: Task): Long {
+ return runBlocking {
+ suspendCoroutine {
+ queueExecutor.execute {
+ val result = taskDatabase.insert("tasks", null, ContentValues().apply {
+ put("type", task.type.key)
+ put("hash", task.hash)
+ put("title", task.title)
+ put("status", task.status.key)
+ put("extra", task.extra)
+ })
+ it.resumeWith(Result.success(result))
+ }
+ }
+ }
+ }
+
+ private fun updateTask(id: Long, task: Task) {
+ queueExecutor.execute {
+ taskDatabase.execSQL("UPDATE tasks SET status = ?, extra = ? WHERE id = ?",
+ arrayOf(
+ task.status.key,
+ task.extra,
+ id.toString()
+ )
+ )
+ }
+ }
+
+ fun clearAllTasks() {
+ runBlocking {
+ launch(queueExecutor.asCoroutineDispatcher()) {
+ taskDatabase.execSQL("DELETE FROM tasks")
+ }
+ }
+ }
+
+ fun createPendingTask(task: Task): PendingTask {
+ val taskId = putNewTask(task)
+ task.changeListener = {
+ updateTask(taskId, task)
+ }
+
+ val pendingTask = PendingTask(taskId, task)
+ activeTasks[taskId] = pendingTask
+ return pendingTask
+ }
+
+ fun getTaskByHash(hash: String?): Task? {
+ if (hash == null) return null
+ taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
+ if (cursor.moveToNext()) {
+ return readTaskFromCursor(cursor)
+ }
+ }
+ return null
+ }
+
+ fun getActiveTasks() = activeTasks
+
+ fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map {
+ val tasks = mutableMapOf()
+ val invalidTasks = mutableListOf()
+
+ taskDatabase.rawQuery("SELECT * FROM tasks WHERE id < ? ORDER BY id DESC LIMIT ?", arrayOf(lastId.toString(), limit.toString())).use { cursor ->
+ while (cursor.moveToNext()) {
+ runCatching {
+ val task = readTaskFromCursor(cursor)
+ if (!task.status.isFinalStage()) { task.status = TaskStatus.FAILURE }
+ tasks[cursor.getLong("id")] = task
+ }.onFailure {
+ invalidTasks.add(cursor.getLong("id"))
+ remoteSideContext.log.warn("Failed to read task ${cursor.getLong("id")}")
+ }
+ }
+ }
+
+ invalidTasks.forEach {
+ queueExecutor.execute {
+ taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(it.toString()))
+ }
+ }
+
+ return tasks
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/MapActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/MapActivity.kt
deleted file mode 100644
index fb93f395f..000000000
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/MapActivity.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package me.rhunk.snapenhance.ui
-
-import android.annotation.SuppressLint
-import android.app.Activity
-import android.app.AlertDialog
-import android.content.Context
-import android.os.Bundle
-import android.view.MotionEvent
-import android.widget.Button
-import android.widget.EditText
-import me.rhunk.snapenhance.core.R
-import org.osmdroid.config.Configuration
-import org.osmdroid.tileprovider.tilesource.TileSourceFactory
-import org.osmdroid.util.GeoPoint
-import org.osmdroid.views.MapView
-import org.osmdroid.views.Projection
-import org.osmdroid.views.overlay.Marker
-import org.osmdroid.views.overlay.Overlay
-
-
-class MapActivity : Activity() {
-
- private lateinit var mapView: MapView
-
- @SuppressLint("MissingInflatedId", "ResourceType")
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- val contextBundle = intent.extras?.getBundle("location") ?: return
- val locationLatitude = contextBundle.getDouble("latitude")
- val locationLongitude = contextBundle.getDouble("longitude")
-
- Configuration.getInstance().load(applicationContext, getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
-
- setContentView(R.layout.map)
-
- mapView = findViewById(R.id.mapView)
- mapView.setMultiTouchControls(true);
- mapView.setTileSource(TileSourceFactory.MAPNIK)
-
- val startPoint = GeoPoint(locationLatitude, locationLongitude)
- mapView.controller.setZoom(10.0)
- mapView.controller.setCenter(startPoint)
-
- val marker = Marker(mapView)
- marker.isDraggable = true
- marker.position = startPoint
- marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
-
- mapView.overlays.add(object: Overlay() {
- override fun onSingleTapConfirmed(e: MotionEvent?, mapView: MapView?): Boolean {
- val proj: Projection = mapView!!.projection
- val loc = proj.fromPixels(e!!.x.toInt(), e.y.toInt()) as GeoPoint
- marker.position = loc
- mapView.invalidate()
- return true
- }
- })
-
- mapView.overlays.add(marker)
-
- val applyButton = findViewById