package me.eternal.purrfect 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.os.Handler import android.os.Looper 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 kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import me.eternal.purrfect.bridge.BridgeService import me.eternal.purrfect.common.BuildConfig import me.eternal.purrfect.common.Constants import me.eternal.purrfect.common.ReceiversConfig import me.eternal.purrfect.common.TargetApp import me.eternal.purrfect.common.action.EnumAction import me.eternal.purrfect.common.bridge.wrapper.LocaleWrapper import me.eternal.purrfect.common.bridge.wrapper.LoggerWrapper import me.eternal.purrfect.common.bridge.wrapper.MappingsWrapper import me.eternal.purrfect.common.config.ModConfig import me.eternal.purrfect.common.config.impl.RootConfig import me.eternal.purrfect.common.logger.fatalCrash import me.eternal.purrfect.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.eternal.purrfect.common.util.constantLazyBridge import me.eternal.purrfect.common.util.getPurgeTime import me.eternal.purrfect.e2ee.E2EEImplementation import me.eternal.purrfect.scripting.RemoteScriptManager import me.eternal.purrfect.storage.AppDatabase import me.eternal.purrfect.task.RemoteTaskInterface import me.eternal.purrfect.task.TaskManager import me.eternal.purrfect.ui.manager.MainActivity import me.eternal.purrfect.ui.manager.data.InstallationSummary import me.eternal.purrfect.ui.manager.data.ModInfo import me.eternal.purrfect.ui.manager.data.PlatformInfo import me.eternal.purrfect.ui.manager.data.SnapchatAppInfo import me.eternal.purrfect.ui.overlay.RemoteOverlay import me.eternal.purrfect.ui.setup.Requirements import me.eternal.purrfect.ui.setup.SetupActivity import me.eternal.purrfect.ui.setup.SetupPreferences import me.eternal.purrfect.task.AnnouncementCheckWorker import java.io.ByteArrayInputStream import java.lang.ref.WeakReference import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import com.tonyodev.fetch2.Fetch import com.tonyodev.fetch2.FetchConfiguration import java.io.File import java.util.concurrent.TimeUnit class RemoteSideContext( val androidContext: Context ) { val fetch: Fetch by lazy { val fetchConfiguration = FetchConfiguration.Builder(androidContext) .setDownloadConcurrentLimit(3) .build() Fetch.getInstance(fetchConfiguration) } val coroutineScope = CoroutineScope(Dispatchers.IO) private var _activity: WeakReference? = null var bridgeService: BridgeService? = null var activity: ComponentActivity? get() = _activity?.get() set(value) { _activity?.clear(); _activity = WeakReference(value) } val sharedPreferences: SharedPreferences get() = androidContext.getSharedPreferences("prefs", 0) private var targetAppOverride: TargetApp? = null val activeTargetApp: TargetApp get() = targetAppOverride ?: TargetApp.fromKey(sharedPreferences.getString(TargetApp.PREF_KEY, TargetApp.SNAPCHAT.key)) val isRedditMode: Boolean get() = activeTargetApp == TargetApp.REDDIT val isLimitedTargetMode: Boolean get() = activeTargetApp != TargetApp.SNAPCHAT val fileHandleManager = RemoteFileHandleManager(this) val database = AppDatabase(this) val trackerDataManager = me.eternal.purrfect.storage.TrackerDataManagerImpl(database) val config = ModConfig(androidContext, constantLazyBridge { fileHandleManager }) val translation = LocaleWrapper(androidContext, constantLazyBridge { fileHandleManager }) val mappings = MappingsWrapper(constantLazyBridge { fileHandleManager }) val taskManager = TaskManager(this) val taskInterface = RemoteTaskInterface(this) val streaksReminder = StreaksReminder(this) val log = LogManager(this) val scriptManager = RemoteScriptManager(this) val remoteOverlay = RemoteOverlay(this) val e2eeImplementation = E2EEImplementation(this) val messageLogger by lazy { LoggerWrapper(androidContext) } val tracker = RemoteTracker(this) val accountStorage = RemoteAccountStorage(this) val locationManager = RemoteLocationManager(this) init { val prefs = androidContext.getSharedPreferences("prefs", 0) if (!prefs.contains("debug_test_mode")) { prefs.edit().putBoolean("debug_test_mode", true).apply() } } //used to load bitmoji selfies and download previews val imageLoader by lazy { ImageLoader.Builder(androidContext) .dispatcher(Dispatchers.IO) .memoryCache { MemoryCache.Builder(androidContext) .maxSizePercent(0.25) .build() } .diskCache { DiskCache.Builder() .directory(androidContext.cacheDir.resolve("coil-disk-cache")) .maxSizeBytes(1024 * 1024 * 100) // 100MB .build() } .components { add(VideoFrameDecoder.Factory()) }.build() } val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() } fun reload() { runCatching { runBlocking(Dispatchers.IO) { log.init() log.verbose("Loading RemoteSideContext") config.load() config.root.reddit.migrateLegacyFlags() mirrorRedditFeaturePrefs() ensureAutoUpdateCheckOnUpgrade() launch { mappings.apply { init(androidContext) } } translation.apply { userLocale = config.locale load() } scheduleAnnouncementCheck() database.init() streaksReminder.init() scriptManager.init() launch { taskManager.init() config.root.messaging.messageLogger.takeIf { it.globalState == true }?.autoPurge?.let { getPurgeTime(it.getNullable()) }?.let { messageLogger.purgeAll(it) } config.root.friendTracker.takeIf { it.globalState == true }?.autoPurge?.let { getPurgeTime(it.getNullable()) }?.let { messageLogger.purgeTrackerLogs(it) } } } }.onFailure { log.error("Failed to load RemoteSideContext", it) androidContext.fatalCrash(it) } scriptManager.runtime.eachModule { callFunction("module.onPurrfectLoad", androidContext) } } val installationSummary by lazy { InstallationSummary( snapchatInfo = mappings.getSnapchatPackageInfo()?.let { val packageName = requireNotNull(it.packageName) { "Package name cannot be null" } SnapchatAppInfo( packageName = packageName, version = it.versionName ?: "unknown", 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"), gitHash = BuildConfig.GIT_HASH, isDebugBuild = BuildConfig.DEBUG, mappingVersion = mappings.getGeneratedBuildNumber(), mappingsOutdated = mappings.isMappingsOutdated() ), platformInfo = PlatformInfo( device = Build.DEVICE, androidVersion = Build.VERSION.RELEASE, systemAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" ) ) } fun longToast(message: Any) { androidContext.mainExecutor.execute { Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() } log.debug(message.toString()) } fun shortToast(message: Any) { androidContext.mainExecutor.execute { Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() } log.debug(message.toString()) } fun hasMessagingBridge() = bridgeService != null && bridgeService?.messagingBridge != null && bridgeService?.messagingBridge?.asBinder()?.pingBinder() == true fun checkForRequirements(overrideRequirements: Int? = null): Boolean { var requirements = overrideRequirements ?: 0 if (sharedPreferences.getBoolean("setup_in_progress", false)) { requirements = requirements or Requirements.FIRST_RUN } if (!config.wasPresent) { requirements = requirements or Requirements.FIRST_RUN } val shouldCheckSnapchatSetup = activeTargetApp == TargetApp.SNAPCHAT || SetupPreferences.hasCompletedTarget(sharedPreferences, TargetApp.SNAPCHAT) if (shouldCheckSnapchatSetup) { config.root.downloader.saveFolder.get().let { val allowDefaultSaveFolder = sharedPreferences.getBoolean("downloader_use_default_save_folder", false) if (it.isEmpty()) { if (!allowDefaultSaveFolder) { requirements = requirements or Requirements.SAVE_FOLDER } } else if (run { val documentFile = runCatching { DocumentFile.fromTreeUri(androidContext, Uri.parse(it)) }.getOrNull() documentFile == null || !documentFile.exists() || !documentFile.canWrite() }) { requirements = requirements or Requirements.SAVE_FOLDER } } if (!sharedPreferences.getBoolean("debug_disable_mapper", false) && mappings.getSnapchatPackageInfo() != null && mappings.isMappingsOutdated()) { requirements = requirements or Requirements.MAPPINGS } } if (requirements == 0) return false val currentContext = activity ?: androidContext Intent(currentContext, SetupActivity::class.java).apply { putExtra("requirements", requirements) if (currentContext !is Activity) { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } currentContext.startActivity(this) return true } } fun launchActionIntent(action: EnumAction) { val intent = androidContext.packageManager.getLaunchIntentForPackage( Constants.SNAPCHAT_PACKAGE_NAME ) if (intent == null) { shortToast(translation["toast_snapchat_not_installed"]) return } intent.putExtra(EnumAction.ACTION_PARAMETER, action.key) androidContext.startActivity(intent) } fun openTargetPackage(packageName: String, appLabel: String) { val intent = androidContext.packageManager .getLaunchIntentForPackage(packageName) ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) if (intent == null) { shortToast("$appLabel is not installed") return } androidContext.startActivity(intent) } fun forceStopTargetPackage(packageName: String, appLabel: String) { if (packageName != Constants.REDDIT_PACKAGE_NAME) { shortToast("Force stop is only available for Reddit") return } runCatching { androidContext.sendBroadcast( Intent(Constants.REDDIT_FORCE_STOP_ACTION) .setPackage(Constants.REDDIT_PACKAGE_NAME) ) }.onSuccess { shortToast("Close signal sent to $appLabel") }.onFailure { log.warn("Failed to send close signal to $appLabel: ${it.message}") shortToast("Failed to signal $appLabel") } } fun setActiveTargetApp(targetApp: TargetApp) { sharedPreferences.edit().putString(TargetApp.PREF_KEY, targetApp.key).apply() } fun setTargetAppOverride(targetApp: TargetApp?) { targetAppOverride = targetApp } fun mirrorRedditFeaturePrefs() { runCatching { val redditJson = getRedditFeaturesJson() File(androidContext.filesDir, REDDIT_FEATURE_CONFIG_FILE).apply { parentFile?.mkdirs() writeText(redditJson, Charsets.UTF_8) } redditFeatureExternalFiles().forEach { file -> runCatching { file.parentFile?.mkdirs() file.writeText(redditJson, Charsets.UTF_8) file.parentFile?.setReadable(true, false) file.setReadable(true, false) }.onFailure { log.warn("Failed to mirror Reddit feature config to ${file.absolutePath}: ${it.message}") } } androidContext.getSharedPreferences(REDDIT_FEATURE_PREFS, Context.MODE_PRIVATE) .edit() .putBoolean("block_promoted_posts", config.root.reddit.blockPromotedPostsEnabled()) .putBoolean("block_comment_ads", config.root.reddit.blockCommentAdsEnabled()) .putBoolean("unlock_reddit_premium", config.root.reddit.unlockRedditPremiumEnabled()) .putBoolean("open_links_in_external_browser", config.root.reddit.openLinksInExternalBrowserEnabled()) .putBoolean("disable_screenshot_popup", config.root.reddit.disableScreenshotPopupEnabled()) .putBoolean("hide_answers_button", false) .putBoolean("hide_chat_button", false) .putBoolean("hide_create_button", config.root.reddit.hideCreateButtonEnabled()) .putBoolean("hide_discover_communities_button", false) .putBoolean("hide_games_button", false) .putBoolean("hide_recently_visited_shelf", config.root.reddit.hideRecentlyVisitedShelfEnabled()) .putBoolean("hide_games_on_reddit_shelf", config.root.reddit.hideGamesOnRedditShelfEnabled()) .putBoolean("hide_reddit_pro_shelf", config.root.reddit.hideRedditProShelfEnabled()) .putBoolean("hide_about_shelf", config.root.reddit.hideAboutShelfEnabled()) .putBoolean("hide_resources_shelf", config.root.reddit.hideResourcesShelfEnabled()) .putBoolean("hide_recommended_communities", false) .putBoolean("hide_trending_today_shelf", config.root.reddit.hideTrendingTodayShelfEnabled()) .putBoolean("remove_nsfw_warning_dialog", config.root.reddit.removeNsfwWarningDialogEnabled()) .putBoolean("remove_notification_suggestion_dialog", config.root.reddit.removeNotificationSuggestionDialogEnabled()) .putBoolean("sanitize_sharing_links", config.root.reddit.sanitizeSharingLinksEnabled()) .putBoolean("add_scroll_to_top_button", config.root.reddit.addScrollToTopButtonEnabled()) .putBoolean("color_coded_comment_threads", false) .putBoolean("restore_deleted_content", false) .commit() val prefsFile = File(androidContext.applicationInfo.dataDir, "shared_prefs/$REDDIT_FEATURE_PREFS.xml") File(androidContext.applicationInfo.dataDir).setExecutable(true, false) File(androidContext.applicationInfo.dataDir).setReadable(true, false) prefsFile.parentFile?.setExecutable(true, false) prefsFile.parentFile?.setReadable(true, false) prefsFile.setReadable(true, false) broadcastRedditFeaturePrefs(redditJson) log.verbose("Mirrored Reddit feature config JSON") }.onFailure { log.error("Failed to mirror Reddit feature prefs", it) } } private fun broadcastRedditFeaturePrefs(json: String) { runCatching { androidContext.sendBroadcast( Intent(Constants.REDDIT_CONFIG_UPDATE_ACTION) .setPackage(Constants.REDDIT_PACKAGE_NAME) .putExtra(Constants.REDDIT_CONFIG_JSON_EXTRA, json) ) }.onFailure { log.warn("Failed to broadcast Reddit feature config: ${it.message}") } } fun getRedditFeaturesJson(): String { return Gson().toJson( mapOf( "block_promoted_posts" to config.root.reddit.blockPromotedPostsEnabled(), "block_comment_ads" to config.root.reddit.blockCommentAdsEnabled(), "unlock_reddit_premium" to config.root.reddit.unlockRedditPremiumEnabled(), "open_links_in_external_browser" to config.root.reddit.openLinksInExternalBrowserEnabled(), "disable_screenshot_popup" to config.root.reddit.disableScreenshotPopupEnabled(), "hide_answers_button" to false, "hide_chat_button" to false, "hide_create_button" to config.root.reddit.hideCreateButtonEnabled(), "hide_discover_communities_button" to false, "hide_games_button" to false, "hide_recently_visited_shelf" to config.root.reddit.hideRecentlyVisitedShelfEnabled(), "hide_games_on_reddit_shelf" to config.root.reddit.hideGamesOnRedditShelfEnabled(), "hide_reddit_pro_shelf" to config.root.reddit.hideRedditProShelfEnabled(), "hide_about_shelf" to config.root.reddit.hideAboutShelfEnabled(), "hide_resources_shelf" to config.root.reddit.hideResourcesShelfEnabled(), "hide_recommended_communities" to false, "hide_trending_today_shelf" to config.root.reddit.hideTrendingTodayShelfEnabled(), "remove_nsfw_warning_dialog" to config.root.reddit.removeNsfwWarningDialogEnabled(), "remove_notification_suggestion_dialog" to config.root.reddit.removeNotificationSuggestionDialogEnabled(), "sanitize_sharing_links" to config.root.reddit.sanitizeSharingLinksEnabled(), "add_scroll_to_top_button" to config.root.reddit.addScrollToTopButtonEnabled(), "color_coded_comment_threads" to false, "restore_deleted_content" to false ) ) } fun resetActiveTargetConfig() { if (isRedditMode) { val defaults = RootConfig().apply { lateInit(androidContext) } config.root.reddit.fromJson(defaults.reddit.toJson()) } else { val redditConfig = config.root.reddit.toJson() config.reset() config.root.reddit.fromJson(redditConfig) } config.root.reddit.migrateLegacyFlags() config.writeConfig() mirrorRedditFeaturePrefs() } private fun redditFeatureExternalFiles(): List { return listOf( File("/storage/emulated/0/Android/media/${BuildConfig.APPLICATION_ID}/$REDDIT_FEATURE_CONFIG_FILE"), File("/sdcard/Android/media/${BuildConfig.APPLICATION_ID}/$REDDIT_FEATURE_CONFIG_FILE") ).distinctBy { it.absolutePath } } fun requestSocialSnapshotRefresh( openSnapchatFirst: Boolean = true, snapchatWarmupDelayMs: Long = 1200L, returnDelayMs: Long = 1200L ) { fun sendSocialSnapshotBroadcast() { runCatching { androidContext.sendBroadcast( SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {} ) }.onFailure { log.error("Failed to request latest social snapshot", it) } } if (!openSnapchatFirst) { sendSocialSnapshotBroadcast() return } val snapchatIntent = androidContext.packageManager .getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) if (snapchatIntent == null) { shortToast(translation["toast_snapchat_not_installed"]) sendSocialSnapshotBroadcast() return } val returnIntent = Intent(androidContext, MainActivity::class.java).apply { addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP ) } val mainHandler = Handler(Looper.getMainLooper()) runCatching { androidContext.startActivity(snapchatIntent) mainHandler.postDelayed( { runCatching { androidContext.startActivity(returnIntent) }.onFailure { log.error("Failed to return to Purrfect after Snapchat handoff", it) } mainHandler.postDelayed( { sendSocialSnapshotBroadcast() }, returnDelayMs ) }, snapchatWarmupDelayMs ) }.onFailure { log.error("Failed to launch Snapchat for social snapshot refresh", it) sendSocialSnapshotBroadcast() } } private fun scheduleAnnouncementCheck() { val workManager = WorkManager.getInstance(androidContext) val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val inputData = Data.Builder() .putString("announcements_url", "https://raw.githubusercontent.com/particle-box/Purrfect/dev/announcements.txt") .putString("announcements_fallback_url", "https://raw.githubusercontent.com/curious-freak/Purrfect/dev/announcements.txt") .putString("channel_name", "Announcements") .putString("channel_description", "Notifications for Purrfect announcements") .putString("notification_title", "New announcement available") .putString("notification_text", "Tap to open and read.") .build() val workRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) .setConstraints(constraints) .setInputData(inputData) .build() workManager.enqueueUniquePeriodicWork( "purrfect_announcement_check", ExistingPeriodicWorkPolicy.REPLACE, workRequest ) } private fun ensureAutoUpdateCheckOnUpgrade() { val currentVersion = BuildConfig.VERSION_CODE.toLong() val lastVersion = sharedPreferences.getLong("last_build_version_code", -1L) val reenabledOnce = sharedPreferences.getBoolean("auto_update_reenabled_once", false) if (lastVersion == currentVersion) return if (!reenabledOnce) { config.root.global.updateSettings.autoUpdateCheck.set(true) config.writeConfig() sharedPreferences.edit() .putBoolean("auto_update_reenabled_once", true) .apply() } sharedPreferences.edit().putLong("last_build_version_code", currentVersion).apply() } fun syncSkinSettings() { runCatching { val uiSettings = config.root.global.uiSettings sharedPreferences.edit() .putString("manager_theme", uiSettings.managerTheme.get()) .putString("aphelion_skin", uiSettings.aphelionSkin.get()) .putString("lumina_mode", uiSettings.luminaMode.get()) .putString("lumina_accent", uiSettings.luminaAccent.get()) .putString("aether_mode", uiSettings.aetherMode.get()) .putString("aether_accent", uiSettings.aetherAccent.get()) .putBoolean("aether_amoled", uiSettings.aetherAmoled.get()) .putString("cyberware_style", uiSettings.cyberwareStyle.get()) .apply() } } companion object { const val REDDIT_FEATURE_PREFS = "reddit_features" const val REDDIT_FEATURE_CONFIG_FILE = "reddit_features.json" } }