proton-mail-android/app/src/main/java/ch/protonmail/android/activities/settings/NotificationSettingsViewMod...

202 lines
7.4 KiB
Kotlin

/*
* Copyright (c) 2022 Proton AG
*
* This file is part of Proton Mail.
*
* Proton Mail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Mail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Mail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.activities.settings
import android.app.Application
import android.content.Context
import android.media.Ringtone
import android.media.RingtoneManager
import android.media.RingtoneManager.TYPE_NOTIFICATION
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import ch.protonmail.android.R
import ch.protonmail.android.api.models.User
import ch.protonmail.android.core.UserManager
import ch.protonmail.android.exceptions.InvalidRingtoneException
import ch.protonmail.android.exceptions.NoDefaultRingtoneException
import ch.protonmail.android.utils.extensions.isEmpty
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import studio.forface.viewstatestore.ViewStateStore
import studio.forface.viewstatestore.ViewStateStoreScope
import java.io.File
/**
* A [ViewModel] for Notification's Settings
* Inherit from [AndroidViewModel] for handle eventual exceptions on [Ringtone] management
* Implements [ViewStateStoreScope] for being able to publish to a Locked [ViewStateStore]
*/
internal class NotificationSettingsViewModel(
application: Application,
userManager: UserManager
) : AndroidViewModel(application), ViewStateStoreScope {
private companion object {
/**
* [Uri] of default ringtone from [RingtoneManager]
* This [Uri] could be [Uri.EMPTY]
*/
val DEFAULT_RINGTONE_URI: Uri =
RingtoneManager.getDefaultUri(TYPE_NOTIFICATION) ?: Uri.EMPTY
}
/** @return [Context] from [getApplication] */
private val context: Context get() = getApplication()
/**
* @return [Uri] of the current ringtone, whether from [user] or default one
* This [Uri] could be [Uri.EMPTY]
*/
val currentRingtoneUri get() = user.ringtone ?: DEFAULT_RINGTONE_URI
/** A Locked [ViewStateStore] of type [RingtoneSettingsUiModel] */
val ringtoneSettings = ViewStateStore<RingtoneSettingsUiModel>().lock
/** Lazy instance of [User] from [UserManager] */
private val user by lazy { userManager.requireCurrentLegacyUser() }
init {
sendRingtoneSettings()
}
/** @return [RingtoneSettingsUiModel] */
@VisibleForTesting
internal fun createRingtoneSettings(): RingtoneSettingsUiModel {
val ringtoneTitle = if (currentRingtoneUri.isEmpty()) {
ringtoneSettings.setError(NoDefaultRingtoneException())
NONE
} else {
// Try to getUserRingtone else getDefaultRingtone
val ringtone = try {
getUserRingtone() // could be null
} catch (e: SecurityException) {
ringtoneSettings.setError(InvalidRingtoneException(e, currentRingtoneUri))
null
} ?: getDefaultRingtone()
ringtone.title
}
return RingtoneSettingsUiModel(user.notificationSetting, ringtoneTitle)
}
/**
* @return [Ringtone] from [DEFAULT_RINGTONE_URI]
* @throws AssertionError if [currentRingtoneUri] is empty
*/
private fun getDefaultRingtone(): Ringtone {
if (currentRingtoneUri.isEmpty()) throw AssertionError(
"'${::currentRingtoneUri.name}' is empty. Check 'Uri.isEmpty()' before call this"
)
return RingtoneManager.getRingtone(context, currentRingtoneUri)
}
/**
* @return OPTIONAL [Ringtone] if [currentRingtoneUri] is not [DEFAULT_RINGTONE_URI] else `null`
* @throws SecurityException
* @throws AssertionError if [currentRingtoneUri] is empty
*/
@VisibleForTesting
internal fun getUserRingtone(): Ringtone? {
if (currentRingtoneUri.isEmpty()) throw AssertionError(
"'${::currentRingtoneUri.name}' is empty. Check 'Uri.isEmpty()' before call this"
)
return if (currentRingtoneUri != DEFAULT_RINGTONE_URI) {
RingtoneManager.getRingtone(context, currentRingtoneUri)
} else null
}
/** Create and publish a [RingtoneSettingsUiModel] from the current [user]s Setting */
private fun sendRingtoneSettings() {
ringtoneSettings.setData(createRingtoneSettings())
}
/** Set new ringtone [Uri] to [User] and refresh data */
fun setRingtone(uri: Uri) {
ringtoneSettings.setLoading()
viewModelScope.launch {
try {
val safeUri = withContext(IO) { storeToPrivateIfFileScheme(uri) }
user.ringtone = safeUri
sendRingtoneSettings()
} catch (t: Throwable) {
ringtoneSettings.setError(InvalidRingtoneException(t, uri))
}
}
}
/**
* If the given [Uri] has a **file** scheme, store the relative file into a private folder
* @return original [Uri] if it safe, else the [Uri] from file just copied to private location
* @throws ( May throws exception )
*/
@Suppress("RedundantSuspendModifier") // We don't wanna run it on UI
private suspend fun storeToPrivateIfFileScheme(uri: Uri): Uri {
// Return same uri if scheme is not file
if (uri.scheme != "file") return uri
val directory = context.filesDir
val file = File(directory, "cachedNotificationRingtone")
val output = file.outputStream()
context.contentResolver.openInputStream(uri)!!.use { it.copyTo(output) }
return FileProvider.getUriForFile(context, context.packageName, file)
}
/** [ViewModelProvider.NewInstanceFactory] for [NotificationSettingsViewModel] */
class Factory(
private val application: Application,
private val userManager: UserManager
) : ViewModelProvider.NewInstanceFactory() {
/** @return new instance of [NotificationSettingsViewModel] casted as T */
@Suppress("UNCHECKED_CAST") // NotificationSettingsViewModel is T, since T is ViewModel
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(NotificationSettingsViewModel::class.java))
return NotificationSettingsViewModel(application, userManager) as T
}
}
/** A [CharSequence] representing [R.string.x_none] resource */
@Suppress("PrivatePropertyName")
private val NONE = context.getString(R.string.x_none)
/** @return [String] from title of [Ringtone] */
private val Ringtone.title get() = getTitle(context)
}
/**
* Ui Model for Ringtone settings.
*
* @param userOption [Int]
* @see User.NotificationSetting
*/
internal data class RingtoneSettingsUiModel(
val userOption: Int,
val name: CharSequence
)