Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package at.bitfire.icsdroid.model

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand All @@ -15,25 +17,45 @@ import at.bitfire.icsdroid.db.AppDatabase
import at.bitfire.icsdroid.db.entity.Credential
import at.bitfire.icsdroid.db.entity.Subscription
import at.bitfire.icsdroid.ui.ResourceInfo
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.net.URI
import java.net.URISyntaxException
import javax.inject.Inject

@HiltViewModel
class AddSubscriptionModel @Inject constructor(
@HiltViewModel(assistedFactory = AddSubscriptionModel.Factory::class)
class AddSubscriptionModel @AssistedInject constructor(
@Assisted("title") initialTitle: String?,
@Assisted("color") initialColor: Int?,
@Assisted("url") initialUrl: String?,
@param:ApplicationContext private val context: Context,
private val db: AppDatabase,
val validator: Validator,
val subscriptionSettingsUseCase: SubscriptionSettingsUseCase
val validator: Validator
) : ViewModel() {

@AssistedFactory
interface Factory {
fun create(
@Assisted("title") title: String? = null,
@Assisted("color") color: Int? = null,
@Assisted("url") url: String? = null
): AddSubscriptionModel
}

val subscriptionSettingsUseCase: SubscriptionSettingsUseCase = SubscriptionSettingsUseCase(
SubscriptionSettingsUseCase.UiState(
title = initialTitle,
color = initialColor,
url = initialUrl
)
)

data class UiState(
val success: Boolean = false,
val errorMessage: String? = null,
val isCreating: Boolean = false,
val showNextButton: Boolean = false,
Expand Down Expand Up @@ -129,10 +151,10 @@ class AddSubscriptionModel @Inject constructor(
// sync the subscription to reflect the changes in the calendar provider
SyncWorker.run(context)
}
uiState = uiState.copy(success = true)
toastAsync(context, message = context.getString(R.string.add_calendar_created))
} catch (e: Exception) {
Log.e(Constants.TAG, "Couldn't create calendar", e)
uiState = uiState.copy(errorMessage = e.localizedMessage ?: e.message)
toastAsync(context, message = e.localizedMessage ?: e.message)
} finally {
uiState = uiState.copy(isCreating = false)
}
Expand Down Expand Up @@ -217,4 +239,25 @@ class AddSubscriptionModel @Inject constructor(
}
return uri
}

fun onFilePicked(uri: Uri?) {
if (uri == null) return

// keep the picked file accessible after the first sync and reboots
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
subscriptionSettingsUseCase.setUrl(uri.toString())

// Get file name
val displayName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val name = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.getString(name)
}
subscriptionSettingsUseCase.setFileName(displayName)

checkUrlIntroductionPage()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import at.bitfire.icsdroid.db.entity.Credential
import at.bitfire.icsdroid.db.entity.Subscription
import javax.inject.Inject

class SubscriptionSettingsUseCase @Inject constructor() {
class SubscriptionSettingsUseCase(initialUiState: UiState = UiState()) {

@Deprecated("Do not inject constructor. Manually initialize with initial state.")
@Inject constructor(): this(UiState())

data class UiState(
val url: String? = null,
val fileName: String? = null,
Expand All @@ -33,11 +37,9 @@ class SubscriptionSettingsUseCase @Inject constructor() {
val validUrlInput: Boolean = url?.let { url ->
HttpUtils.acceptedProtocol(url.toUri())
} ?: false

fun isInitialized() = url != null || title != null || color != null
}

var uiState by mutableStateOf(UiState())
var uiState by mutableStateOf(initialUiState)
private set

fun setUrl(value: String?) {
Expand Down Expand Up @@ -98,24 +100,6 @@ class SubscriptionSettingsUseCase @Inject constructor() {
)
}

/**
* Set initial values when creating a new subscription.
*
* Note that all values will be overwritten, so call this method before changing any individual
* value, or when you want to reset the form to an initial state.
*/
fun setInitialValues(
title: String?,
color: Int?,
url: String?,
) {
uiState = UiState(
title = title,
color = color,
url = url,
)
}

fun equalsSubscription(subscription: Subscription) =
uiState.url == subscription.url.toString()
&& uiState.title == subscription.displayName
Expand Down
70 changes: 23 additions & 47 deletions app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import android.os.Build
import android.os.PowerManager
import android.util.Log
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
Expand All @@ -39,7 +38,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import java.io.FileInputStream
Expand Down Expand Up @@ -184,10 +182,7 @@ class SubscriptionsModel @Inject constructor(

fun onBackupExportRequested(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
val toast = toastAsync(
messageResId = R.string.backup_exporting,
duration = Toast.LENGTH_LONG
)
val toast = toastAsync(context, context.getString(R.string.backup_exporting))

val subscriptions = subscriptions.value
Log.i(TAG, "Exporting ${subscriptions.size} subscriptions...")
Expand All @@ -205,25 +200,21 @@ class SubscriptionsModel @Inject constructor(
}

toastAsync(
messageResId = R.string.backup_exported,
cancelToast = toast
context,
message = context.getString(R.string.backup_exported),
cancelToast = toast,
duration = Toast.LENGTH_SHORT
)
} catch (e: IOException) {
Log.e(TAG, "Could not write export file.", e)
toastAsync(
messageResId = R.string.backup_export_error_io,
duration = Toast.LENGTH_LONG
)
toastAsync(context, context.getString(R.string.backup_export_error_io))
}
}
}

fun onBackupImportRequested(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
val toast = toastAsync(
messageResId = R.string.backup_importing,
duration = Toast.LENGTH_LONG
)
val toast = toastAsync(context, context.getString(R.string.backup_importing))

try {
val jsonString = context.contentResolver.openFileDescriptor(uri, "r")?.use { fd ->
Expand All @@ -233,9 +224,9 @@ class SubscriptionsModel @Inject constructor(
}
if (jsonString == null) {
toastAsync(
messageResId = R.string.backup_import_error_io,
cancelToast = toast,
duration = Toast.LENGTH_LONG
context,
message = context.getString(R.string.backup_import_error_io),
cancelToast = toast
)
return@launch
}
Expand Down Expand Up @@ -267,48 +258,33 @@ class SubscriptionsModel @Inject constructor(
SyncWorker.run(context)

toastAsync(
message = {
resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size)
},
cancelToast = toast
context,
message = context.resources.getQuantityString(R.plurals.backup_imported, newSubscriptions.size, newSubscriptions.size),
cancelToast = toast,
duration = Toast.LENGTH_SHORT
)
} catch (e: JSONException) {
Log.e(TAG, "Could not load JSON: $e")
toastAsync(
messageResId = R.string.backup_import_error_json,
cancelToast = toast,
duration = Toast.LENGTH_LONG
context,
message = context.getString(R.string.backup_import_error_json),
cancelToast = toast
)
} catch (e: SecurityException) {
Log.e(TAG, "Could not load JSON: $e")
toastAsync(
messageResId = R.string.backup_import_error_security,
cancelToast = toast,
duration = Toast.LENGTH_LONG
context,
message = context.getString(R.string.backup_import_error_security),
cancelToast = toast
)
} catch (e: IOException) {
Log.e(TAG, "Could not load JSON: $e")
toastAsync(
messageResId = R.string.backup_import_error_io,
cancelToast = toast,
duration = Toast.LENGTH_LONG
context,
message = context.getString(R.string.backup_import_error_io),
cancelToast = toast
)
}
}
}

private suspend fun toastAsync(
message: (Context.() -> String)? = null,
@StringRes messageResId: Int? = null,
cancelToast: Toast? = null,
duration: Int = Toast.LENGTH_SHORT
): Toast? = withContext(Dispatchers.Main) {
cancelToast?.cancel()

when {
message != null -> Toast.makeText(context, message(context), duration)
messageResId != null -> Toast.makeText(context, messageResId, duration)
else -> return@withContext null
}.also { it.show() }
}
}
22 changes: 22 additions & 0 deletions app/src/main/java/at/bitfire/icsdroid/model/ViewModelUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package at.bitfire.icsdroid.model

import android.content.Context
import android.widget.Toast
import androidx.annotation.IntDef
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@Retention(AnnotationRetention.SOURCE)
@IntDef(Toast.LENGTH_SHORT, Toast.LENGTH_LONG)
annotation class ToastDuration

suspend fun toastAsync(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unnecessarily confusing to me. I am sorry, that I said we could extract it to utils. It does not make sense. I think it's much better to simply trigger and handle cancelation in place when needed. Either in the screens (preferably) or the view model if you think that's much cleaner.

context: Context,
message: String?,
cancelToast: Toast? = null,
@ToastDuration duration: Int = Toast.LENGTH_LONG,
): Toast? = withContext(Dispatchers.Main) {
cancelToast?.cancel()

Toast.makeText(context, message, duration).also { it.show() }
}
Loading