Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified app/libs/snapmod.jar
Binary file not shown.
63 changes: 63 additions & 0 deletions app/src/main/java/xyz/rodit/snapmod/arroyo/ArroyoReader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package xyz.rodit.snapmod.arroyo

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.util.Base64
import xyz.rodit.snapmod.logging.log
import xyz.rodit.snapmod.util.ProtoReader
import java.io.File

class ArroyoReader(private val context: Context) {

fun getMessageContent(conversationId: String, messageId: String): String? {
val blob = getMessageBlob(conversationId, messageId) ?: return null
return followProtoString(blob, 4, 4, 2, 1)
}

fun getKeyAndIv(conversationId: String, messageId: String): Pair<ByteArray, ByteArray>? {
val blob = getMessageBlob(conversationId, messageId) ?: return null
val key = followProtoString(blob, 4, 4, 3, 3, 5, 1, 1, 4, 1) ?: return null
val iv = followProtoString(blob, 4, 4, 3, 3, 5, 1, 1, 4, 2) ?: return null
return Base64.decode(key, Base64.DEFAULT) to Base64.decode(iv, Base64.DEFAULT)
}

fun getSnapKeyAndIv(conversationId: String, messageId: String): Pair<ByteArray, ByteArray>? {
val blob = getMessageBlob(conversationId, messageId) ?: return null
val key = followProto(blob, 4, 4, 11, 5, 1, 1, 19, 1) ?: return null
val iv = followProto(blob, 4, 4, 11, 5, 1, 1, 19, 2) ?: return null
return key to iv
}

private fun followProtoString(data: ByteArray, vararg indices: Int): String? {
val proto = followProto(data, *indices)
return if (proto != null) String(proto) else null
}

private fun followProto(data: ByteArray, vararg indices: Int): ByteArray? {
var current = data
indices.forEach { i ->
val parts = ProtoReader(current).read()
current = parts.firstOrNull { it.index == i }?.value ?: return null
}

return current
}

private fun getMessageBlob(conversationId: String, messageId: String): ByteArray? {
SQLiteDatabase.openDatabase(
File(context.filesDir, "../databases/arroyo.db").path,
null,
0
).use {
it.rawQuery(
"SELECT message_content FROM conversation_message WHERE client_conversation_id='$conversationId' AND server_message_id=$messageId",
null
).use { cursor ->
if (cursor.moveToFirst()) return cursor.getBlob(0)
else log.debug("No result in db.")
}
}

return null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import xyz.rodit.snapmod.features.friendsfeed.FeedModifier
import xyz.rodit.snapmod.features.info.AdditionalFriendInfo
import xyz.rodit.snapmod.features.info.NetworkLogging
import xyz.rodit.snapmod.features.messagemenu.MessageMenuModifier
import xyz.rodit.snapmod.features.notifications.FilterTypes
import xyz.rodit.snapmod.features.notifications.ShowMessageContent
import xyz.rodit.snapmod.features.opera.OperaModelModifier
import xyz.rodit.snapmod.features.saving.ChatSaving
import xyz.rodit.snapmod.features.saving.PublicProfileSaving
Expand Down Expand Up @@ -35,6 +37,10 @@ class FeatureManager(context: FeatureContext) : Contextual(context) {
// Message context menu
add(::MessageMenuModifier)

// Notifications
add(::FilterTypes)
add(::ShowMessageContent)

// Opera (story/snap view)
add(::OperaModelModifier)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package xyz.rodit.snapmod.features.notifications

import xyz.rodit.snapmod.features.Feature
import xyz.rodit.snapmod.features.FeatureContext
import xyz.rodit.snapmod.mappings.NotificationData
import xyz.rodit.snapmod.mappings.NotificationHandler
import xyz.rodit.snapmod.util.before
import xyz.rodit.snapmod.util.getList

class FilterTypes(context: FeatureContext) : Feature(context) {

private val hiddenTypes = hashSetOf<String>()

override fun onConfigLoaded(first: Boolean) {
hiddenTypes.clear()
hiddenTypes.addAll(context.config.getList("filtered_notification_types"))
}

override fun performHooks() {
NotificationHandler.handle.before {
if (hiddenTypes.isEmpty()) return@before

val handler = NotificationHandler.wrap(it.thisObject)
val data = NotificationData.wrap(handler.data)
val bundle = data.bundle

val type =
bundle.getString("type") ?: bundle.getString("n_key")?.split('~')?.get(0) ?: ""
if (hiddenTypes.contains(type.lowercase())) {
it.result = null
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package xyz.rodit.snapmod.features.notifications

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.ThumbnailUtils
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Size
import androidx.core.app.NotificationCompat
import com.google.gson.Gson
import com.google.gson.JsonArray
import xyz.rodit.snapmod.Shared
import xyz.rodit.snapmod.arroyo.ArroyoReader
import xyz.rodit.snapmod.features.Feature
import xyz.rodit.snapmod.features.FeatureContext
import xyz.rodit.snapmod.logging.log
import xyz.rodit.snapmod.mappings.*
import xyz.rodit.snapmod.util.before
import java.io.File
import java.io.InputStream
import java.net.URL

private const val CHANNEL_ID = "snapmod_notifications"

private val imageSig = listOf(
"ffd8ff", // jpeg
"1a45dfa3", // webm
"89504e47", // png
)

class ShowMessageContent(context: FeatureContext) : Feature(context) {

private val arroyoReader = ArroyoReader(context.appContext)
private val gson: Gson = Gson()

private val notifications
get() = context.appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

private var notificationSmallIcon = 0
private var notificationId = 0

override fun init() {
notificationSmallIcon =
context.appContext.resources.getIdentifier("icon_v6", "mipmap", Shared.SNAPCHAT_PACKAGE)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notifications.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"SnapMod Custom Notifications",
NotificationManager.IMPORTANCE_HIGH
)
)
}
}

override fun performHooks() {
NotificationHandler.handle.before(context, "show_notification_content") {
val handler = NotificationHandler.wrap(it.thisObject)
val data = NotificationData.wrap(handler.data)
val messageHandler = MessagingNotificationHandler.wrap(handler.handler)
val idProvider = ConversationIdProvider.wrap(handler.conversationIdProvider)
val bundle = data.bundle
val type = bundle.get("type") ?: "unknown"
val conversationId = bundle.getString("arroyo_convo_id")
val messageId = bundle.getString("arroyo_message_id")
if (conversationId.isNullOrBlank() || messageId.isNullOrBlank()) return@before
bundle.getString("media_info")?.let { mediaInfo ->
if (!context.config.getBoolean("show_notification_media_previews")) return@before

val snap = type == "snap"
val (key, iv) = (if (snap)
arroyoReader.getSnapKeyAndIv(conversationId, messageId)
else
arroyoReader.getKeyAndIv(conversationId, messageId)) ?: return@before

val media = getDownloadUrls(mediaInfo)
val crypt = AesCrypto(key, iv)
crypt.decrypt(URL(media[0]).openStream()).use { stream ->
val (bitmap, isImage) = generatePreview(stream)
if (bitmap == null) return@before

val feedId = messageHandler.conversationRepository.getFeedId(conversationId)
val group = idProvider.conversationIdentifier.group

val title = (bundle.getString("ab_cnotif_body") ?: "sent Media") +
if (snap)
" (${if (isImage) "Image" else "Video"})"
else
" (Media x ${media.size})"

val notification = NotificationCompat.Builder(context.appContext, CHANNEL_ID)
.setContentTitle(bundle.getString("sender") ?: "Unknown Sender")
.setContentText(title)
.setSmallIcon(notificationSmallIcon)
.setLargeIcon(bitmap)
.setContentIntent(createContentIntent(feedId, bundle, group))
.setAutoCancel(true)
.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null)
)
.build()

notifications.notify(notificationId++, notification)
it.result = null
}

return@before
}

val content = arroyoReader.getMessageContent(conversationId, messageId) ?: return@before
bundle.putString("ab_cnotif_body", content)
}
}

private fun getDownloadUrls(mediaInfoJson: String): List<String> {
val json = gson.fromJson(mediaInfoJson, JsonArray::class.java)
return json.map { it.asJsonObject.get("directDownloadUrl").asString }.toList()
}

private fun createContentIntent(feedId: Long, bundle: Bundle, group: Boolean): PendingIntent {
val conversationId = bundle.getString("arroyo_convo_id") ?: ""
val uri = Uri.parse("snapchat://notification/notification_chat/").buildUpon()
.appendQueryParameter("feed-id", feedId.toString())
.appendQueryParameter("conversation-id", conversationId)
.appendQueryParameter("is-group", group.toString())
.appendQueryParameter("source_type", "CHAT")
.build()
val intent = Intent("android.intent.action.VIEW_CHAT", uri)
.setClassName(Shared.SNAPCHAT_PACKAGE, Shared.SNAPCHAT_PACKAGE + ".LandingPageActivity")
.putExtra("messageId", bundle.getString("chat_message_id"))
.putExtra("type", "CHAT")
.putExtra("fromServerNotification", true)
.putExtra("notificationId", bundle.getString("n_id"))

val flags = PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(context.appContext, 0, intent, flags)
}

private fun generatePreview(stream: InputStream): Pair<Bitmap?, Boolean> {
val temp = File.createTempFile("snapmod", "tmp", context.appContext.cacheDir)
temp.outputStream().use(stream::copyTo)

val sig = ByteArray(4)
temp.inputStream().use { it.read(sig) }
val sigString = sig.joinToString("") { "%02x".format(it) }

val isImage = imageSig.any { sigString.startsWith(it) }
val preview =
if (isImage) BitmapFactory.decodeFile(temp.path) else generateVideoPreview(temp)

temp.delete()
return preview to isImage
}

private fun generateVideoPreview(file: File): Bitmap? {
try {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ThumbnailUtils.createVideoThumbnail(
file,
Size(1024, 1024),
null
)
} else {
ThumbnailUtils.createVideoThumbnail(
file.path,
MediaStore.Images.Thumbnails.MINI_KIND
)
}
} catch (e: Exception) {
log.error("Error creating video thumbnail.", e)
}

return null
}
}
89 changes: 89 additions & 0 deletions app/src/main/java/xyz/rodit/snapmod/util/ProtoReader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package xyz.rodit.snapmod.util

import xyz.rodit.snapmod.logging.log
import java.math.BigInteger

private val BIGINT_2 = BigInteger.valueOf(2)
private const val TYPE_VAR_INT = 0
private const val TYPE_STRING = 2

internal inline infix fun Byte.and(other: Int): Int = toInt() and other

internal inline infix fun Byte.shl(other: Int): Int = toInt() shl other

class ProtoReader(private val data: ByteArray) {

private var position = 0
private var checkpoint = 0

fun read(): List<ProtoPart> {
val parts = mutableListOf<ProtoPart>()

while (position < data.size) {
checkpoint = position

val varInt = internalReadVarint32()
val type = varInt and 0b111
val index = varInt shr 3

var value = ByteArray(0)

if (type == TYPE_VAR_INT) {
value = internalReadVarint32().toString().toByteArray()
} else if (type == TYPE_STRING) {
val length = internalReadVarint32()
value = ByteArray(length)
data.copyInto(value, 0, position, position + length)
position += length
} else {
log.error("Unknown protobuf type $type")
}

parts.add(ProtoPart(index, type, value))
}

return parts
}

private fun readByte(): Byte {
return data[position++]
}

private fun internalReadVarint32(): Int {
var tmp = readByte()
if (tmp >= 0) {
return tmp.toInt()
}
var result = tmp and 0x7f
tmp = readByte()
if (tmp >= 0) {
result = result or (tmp shl 7)
} else {
result = result or (tmp and 0x7f shl 7)
tmp = readByte()
if (tmp >= 0) {
result = result or (tmp shl 14)
} else {
result = result or (tmp and 0x7f shl 14)
tmp = readByte()
if (tmp >= 0) {
result = result or (tmp shl 21)
} else {
result = result or (tmp and 0x7f shl 21)
tmp = readByte()
result = result or (tmp shl 28)
if (tmp < 0) {
for (i in 0..4) {
if (readByte() >= 0) {
return result
}
}
}
}
}
}
return result
}
}

data class ProtoPart(val index: Int, val type: Int, val value: ByteArray)
Loading