Skip to content

Commit

Permalink
Add preview for snap/chat images and videos in notification.
Browse files Browse the repository at this point in the history
  • Loading branch information
rodit committed May 17, 2022
1 parent 919e163 commit 61ac14f
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 52 deletions.
Binary file modified app/libs/snapmod.jar
Binary file not shown.
72 changes: 46 additions & 26 deletions app/src/main/java/xyz/rodit/snapmod/arroyo/ArroyoReader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,60 @@ 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? {
try {
val db: SQLiteDatabase =
SQLiteDatabase.openDatabase(
File(context.filesDir, "../databases/arroyo.db").path,
null,
0
)
val cursor =
db.rawQuery(
"SELECT message_content FROM conversation_message WHERE client_conversation_id='$conversationId' AND server_message_id=$messageId",
null
)
if (cursor.moveToFirst()) {
var parts = ProtoReader(cursor.getBlob(0)).read()
var container = parts.firstOrNull { it.index == 4 }?.value ?: return null
parts = ProtoReader(container).read()
container = parts.firstOrNull { it.index == 4 }?.value ?: return null
parts = ProtoReader(container).read()
container = parts.firstOrNull { it.index == 2 }?.value ?: return null
parts = ProtoReader(container).read()
container = parts.firstOrNull { it.index == 1 }?.value ?: return null
return String(container)
} else {
log.debug("No result in db.")
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.")
}
} catch (e: Exception) {
log.error("Error reading arroyo db", e)
}

return null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,185 @@
package xyz.rodit.snapmod.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.mappings.NotificationData
import xyz.rodit.snapmod.mappings.NotificationHandler
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 data = NotificationData.wrap(NotificationHandler.wrap(it.thisObject).data)
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
}
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<string name="tweaks_header">Tweaks</string>
<string name="snaps_header">Snaps</string>
<string name="downloads_header">Downloads</string>
<string name="notifications_header">Notifications</string>
<string name="location_header">Location Sharing</string>
<string name="debug_header">Information</string>

Expand Down Expand Up @@ -139,4 +140,7 @@

<string name="show_notification_content_title">Show Message In Notifications</string>
<string name="show_notification_content_description">Displays message content in notifications.</string>

<string name="show_notification_media_previews_title">Show Media Previews</string>
<string name="show_notification_media_previews_description">Shows previews in the notifications for non-text media (images and videos). Note, this requires the entire media to be downloaded so this will consume data when notifications are received.</string>
</resources>
32 changes: 10 additions & 22 deletions app/src/main/res/xml/root_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,6 @@
app:title="@string/enable_new_voice_notes_title"
app:summary="@string/enable_new_voice_notes_description"
app:iconSpaceReserved="false" />

<SwitchPreferenceCompat
app:key="show_notification_content"
app:title="@string/show_notification_content_title"
app:summary="@string/show_notification_content_description"
app:iconSpaceReserved="false" />
</PreferenceCategory>

<PreferenceCategory app:title="@string/snaps_header"
Expand Down Expand Up @@ -284,27 +278,21 @@

</PreferenceCategory>

<PreferenceCategory app:title="@string/location_header"

<PreferenceCategory app:title="@string/notifications_header"
app:iconSpaceReserved="false">

<SwitchPreferenceCompat
app:key="location_share_override"
app:title="@string/location_share_override_title"
app:summary="@string/location_share_override_description"
app:key="show_notification_content"
app:title="@string/show_notification_content_title"
app:summary="@string/show_notification_content_description"
app:iconSpaceReserved="false" />

<EditTextPreference
app:key="location_share_lat"
app:title="@string/location_share_lat_title"
app:iconSpaceReserved="false"
app:defaultValue="0" />

<EditTextPreference
app:key="location_share_long"
app:title="@string/location_share_long_title"
app:iconSpaceReserved="false"
app:defaultValue="0" />

<SwitchPreferenceCompat
app:key="show_notification_media_previews"
app:title="@string/show_notification_media_previews_title"
app:summary="@string/show_notification_media_previews_description"
app:iconSpaceReserved="false" />
</PreferenceCategory>

<PreferenceCategory app:title="@string/debug_header"
Expand Down
Loading

0 comments on commit 61ac14f

Please sign in to comment.