fix(PR): story decryption by hazelnut27

Enhance media encryption handling with hybrid resolver and improved decryption methods
This commit is contained in:
ΞTΞRNAL
2026-02-07 19:14:50 +05:30
committed by GitHub
4 changed files with 314 additions and 208 deletions

View File

@@ -9,7 +9,6 @@ import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// key and iv are base64 encoded into url safe strings
data class MediaEncryptionKeyPair(
val key: String,
val iv: String,

View File

@@ -48,11 +48,18 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair
import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe
import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import java.nio.file.Paths
import java.util.UUID
import kotlin.coroutines.suspendCoroutine
import kotlin.math.absoluteValue
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class SnapChapterInfo(
val offset: Long,
@@ -205,37 +212,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private fun extractStoryEncryption(paramMap: ParamMap): MediaEncryptionKeyPair? {
val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String
?: paramMap["REPLY_MEDIA_KEY"] as? String
?: return null
val ivRaw = paramMap["CONTEXT_REPLY_MEDIA_IV"] as? String
?: paramMap["REPLY_MEDIA_IV"] as? String
?: return null
return try {
val keyBytes = android.util.Base64.decode(keyRaw, android.util.Base64.DEFAULT)
val ivBytes = android.util.Base64.decode(ivRaw, android.util.Base64.DEFAULT)
if (keyBytes.size != 32 || ivBytes.size != 16) {
return null
}
MediaEncryptionKeyPair(
key = android.util.Base64.encodeToString(keyBytes, android.util.Base64.NO_WRAP),
iv = android.util.Base64.encodeToString(ivBytes, android.util.Base64.NO_WRAP),
urlSafe = false
)
} catch (e: Exception) {
context.log.error("Story AES decode failed", e)
null
}
}
private fun downloadOperaMedia(
downloadManagerClient: DownloadManagerClient,
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
@@ -243,69 +219,50 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
) {
if (mediaInfoMap.isEmpty()) return
val storyKeyPair = extractStoryEncryption(paramMap)
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
// Story Snap Entry (images)
paramMap["SNAP_ID"]?.toString()?.let { snapId ->
context.database.getStorySnapEntry(snapId)?.let { storySnapEntry ->
val urlToDownload = storySnapEntry?.mediaUrl ?: originalMediaInfo.uri
val encryptionPair =
safeGetEncryptionPair(originalMediaInfo)
?: extractStoryEncryption(paramMap)
downloadManagerClient.downloadSingleMedia(
originalMediaInfoReference,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
encryptionPair
storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"),
DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)),
(storySnapEntry.mediaKey to storySnapEntry.mediaIv)
.takeIf { it.first != null && it.second != null }
?.let { (key, iv) -> MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) }
)
return
}
}
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
// Overlay (if present)
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
val overlayReference = handleLocalReferences(overlay.uri)
val originalEncryption = safeGetEncryptionPair(originalMediaInfo)
val overlayEncryption = overlay.encryption?.toKeyPair()
downloadManagerClient.downloadMediaWithOverlay(
original = InputMedia(
originalMediaInfoReference,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
encryption = originalEncryption
originalMediaInfo.encryption?.toKeyPair()
),
overlay = InputMedia(
overlayReference,
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
encryption = overlayEncryption,
overlay.encryption?.toKeyPair(),
isOverlay = true
)
)
return
}
// fallback to single media download
val encryptionPair =
storyKeyPair ?: safeGetEncryptionPair(originalMediaInfo)
// Single media (video/DASH)
downloadManagerClient.downloadSingleMedia(
originalMediaInfoReference,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
encryptionPair
originalMediaInfo.encryption?.toKeyPair()
)
}
private fun safeGetEncryptionPair(info: MediaInfo?): MediaEncryptionKeyPair? {
val enc = info?.encryption ?: return null
return runCatching {
enc.toKeyPair()
}.onFailure { context.log.verbose("Failed to parse encryption key pair → ${info.uri}")
}.getOrNull()
}
fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean {
@@ -313,32 +270,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
return canUseRule(databaseMessage.clientConversationId!!)
}
private fun resolveMediaUrl(raw: String): String {
var url = raw.trim()
// Remove garbage prefix/suffix sometimes present
if (url.contains("https://")) {
url = url.substringAfter("https://")
url = "https://$url"
}
val knownGoodPrefixes = listOf(
"https://cf-st.sc-cdn.net",
"https://bolt-gcdn.sc-cdn.net",
"https://app.snapchat.com",
"https://cf-st-nl1.sc-cdn.net" // sometimes region specific
)
return when {
url.startsWith("http") -> url
knownGoodPrefixes.any { url.contains(it) } -> url.substringAfterLast("http")
else -> "${RemoteMediaResolver.CF_ST_CDN_D}/$url"
}.also { resolved ->
if (resolved != raw) {
context.log.debug("URL rewritten: $raw$resolved")
}
}
}
/**
* Handles the media from the opera viewer
*
@@ -347,105 +278,94 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
* @param forceDownload if the media should be downloaded
*/
private fun handleOperaMedia(
paramMap: ParamMap,
mediaInfoMap: Map<SplitMediaAssetType,
MediaInfo>,
forceDownload: Boolean,
paramMap: ParamMap,
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
forceDownload: Boolean,
forceAllowDuplicate: Boolean = false
) {
//messages
// ─── Messages ─────────────────────────
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
val messageId = id.substring(id.lastIndexOf(":") + 1).toLong()
val conversationMessage = context.database.getConversationMessageFromId(messageId)!!
val messageId = id.substringAfterLast(":").toLong()
val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let
val conversationId = conversationMessage.clientConversationId!!
if (!forceDownload && !canUseRule(conversationId)) {
return
}
if (!forceDownload && !canUseRule(conversationId)) return@let
val senderId = conversationMessage.senderId!!
if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() &&
senderId == context.database.myUserId
) return@let
if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && senderId == context.database.myUserId) return
val author = context.database.getFriendInfo(senderId) ?: return
val author = context.database.getFriendInfo(senderId) ?: return@let
val authorUsername = author.usernameForSorting!!
val mediaId = paramMap["MEDIA_ID"]?.toString()?.let {
if (it.contains("-")) it.substringAfter("-")
else it
}?.substringBefore(".")
downloadOperaMedia(provideDownloadManagerClient(
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId",
mediaAuthor = authorUsername,
creationTimestamp = conversationMessage.creationTimestamp,
downloadSource = MediaDownloadSource.CHAT_MEDIA,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate
), mediaInfoMap, paramMap)
val mediaId = paramMap["MEDIA_ID"]?.toString()?.substringAfter("-")?.substringBefore(".") ?: ""
downloadOperaMedia(
provideDownloadManagerClient(
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId",
mediaAuthor = authorUsername,
creationTimestamp = conversationMessage.creationTimestamp,
downloadSource = MediaDownloadSource.CHAT_MEDIA,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate
),
mediaInfoMap,
paramMap
)
return
}
//private stories
// ─── Private Friend Story ─────────────────────────
paramMap["PLAYLIST_V2_GROUP"]?.takeIf {
forceDownload || shouldAutoDownload("friend_stories")
}?.let { playlistGroup ->
val playlistGroupString = playlistGroup.toString()
// Try multiple possible keys/paths in order of likelihood
val storyUserId = sequenceOf(
// Most common new locations
{ paramMap["STORY_USER_ID"]?.toString() },
{ paramMap["CREATOR_USER_ID"]?.toString() },
{ paramMap["USER_ID"]?.toString() },
{ paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() },
// Old ones as fallback
{ paramMap["PLAYABLE_STORY_SNAP_RECORD"]
?.toString()
?.substringAfter("userId=")
?.substringBefore(",") },
// Parse playlistGroup string more carefully
{ playlistGroupString.substringAfter("userId=", missingDelimiterValue = "").substringBefore(",") },
{ playlistGroupString.substringAfter("storyUserId=", missingDelimiterValue = "").substringBefore(",") },
// Last desperate fallback — sometimes it's in nested snap record
{ paramMap["SNAP_PLAYLIST_ITEM"]
?.toString()
?.substringAfter("userId=")
?.substringBefore(",") }
).mapNotNull { it() }.firstOrNull { it.isNotBlank() && it != "null" }
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let {
if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null
} ?: if (playlistGroupString.contains("storyUserId=")) {
playlistGroupString.substringAfter("storyUserId=").substringBefore(",")
} else {
//story replies
val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }
?.invoke(playlistGroup)?.toString()
?.split(":")?.getOrNull(2) ?: return@let
// ──────────────────────────────────────────────
val authorUserId = storyUserId ?: run {
context.log.warn("[FriendStories] Could not extract user ID — falling back to current user")
context.database.myUserId // prevents crash, but will tag as self
val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let
val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let
conversationParticipants.firstOrNull { it != conversationMessage.senderId }
}
val author = context.database.getFriendInfo(authorUserId)
?: context.database.getFriendInfoByUsername(authorUserId) // sometimes it's username
?: throw Exception("No friend info for ID: $authorUserId")
val authorName = author.usernameForSorting ?: author.displayName ?: "UnknownFriend"
val author = context.database.getFriendInfo(
if (storyUserId == null || storyUserId == "null")
context.database.myUserId
else storyUserId
) ?: throw Exception("Friend not found in database")
val authorName = author.usernameForSorting!!
if (!forceDownload) {
if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return
if (!canUseRule(author.userId!!)) return
}
downloadOperaMedia(provideDownloadManagerClient(
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaAuthor = authorName,
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
?.substringBefore(",")?.toLongOrNull(),
downloadSource = MediaDownloadSource.STORY,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate,
), mediaInfoMap, paramMap)
downloadOperaMedia(
provideDownloadManagerClient(
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaAuthor = authorName,
creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=")
?.substringBefore(",")?.toLongOrNull(),
downloadSource = MediaDownloadSource.STORY,
friendInfo = author,
forceAllowDuplicate = forceAllowDuplicate
),
mediaInfoMap,
paramMap
)
return
}
// ─── Public Stories / Spotlight ───────────────────
val snapSource = paramMap["SNAP_SOURCE"].toString()
//spotlight
@@ -480,7 +400,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}"
}
val playlistUrl = resolveMediaUrl(paramMap["MEDIA_ID"].toString())
val playlistUrl = paramMap["MEDIA_ID"].toString().let {
val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net"))
urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex ->
it.substring(validIndex)
} ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it"
}
context.runOnUiThread {
val selectedChapters = mutableListOf<Int>()
@@ -810,7 +736,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!isPreview) {
if (forceDownloadFirst ||
decodedAttachments.size == 1 ||
context.isMainActivityPaused // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
context.isMainActivityPaused
) {
downloadMessageAttachments(friendInfo, message, authorName,
listOf(decodedAttachments.first()),

View File

@@ -1,5 +1,7 @@
package me.eternal.purrfectsnap.core.wrapper.impl.media
import android.util.Base64
import android.util.Log
import me.eternal.purrfectsnap.common.data.download.MediaEncryptionKeyPair
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import java.io.InputStream
@@ -10,83 +12,206 @@ import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.ExperimentalEncodingApi
import android.util.Base64
// Cipher mode enum
enum class SnapCipherMode {
CBC,
CTR
}
// Encryption Wrapper
class EncryptionWrapper(
instance: Any?,
instance: Any? = null,
private val mode: SnapCipherMode = SnapCipherMode.CBC
) : AbstractWrapper(instance) {
fun decrypt(data: ByteArray?): ByteArray {
return newCipher(Cipher.DECRYPT_MODE).doFinal(data)
// Manual key injection (story fallback)
private var manualKey: ByteArray? = null
private var manualIv: ByteArray? = null
constructor(
key: ByteArray,
iv: ByteArray,
mode: SnapCipherMode = SnapCipherMode.CBC
) : this(null, mode) {
manualKey = key
manualIv = iv
}
fun decrypt(inputStream: InputStream?): InputStream {
return CipherInputStream(inputStream, newCipher(Cipher.DECRYPT_MODE))
}
fun decrypt(outputStream: OutputStream?): OutputStream {
return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE))
}
fun newCipher(modeInt: Int): Cipher {
val cipher = cipher
cipher.init(
modeInt,
SecretKeySpec(keySpec, "AES"),
IvParameterSpec(ivKeyParameterSpec)
)
return cipher
}
/**
* Dynamic cipher selection
*/
private val cipher: Cipher
get() = when (mode) {
SnapCipherMode.CBC ->
Cipher.getInstance("AES/CBC/PKCS5Padding")
SnapCipherMode.CTR ->
Cipher.getInstance("AES/CTR/NoPadding")
}
// Key + IV extraction
val keySpec: ByteArray by lazy {
searchByteArrayField(32)[instance] as ByteArray
manualKey ?: searchByteArrayField(32)[instance] as ByteArray
}
val ivKeyParameterSpec: ByteArray by lazy {
searchByteArrayField(16)[instance] as ByteArray
manualIv ?: searchByteArrayField(16)[instance] as ByteArray
}
private fun searchByteArrayField(arrayLength: Int): Field {
return instanceNonNull()::class.java.fields.first { f ->
private fun searchByteArrayField(length: Int): Field {
return instanceNonNull()::class.java.declaredFields.first { field ->
try {
if (!f.type.isArray ||
f.type.componentType != Byte::class.javaPrimitiveType
field.isAccessible = true
if (!field.type.isArray ||
field.type.componentType != Byte::class.javaPrimitiveType
) return@first false
(f.get(instanceNonNull()) as ByteArray).size == arrayLength
val value = field.get(instanceNonNull()) as? ByteArray
value?.size == length
} catch (_: Exception) {
false
}
}
}
// Cipher builders
/** Chat media */
private fun buildCBCCipherPKCS5(mode: Int): Cipher {
return Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(
mode,
SecretKeySpec(keySpec, "AES"),
IvParameterSpec(ivKeyParameterSpec)
)
}
}
/** Story images */
private fun buildCBCCipherNoPadding(mode: Int): Cipher {
return Cipher.getInstance("AES/CBC/NoPadding").apply {
init(
mode,
SecretKeySpec(keySpec, "AES"),
IvParameterSpec(ivKeyParameterSpec)
)
}
}
/** Videos + DASH */
private fun buildCTRCipher(mode: Int): Cipher {
return Cipher.getInstance("AES/CTR/NoPadding").apply {
init(
mode,
SecretKeySpec(keySpec, "AES"),
IvParameterSpec(ivKeyParameterSpec)
)
}
}
// Generic decrypt (auto detect)
fun decrypt(data: ByteArray): ByteArray {
Log.d(
"PurrfectSnap",
"Decrypt → keySize=${keySpec.size} ivSize=${ivKeyParameterSpec.size}"
)
return try {
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE).doFinal(data)
} catch (cbcError: Exception) {
Log.d("PurrfectSnap", "PKCS5 failed → trying CTR")
buildCTRCipher(Cipher.DECRYPT_MODE).doFinal(data)
}
}
// Image decrypt (story + chat safe)
fun decryptImage(data: ByteArray): ByteArray {
Log.d(
"PurrfectSnap",
"Image Decrypt → keySize=${keySpec.size} ivSize=${ivKeyParameterSpec.size}"
)
return try {
// Story images
buildCBCCipherNoPadding(Cipher.DECRYPT_MODE).doFinal(data)
} catch (noPadError: Exception) {
Log.d("PurrfectSnap", "CBC NoPadding failed → trying PKCS5")
try {
// Chat images
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE).doFinal(data)
} catch (pkcsError: Exception) {
Log.d("PurrfectSnap", "PKCS5 failed → trying CTR")
// Edge fallback
buildCTRCipher(Cipher.DECRYPT_MODE).doFinal(data)
}
}
}
// Stream decrypt
fun decrypt(input: InputStream): InputStream {
val cipher = try {
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE)
} catch (_: Exception) {
buildCTRCipher(Cipher.DECRYPT_MODE)
}
return CipherInputStream(input, cipher)
}
fun decryptImage(input: InputStream): InputStream {
val cipher = try {
buildCBCCipherNoPadding(Cipher.DECRYPT_MODE)
} catch (_: Exception) {
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE)
}
return CipherInputStream(input, cipher)
}
fun decrypt(output: OutputStream): OutputStream {
val cipher = try {
buildCBCCipherPKCS5(Cipher.DECRYPT_MODE)
} catch (_: Exception) {
buildCTRCipher(Cipher.DECRYPT_MODE)
}
return CipherOutputStream(output, cipher)
}
}
@OptIn(ExperimentalEncodingApi::class)
// KeyPair Extensions
/** Standard Base64 */
fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair {
return MediaEncryptionKeyPair(
key = android.util.Base64.encodeToString(this.keySpec, android.util.Base64.NO_WRAP),
iv = android.util.Base64.encodeToString(this.ivKeyParameterSpec, android.util.Base64.NO_WRAP),
urlSafe = true
key = Base64.encodeToString(this.keySpec, Base64.NO_WRAP),
iv = Base64.encodeToString(this.ivKeyParameterSpec, Base64.NO_WRAP),
urlSafe = false
)
}
/** URL Safe (story media) */
fun EncryptionWrapper.toKeyPairUrlSafe(): MediaEncryptionKeyPair {
return MediaEncryptionKeyPair(
key = Base64.encodeToString(
this.keySpec,
Base64.URL_SAFE or Base64.NO_WRAP
),
iv = Base64.encodeToString(
this.ivKeyParameterSpec,
Base64.URL_SAFE or Base64.NO_WRAP
),
urlSafe = true
)
}

