exporting credentials now requires a password (encrypted)
This commit is contained in:
parent
ed6275b0e8
commit
2214c47c0c
6 changed files with 228 additions and 26 deletions
|
@ -49,6 +49,8 @@ import ani.dantotsu.media.Media
|
||||||
import ani.dantotsu.parsers.ShowResponse
|
import ani.dantotsu.parsers.ShowResponse
|
||||||
import ani.dantotsu.settings.saving.PrefName
|
import ani.dantotsu.settings.saving.PrefName
|
||||||
import ani.dantotsu.settings.saving.PrefManager
|
import ani.dantotsu.settings.saving.PrefManager
|
||||||
|
import ani.dantotsu.settings.saving.internal.PreferenceKeystore
|
||||||
|
import ani.dantotsu.settings.saving.internal.PreferenceKeystore.Companion.generateSalt
|
||||||
import ani.dantotsu.subcriptions.NotificationClickReceiver
|
import ani.dantotsu.subcriptions.NotificationClickReceiver
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.model.GlideUrl
|
import com.bumptech.glide.load.model.GlideUrl
|
||||||
|
@ -588,16 +590,26 @@ fun saveImageToDownloads(title: String, bitmap: Bitmap, context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun savePrefsToDownloads(title: String, map: Map<String, *>, context: Context) {
|
fun savePrefsToDownloads(title: String, map: Map<String, *>, context: Context, password: CharArray? = null) {
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"$APPLICATION_ID.provider",
|
"$APPLICATION_ID.provider",
|
||||||
savePrefs(
|
if (password != null) {
|
||||||
map,
|
savePrefs(
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath,
|
map,
|
||||||
title,
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath,
|
||||||
context
|
title,
|
||||||
) ?: return
|
context,
|
||||||
|
password
|
||||||
|
) ?: return
|
||||||
|
} else {
|
||||||
|
savePrefs(
|
||||||
|
map,
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath,
|
||||||
|
title,
|
||||||
|
context
|
||||||
|
) ?: return
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -622,6 +634,34 @@ fun savePrefs(map: Map<String, *>, path: String, title: String, context: Context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePrefs(map: Map<String, *>, path: String, title: String, context: Context, password: CharArray): File? {
|
||||||
|
var file = File(path, "$title.ani")
|
||||||
|
var counter = 1
|
||||||
|
while (file.exists()) {
|
||||||
|
file = File(path, "${title}_${counter}.ani")
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
val salt = generateSalt()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val gson = Gson()
|
||||||
|
val json = gson.toJson(map)
|
||||||
|
val encryptedData = PreferenceKeystore.encryptWithPassword(password, json, salt)
|
||||||
|
|
||||||
|
// Combine salt and encrypted data
|
||||||
|
val dataToSave = salt + encryptedData
|
||||||
|
|
||||||
|
file.writeBytes(dataToSave)
|
||||||
|
scanFile(file.absolutePath, context)
|
||||||
|
toast(String.format(context.getString(R.string.saved_to_path, file.absolutePath)))
|
||||||
|
file
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackString("Failed to save settings: ${e.localizedMessage}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun shareImage(title: String, bitmap: Bitmap, context: Context) {
|
fun shareImage(title: String, bitmap: Bitmap, context: Context) {
|
||||||
|
|
||||||
val contentUri = FileProvider.getUriForFile(
|
val contentUri = FileProvider.getUriForFile(
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.graphics.drawable.Animatable
|
||||||
import android.os.Build.*
|
import android.os.Build.*
|
||||||
import android.os.Build.VERSION.*
|
import android.os.Build.VERSION.*
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
@ -39,6 +40,7 @@ import ani.dantotsu.parsers.MangaSources
|
||||||
import ani.dantotsu.settings.saving.PrefName
|
import ani.dantotsu.settings.saving.PrefName
|
||||||
import ani.dantotsu.settings.saving.PrefManager
|
import ani.dantotsu.settings.saving.PrefManager
|
||||||
import ani.dantotsu.settings.saving.internal.Location
|
import ani.dantotsu.settings.saving.internal.Location
|
||||||
|
import ani.dantotsu.settings.saving.internal.PreferenceKeystore
|
||||||
import ani.dantotsu.subcriptions.Notifications
|
import ani.dantotsu.subcriptions.Notifications
|
||||||
import ani.dantotsu.subcriptions.Notifications.Companion.openSettings
|
import ani.dantotsu.subcriptions.Notifications.Companion.openSettings
|
||||||
import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime
|
import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime
|
||||||
|
@ -70,6 +72,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
|
||||||
lateinit var binding: ActivitySettingsBinding
|
lateinit var binding: ActivitySettingsBinding
|
||||||
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
||||||
private var cursedCounter = 0
|
private var cursedCounter = 0
|
||||||
|
private var tempPassword: CharArray? = null
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@SuppressLint("SetTextI18n", "Recycle")
|
@SuppressLint("SetTextI18n", "Recycle")
|
||||||
|
@ -85,15 +88,28 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
|
||||||
val openDocumentLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val openDocumentLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
try {
|
try {
|
||||||
val jsonString = contentResolver.openInputStream(uri)?.bufferedReader()
|
val jsonString = contentResolver.openInputStream(uri)?.readBytes()
|
||||||
.use { it?.readText()}
|
?: throw Exception("Error reading file")
|
||||||
val location: Location =
|
val location: Location =
|
||||||
Location.entries.find { it.name.lowercase() == selectedImpExp.lowercase() }
|
Location.entries.find { it.name.lowercase() == selectedImpExp.lowercase() }
|
||||||
?: return@registerForActivityResult
|
?: return@registerForActivityResult
|
||||||
|
val decryptedJson = if (location == Location.Protected) {
|
||||||
|
val password = tempPassword ?: return@registerForActivityResult
|
||||||
|
tempPassword = null
|
||||||
|
val salt = jsonString.copyOfRange(0, 16)
|
||||||
|
val encrypted = jsonString.copyOfRange(16, jsonString.size)
|
||||||
|
PreferenceKeystore.decryptWithPassword(
|
||||||
|
password,
|
||||||
|
encrypted,
|
||||||
|
salt
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
jsonString.toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
val gson = Gson()
|
val gson = Gson()
|
||||||
val type = object : TypeToken<Map<String, Map<String, Any>>>() {}.type
|
val type = object : TypeToken<Map<String, Map<String, Any>>>() {}.type
|
||||||
val rawMap: Map<String, Map<String, Any>> = gson.fromJson(jsonString, type)
|
val rawMap: Map<String, Map<String, Any>> = gson.fromJson(decryptedJson, type)
|
||||||
|
|
||||||
val deserializedMap = mutableMapOf<String, Any?>()
|
val deserializedMap = mutableMapOf<String, Any?>()
|
||||||
|
|
||||||
|
@ -112,7 +128,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PrefManager.importAllPrefs(deserializedMap, location)
|
if(PrefManager.importAllPrefs(deserializedMap, location))
|
||||||
|
restartApp()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
toast("Error importing settings")
|
toast("Error importing settings")
|
||||||
|
@ -218,7 +235,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
|
||||||
val pinnedSourcesBoolean =
|
val pinnedSourcesBoolean =
|
||||||
animeSourcesWithoutDownloadsSource.map { it.name in AnimeSources.pinnedAnimeSources }
|
animeSourcesWithoutDownloadsSource.map { it.name in AnimeSources.pinnedAnimeSources }
|
||||||
val pinnedSourcesOriginal: Set<String> = PrefManager.getVal(PrefName.PinnedAnimeSources)
|
val pinnedSourcesOriginal: Set<String> = PrefManager.getVal(PrefName.PinnedAnimeSources)
|
||||||
val pinnedSources = pinnedSourcesOriginal.toMutableSet() ?: mutableSetOf()
|
val pinnedSources = pinnedSourcesOriginal.toMutableSet()
|
||||||
val alertDialog = AlertDialog.Builder(this, R.style.MyPopup)
|
val alertDialog = AlertDialog.Builder(this, R.style.MyPopup)
|
||||||
.setTitle("Pinned Anime Sources")
|
.setTitle("Pinned Anime Sources")
|
||||||
.setMultiChoiceItems(
|
.setMultiChoiceItems(
|
||||||
|
@ -265,24 +282,52 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
|
||||||
binding.importExportSettings.setOnClickListener {
|
binding.importExportSettings.setOnClickListener {
|
||||||
var i = 0
|
var i = 0
|
||||||
selectedImpExp = Location.entries[i].name
|
selectedImpExp = Location.entries[i].name
|
||||||
|
val filteredLocations = Location.entries.filter { it.exportable }
|
||||||
val dialog = AlertDialog.Builder(this, R.style.MyPopup)
|
val dialog = AlertDialog.Builder(this, R.style.MyPopup)
|
||||||
.setTitle("Import/Export Settings")
|
.setTitle("Import/Export Settings")
|
||||||
.setSingleChoiceItems(Location.entries
|
.setSingleChoiceItems( filteredLocations.map { it.name }.toTypedArray(), i) { dialog, which ->
|
||||||
.filter { it.exportable }
|
selectedImpExp = filteredLocations[which].name
|
||||||
.map { it.name }.toTypedArray(), 0) { dialog, which ->
|
|
||||||
selectedImpExp = Location.entries[which].name
|
|
||||||
i = which
|
i = which
|
||||||
}
|
}
|
||||||
.setPositiveButton("Import...") { dialog, _ ->
|
.setPositiveButton("Import...") { dialog, _ ->
|
||||||
openDocumentLauncher.launch(arrayOf("*/*"))
|
if (filteredLocations[i] == Location.Protected) {
|
||||||
|
passwordAlertDialog(false) { password ->
|
||||||
|
if (password != null) {
|
||||||
|
tempPassword = password
|
||||||
|
openDocumentLauncher.launch(arrayOf("*/*"))
|
||||||
|
} else {
|
||||||
|
toast("Password cannot be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
openDocumentLauncher.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Export...") { dialog, _ ->
|
.setNegativeButton("Export...") { dialog, _ ->
|
||||||
if (i < 0) return@setNegativeButton
|
if (i < 0) return@setNegativeButton
|
||||||
savePrefsToDownloads(Location.entries[i].name,
|
|
||||||
PrefManager.exportAllPrefs(Location.entries[i]),
|
|
||||||
this@SettingsActivity)
|
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
if (filteredLocations[i] == Location.Protected) {
|
||||||
|
passwordAlertDialog(true) { password ->
|
||||||
|
if (password != null) {
|
||||||
|
savePrefsToDownloads(
|
||||||
|
filteredLocations[i].name,
|
||||||
|
PrefManager.exportAllPrefs(filteredLocations[i]),
|
||||||
|
this@SettingsActivity,
|
||||||
|
password
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast("Password cannot be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
savePrefsToDownloads(
|
||||||
|
filteredLocations[i].name,
|
||||||
|
PrefManager.exportAllPrefs(filteredLocations[i]),
|
||||||
|
this@SettingsActivity,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.setNeutralButton("Cancel") { dialog, _ ->
|
.setNeutralButton("Cancel") { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
@ -877,6 +922,44 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private fun passwordAlertDialog(isExporting:Boolean, callback: (CharArray?) -> Unit) {
|
||||||
|
val password = CharArray(16).apply { fill('0') }
|
||||||
|
|
||||||
|
// Inflate the dialog layout
|
||||||
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_user_agent, null)
|
||||||
|
dialogView.findViewById<TextInputEditText>(R.id.userAgentTextBox)?.hint = "Password"
|
||||||
|
val subtitleTextView = dialogView.findViewById<TextView>(R.id.subtitle)
|
||||||
|
subtitleTextView?.visibility = View.VISIBLE
|
||||||
|
if (!isExporting)
|
||||||
|
subtitleTextView?.text = "Enter your password to decrypt the file"
|
||||||
|
|
||||||
|
val dialog = AlertDialog.Builder(this, R.style.MyPopup)
|
||||||
|
.setTitle("Enter Password")
|
||||||
|
.setView(dialogView)
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.setNegativeButton("Cancel") { dialog, _ ->
|
||||||
|
password.fill('0')
|
||||||
|
dialog.dismiss()
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
|
||||||
|
dialog.window?.setDimAmount(0.8f)
|
||||||
|
dialog.show()
|
||||||
|
|
||||||
|
// Override the positive button here
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val editText = dialog.findViewById<TextInputEditText>(R.id.userAgentTextBox)
|
||||||
|
if (editText?.text?.isNotBlank() == true) {
|
||||||
|
editText.text?.toString()?.trim()?.toCharArray(password)
|
||||||
|
dialog.dismiss()
|
||||||
|
callback(password)
|
||||||
|
} else {
|
||||||
|
toast("Password cannot be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getDeviceInfo(): String {
|
fun getDeviceInfo(): String {
|
||||||
|
|
|
@ -90,7 +90,7 @@ object PrefManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T> getNullableVal(prefName: PrefName, default: T?) : T? {
|
fun <T> getNullableVal(prefName: PrefName, default: T?) : T? { //Strings don't necessarily need to use this one
|
||||||
return try {
|
return try {
|
||||||
val pref = getPrefLocation(prefName.data.prefLocation)
|
val pref = getPrefLocation(prefName.data.prefLocation)
|
||||||
when (prefName.data.type) {
|
when (prefName.data.type) {
|
||||||
|
@ -254,7 +254,7 @@ object PrefManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun importAllPrefs(prefs: Map<String, *>, prefLocation: Location) {
|
fun importAllPrefs(prefs: Map<String, *>, prefLocation: Location): Boolean {
|
||||||
val pref = getPrefLocation(prefLocation)
|
val pref = getPrefLocation(prefLocation)
|
||||||
var hadError = false
|
var hadError = false
|
||||||
pref.edit().clear().apply()
|
pref.edit().clear().apply()
|
||||||
|
@ -273,8 +273,13 @@ object PrefManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply()
|
apply()
|
||||||
if (hadError) snackString("Error importing preferences")
|
return if (hadError) {
|
||||||
else snackString("Preferences imported")
|
snackString("Error importing preferences")
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
snackString("Preferences imported")
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
package ani.dantotsu.settings.saving.internal
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
|
//used to encrypt and decrypt json strings on import and export
|
||||||
|
class PreferenceKeystore {
|
||||||
|
companion object {
|
||||||
|
fun generateKey(alias: String) {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
|
||||||
|
keyGenerator.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
alias,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
|
||||||
|
.setRandomizedEncryptionRequired(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encryptWithPassword(password: CharArray, plaintext: String, salt: ByteArray): ByteArray {
|
||||||
|
val secretKey = deriveKeyFromPassword(password, salt)
|
||||||
|
val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16)))
|
||||||
|
return cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptWithPassword(password: CharArray, ciphertext: ByteArray, salt: ByteArray): String {
|
||||||
|
val secretKey = deriveKeyFromPassword(password, salt)
|
||||||
|
val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) // Use the correct IV
|
||||||
|
return cipher.doFinal(ciphertext).toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateSalt(): ByteArray {
|
||||||
|
val random = SecureRandom()
|
||||||
|
val salt = ByteArray(16)
|
||||||
|
random.nextBytes(salt)
|
||||||
|
return salt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveKeyFromPassword(password: CharArray, salt: ByteArray): SecretKey {
|
||||||
|
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
val spec = PBEKeySpec(password, salt, 10000, 256)
|
||||||
|
return factory.generateSecret(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -123,12 +123,16 @@ class SubscriptionHelper {
|
||||||
) : java.io.Serializable
|
) : java.io.Serializable
|
||||||
|
|
||||||
private const val subscriptions = "subscriptions"
|
private const val subscriptions = "subscriptions"
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun getSubscriptions(): Map<Int, SubscribeMedia> =
|
fun getSubscriptions(): Map<Int, SubscribeMedia> =
|
||||||
PrefManager.getNullableCustomVal<Map<Int, SubscribeMedia>>(subscriptions, null, Map::class.java)!!
|
(PrefManager.getNullableCustomVal(subscriptions, null, Map::class.java) as? Map<Int, SubscribeMedia>)
|
||||||
?: mapOf<Int, SubscribeMedia>().also { PrefManager.setCustomVal(subscriptions, it) }
|
?: mapOf<Int, SubscribeMedia>().also { PrefManager.setCustomVal(subscriptions, it) }
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun saveSubscription(context: Context, media: Media, subscribed: Boolean) {
|
fun saveSubscription(context: Context, media: Media, subscribed: Boolean) {
|
||||||
val data = PrefManager.getNullableCustomVal<Map<Int, SubscribeMedia>?>(subscriptions, null)!!.toMutableMap()
|
val data = PrefManager.getNullableCustomVal(subscriptions, null, Map::class.java) as? MutableMap<Int, SubscribeMedia>
|
||||||
|
?: mutableMapOf()
|
||||||
if (subscribed) {
|
if (subscribed) {
|
||||||
if (!data.containsKey(media.id)) {
|
if (!data.containsKey(media.id)) {
|
||||||
val new = SubscribeMedia(
|
val new = SubscribeMedia(
|
||||||
|
|
|
@ -6,6 +6,15 @@
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/subtitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/poppins"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="Exporting credentials requires a password for encryption."
|
||||||
|
android:textSize="18sp" />
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
android:id="@+id/userAgentTextBox"
|
android:id="@+id/userAgentTextBox"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue