/* * 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().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 create(modelClass: Class): 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 )