View File

@@ -0,0 +1,56 @@
package me.eternal.purrfectsnap.core.wrapper.impl.media
import android.util.Base64
import me.eternal.purrfectsnap.common.data.download.MediaEncryptionKeyPair
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object HybridEncryptionResolver {
fun resolve(
mediaInfo: MediaInfo,
storyKeyPair: MediaEncryptionKeyPair?
): MediaEncryptionKeyPair? {
// ────OLD WRAPPER (images/snaps)───────────────────
val wrapperPair = runCatching {
mediaInfo.encryption?.toKeyPair()
}.getOrNull()
if (wrapperPair != null) {
return wrapperPair
}
// ────DIRECT MEDIAINFO KEYPAIR────────────────
val directPair = runCatching {
mediaInfo.encryption?.toKeyPairUrlSafe()
}.getOrNull()
if (directPair != null) {
return directPair
}
// ────STORY PARAMMAP AES─────────────────
if (storyKeyPair != null) {
return storyKeyPair
}
// ────NONE FOUND─────────────────
return null
}
// Optional cipher builder if needed elsewhere
fun buildCipher(pair: MediaEncryptionKeyPair): Cipher {
val key = Base64.decode(pair.key, Base64.DEFAULT)
val iv = Base64.decode(pair.iv, Base64.DEFAULT)
return Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key, "AES"),
IvParameterSpec(iv)
)
}
}
}