mirror of
https://github.com/ActivityLauncher/ActivityLauncher.git
synced 2026-08-12 10:23:23 +00:00
feat: add intent extras editing via EditIntentDialog
- Add IntentDef/ExtraDef domain model for intent editing - Add EditIntentDialogFragment and EditIntentViewModel for editing intent action, data, MIME type, categories, and extras - Replace ActivityIntent.kt functions with IntentDef-based versions - Wire EditIntentDialog into ActivityDetailsFragment menu - Add validation for duplicates, field requirements, and type correctness - Add intent-related string resources and drawables - Add unit tests for new ViewModel and utility functions
This commit is contained in:
parent
43eb5b30b2
commit
635e9cb328
16 changed files with 1119 additions and 117 deletions
|
|
@ -2,14 +2,87 @@ package de.szalkowski.activitylauncher.core.util
|
|||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.net.Uri
|
||||
import androidx.core.os.bundleOf
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraDef
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraType
|
||||
import de.szalkowski.activitylauncher.domain.intent.IntentDef
|
||||
|
||||
fun getActivityIntent(activity: ComponentName?, extras: Bundle?): Intent {
|
||||
fun getActivityIntentFromIntentDef(activity: ComponentName?, intentDef: IntentDef?): Intent {
|
||||
val intent = Intent()
|
||||
intent.component = activity
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
if (extras != null) {
|
||||
intent.putExtras(extras)
|
||||
|
||||
if (intentDef != null) {
|
||||
if (!intentDef.action.isNullOrBlank()) {
|
||||
intent.action = intentDef.action
|
||||
}
|
||||
|
||||
if (!intentDef.data.isNullOrBlank()) {
|
||||
intent.data = Uri.parse(intentDef.data)
|
||||
}
|
||||
|
||||
if (!intentDef.mimeType.isNullOrBlank()) {
|
||||
intent.type = intentDef.mimeType
|
||||
}
|
||||
|
||||
if (intentDef.data.isNullOrBlank() && intentDef.mimeType.isNullOrBlank()) {
|
||||
// clear data/type
|
||||
} else if (!intentDef.data.isNullOrBlank() && !intentDef.mimeType.isNullOrBlank()) {
|
||||
intent.data = Uri.parse(intentDef.data)
|
||||
intent.type = intentDef.mimeType
|
||||
}
|
||||
|
||||
intentDef.categories
|
||||
.filter { it.isNotBlank() }
|
||||
.forEach { intent.addCategory(it) }
|
||||
|
||||
val bundle = bundleOf()
|
||||
intentDef.extras.forEach { extra ->
|
||||
if (extra.key.isNotBlank()) {
|
||||
when (extra.type) {
|
||||
ExtraType.STRING -> bundle.putString(extra.key, extra.value)
|
||||
ExtraType.INT -> bundle.putInt(extra.key, extra.value.toIntOrNull() ?: 0)
|
||||
ExtraType.LONG -> bundle.putLong(extra.key, extra.value.toLongOrNull() ?: 0L)
|
||||
ExtraType.FLOAT -> bundle.putFloat(extra.key, extra.value.toFloatOrNull() ?: 0f)
|
||||
ExtraType.DOUBLE -> bundle.putDouble(extra.key, extra.value.toDoubleOrNull() ?: 0.0)
|
||||
ExtraType.BOOLEAN -> bundle.putBoolean(extra.key, extra.value.toBooleanStrictOrNull() ?: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bundle.isEmpty) {
|
||||
intent.putExtras(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
fun getIntentDefFromActivityIntent(intent: Intent): IntentDef {
|
||||
val extras = mutableListOf<ExtraDef>()
|
||||
intent.extras?.let { bundle ->
|
||||
for (key in bundle.keySet()) {
|
||||
val value = bundle.get(key)
|
||||
val (stringValue, type) = when (value) {
|
||||
is String -> value to ExtraType.STRING
|
||||
is Int -> value.toString() to ExtraType.INT
|
||||
is Long -> value.toString() to ExtraType.LONG
|
||||
is Float -> value.toString() to ExtraType.FLOAT
|
||||
is Double -> value.toString() to ExtraType.DOUBLE
|
||||
is Boolean -> value.toString() to ExtraType.BOOLEAN
|
||||
else -> value?.toString().orEmpty() to ExtraType.STRING
|
||||
}
|
||||
extras.add(ExtraDef(key, stringValue, type))
|
||||
}
|
||||
}
|
||||
|
||||
val categories = intent.categories?.toList() ?: emptyList()
|
||||
|
||||
return IntentDef(
|
||||
action = intent.action,
|
||||
data = intent.dataString,
|
||||
mimeType = intent.type,
|
||||
categories = categories,
|
||||
extras = extras,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package de.szalkowski.activitylauncher.domain.intent
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
data class IntentDef(
|
||||
val action: String? = null,
|
||||
val data: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val extras: List<ExtraDef> = emptyList(),
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
parcel.createStringArrayList() ?: emptyList(),
|
||||
parcel.createTypedArrayList(ExtraDef.CREATOR) ?: emptyList(),
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(action)
|
||||
parcel.writeString(data)
|
||||
parcel.writeString(mimeType)
|
||||
parcel.writeStringList(categories)
|
||||
parcel.writeTypedList(extras)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<IntentDef> {
|
||||
override fun createFromParcel(parcel: Parcel): IntentDef = IntentDef(parcel)
|
||||
override fun newArray(size: Int): Array<IntentDef?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
data class ExtraDef(
|
||||
val key: String,
|
||||
val value: String,
|
||||
val type: ExtraType = ExtraType.STRING,
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString() ?: "",
|
||||
parcel.readString() ?: "",
|
||||
ExtraType.valueOf(parcel.readString() ?: ExtraType.STRING.name),
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(key)
|
||||
parcel.writeString(value)
|
||||
parcel.writeString(type.name)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ExtraDef> {
|
||||
override fun createFromParcel(parcel: Parcel): ExtraDef = ExtraDef(parcel)
|
||||
override fun newArray(size: Int): Array<ExtraDef?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ExtraType {
|
||||
STRING,
|
||||
INT,
|
||||
LONG,
|
||||
FLOAT,
|
||||
DOUBLE,
|
||||
BOOLEAN,
|
||||
;
|
||||
|
||||
fun isValid(value: String): Boolean {
|
||||
if (value.isEmpty()) return true
|
||||
return when (this) {
|
||||
STRING -> true
|
||||
INT -> value.toIntOrNull() != null
|
||||
LONG -> value.toLongOrNull() != null
|
||||
FLOAT -> value.toFloatOrNull() != null
|
||||
DOUBLE -> value.toDoubleOrNull() != null
|
||||
BOOLEAN -> value.equals("true", ignoreCase = true) || value.equals("false", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import de.szalkowski.activitylauncher.databinding.FragmentActivityDetailsBinding
|
|||
import de.szalkowski.activitylauncher.domain.external.ReviewRequester
|
||||
import de.szalkowski.activitylauncher.presentation.common.IconPickerDialogFragment
|
||||
import de.szalkowski.activitylauncher.presentation.common.PluginChooserDialogFragment
|
||||
import de.szalkowski.activitylauncher.presentation.intent.EditIntentDialogFragment
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -40,6 +41,13 @@ class ActivityDetailsFragment : Fragment() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
childFragmentManager.setFragmentResultListener(EditIntentDialogFragment.REQUEST_KEY, this) { _, bundle ->
|
||||
val intentDef = bundle.getParcelable<de.szalkowski.activitylauncher.domain.intent.IntentDef>(EditIntentDialogFragment.RESULT_INTENT_DEF)
|
||||
if (intentDef != null) {
|
||||
viewModel.updateIntentDef(intentDef)
|
||||
}
|
||||
}
|
||||
|
||||
childFragmentManager.setFragmentResultListener(PluginChooserDialogFragment.REQUEST_KEY, this) { _, bundle ->
|
||||
val action = bundle.getSerializable(PluginChooserDialogFragment.RESULT_ACTION) as? PluginChooserDialogFragment.PluginAction
|
||||
val launchPlugin = bundle.getParcelable<ComponentName>(PluginChooserDialogFragment.RESULT_LAUNCH_PLUGIN)
|
||||
|
|
@ -85,6 +93,9 @@ class ActivityDetailsFragment : Fragment() {
|
|||
|
||||
val shareItem = menu.findItem(R.id.action_share)
|
||||
shareItem.isEnabled = viewModel.canShare.value
|
||||
|
||||
val advancedItem = menu.findItem(R.id.action_advanced)
|
||||
advancedItem.isEnabled = viewModel.canLaunch.value
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
|
|
@ -97,6 +108,11 @@ class ActivityDetailsFragment : Fragment() {
|
|||
viewModel.shareActivity()
|
||||
true
|
||||
}
|
||||
R.id.action_advanced -> {
|
||||
val dialog = EditIntentDialogFragment.newInstance(viewModel.intentDef.value)
|
||||
dialog.show(childFragmentManager, "edit intent")
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
package de.szalkowski.activitylauncher.presentation.activities
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.os.Bundle
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.szalkowski.activitylauncher.R
|
||||
import de.szalkowski.activitylauncher.core.util.getActivityIntent
|
||||
import de.szalkowski.activitylauncher.core.util.getActivityIntentFromIntentDef
|
||||
import de.szalkowski.activitylauncher.core.util.getIntentDefFromActivityIntent
|
||||
import de.szalkowski.activitylauncher.domain.favorites.FavoritesRepository
|
||||
import de.szalkowski.activitylauncher.domain.intent.IntentDef
|
||||
import de.szalkowski.activitylauncher.domain.launcher.IconLoader
|
||||
import de.szalkowski.activitylauncher.domain.model.LaunchRequest
|
||||
import de.szalkowski.activitylauncher.domain.model.MyActivityInfo
|
||||
|
|
@ -114,6 +116,9 @@ class ActivityDetailsViewModel @Inject constructor(
|
|||
private val _selectedShortcutPlugin = MutableStateFlow<PluginInfo?>(null)
|
||||
val selectedShortcutPlugin: StateFlow<PluginInfo?> = _selectedShortcutPlugin.asStateFlow()
|
||||
|
||||
private val _intentDef = MutableStateFlow(IntentDef())
|
||||
val intentDef: StateFlow<IntentDef> = _intentDef.asStateFlow()
|
||||
|
||||
private val _iconErrorTrigger = MutableStateFlow<String?>(null)
|
||||
|
||||
private val _errorMessage = MutableSharedFlow<Int>()
|
||||
|
|
@ -141,6 +146,8 @@ class ActivityDetailsViewModel @Inject constructor(
|
|||
_editedIconResourceName.value = info.iconResourceName ?: ""
|
||||
|
||||
_editedIcon.value = shortcutRequest?.icon ?: getActivityIconUseCase(info.iconResourceName, componentName)
|
||||
|
||||
_intentDef.value = getIntentDefFromActivityIntent(shortcutRequest?.intent ?: Intent().setComponent(componentName))
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
|
|
@ -189,15 +196,12 @@ class ActivityDetailsViewModel @Inject constructor(
|
|||
_iconErrorTrigger.value = iconResourceName
|
||||
}
|
||||
|
||||
fun updateIntentDef(intentDef: IntentDef) {
|
||||
_intentDef.value = intentDef
|
||||
}
|
||||
|
||||
fun createShortcut() {
|
||||
val info = getEditedActivityInfo()
|
||||
val icon = _editedIcon.value ?: getActivityIconUseCase(info.iconResourceName, info.componentName)
|
||||
val request = ShortcutRequest(
|
||||
name = info.name,
|
||||
intent = getActivityIntent(info.componentName, Bundle()),
|
||||
icon = icon,
|
||||
launcherPlugin = _selectedLaunchPlugin.value?.componentName,
|
||||
)
|
||||
val request = getCurrentShortcutRequest()
|
||||
createShortcutUseCase(request, _selectedShortcutPlugin.value?.componentName)
|
||||
}
|
||||
|
||||
|
|
@ -210,35 +214,32 @@ class ActivityDetailsViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun launchActivity() {
|
||||
val info = getEditedActivityInfo()
|
||||
val request = LaunchRequest(
|
||||
intent = getActivityIntent(info.componentName, Bundle()),
|
||||
launcherPlugin = _selectedLaunchPlugin.value?.componentName,
|
||||
val request = getCurrentShortcutRequest()
|
||||
launchActivityUseCase(
|
||||
LaunchRequest(
|
||||
intent = request.intent,
|
||||
name = request.name,
|
||||
icon = request.icon,
|
||||
launcherPlugin = request.launcherPlugin,
|
||||
),
|
||||
)
|
||||
launchActivityUseCase(request)
|
||||
}
|
||||
|
||||
fun shareActivity() {
|
||||
val info = getEditedActivityInfo()
|
||||
shareActivityUseCase(info.componentName)
|
||||
shareActivityUseCase(ComponentName(_editedPackage.value, _editedClass.value))
|
||||
}
|
||||
|
||||
private fun getEditedActivityInfo(): MyActivityInfo {
|
||||
private fun getCurrentShortcutRequest(): ShortcutRequest {
|
||||
val packageName = _editedPackage.value
|
||||
val className = _editedClass.value
|
||||
val componentName = if (packageName == this.componentName.packageName && className == this.componentName.className) {
|
||||
this.componentName
|
||||
} else if (packageName.isNotEmpty() && className.isNotEmpty()) {
|
||||
ComponentName(packageName, className)
|
||||
} else {
|
||||
this.componentName
|
||||
}
|
||||
val component = ComponentName(packageName, className)
|
||||
val icon = _editedIcon.value ?: getActivityIconUseCase(_editedIconResourceName.value.ifBlank { null }, component)
|
||||
|
||||
return MyActivityInfo(
|
||||
componentName,
|
||||
_editedName.value,
|
||||
_editedIconResourceName.value.ifBlank { null },
|
||||
false,
|
||||
return ShortcutRequest(
|
||||
name = _editedName.value,
|
||||
intent = getActivityIntentFromIntentDef(component, _intentDef.value),
|
||||
icon = icon,
|
||||
launcherPlugin = _selectedLaunchPlugin.value?.componentName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
package de.szalkowski.activitylauncher.presentation.intent
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.PopupMenu
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.szalkowski.activitylauncher.R
|
||||
import de.szalkowski.activitylauncher.databinding.DialogEditIntentBinding
|
||||
import de.szalkowski.activitylauncher.databinding.ItemIntentCategoryBinding
|
||||
import de.szalkowski.activitylauncher.databinding.ItemIntentExtraBinding
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraDef
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraType
|
||||
import de.szalkowski.activitylauncher.domain.intent.IntentDef
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class EditIntentDialogFragment : DialogFragment() {
|
||||
private val viewModel: EditIntentViewModel by viewModels()
|
||||
private var _binding: DialogEditIntentBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
_binding = DialogEditIntentBinding.inflate(layoutInflater)
|
||||
|
||||
val initialIntentDef = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
arguments?.getParcelable(ARG_INTENT_DEF, IntentDef::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
arguments?.getParcelable<IntentDef>(ARG_INTENT_DEF)
|
||||
} ?: IntentDef()
|
||||
viewModel.init(initialIntentDef)
|
||||
|
||||
setupPrefilledAdapters()
|
||||
setupListeners()
|
||||
observeViewModel()
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.title_dialog_edit_intent)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_INTENT_DEF to viewModel.intentDef.value))
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val dialog = dialog as? AlertDialog
|
||||
val okButton = dialog?.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.isIntentValid.collect { isValid ->
|
||||
okButton?.isEnabled = isValid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPrefilledAdapters() {
|
||||
val actions = listOf(
|
||||
Intent.ACTION_VIEW,
|
||||
Intent.ACTION_SEND,
|
||||
Intent.ACTION_EDIT,
|
||||
Intent.ACTION_MAIN,
|
||||
Intent.ACTION_PICK,
|
||||
Intent.ACTION_GET_CONTENT,
|
||||
)
|
||||
binding.atvAction.setAdapter(ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, actions))
|
||||
|
||||
val mimeTypes = listOf(
|
||||
"text/plain",
|
||||
"image/*",
|
||||
"video/*",
|
||||
"audio/*",
|
||||
"application/pdf",
|
||||
"application/octet-stream",
|
||||
)
|
||||
binding.atvMimeType.setAdapter(ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, mimeTypes))
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.atvAction.doAfterTextChanged { viewModel.updateAction(it.toString()) }
|
||||
binding.tiData.doAfterTextChanged { viewModel.updateData(it.toString()) }
|
||||
binding.atvMimeType.doAfterTextChanged { viewModel.updateMimeType(it.toString()) }
|
||||
|
||||
binding.btAddCategory.setOnClickListener { viewModel.addCategory() }
|
||||
binding.btAddExtra.setOnClickListener { showAddExtraMenu() }
|
||||
}
|
||||
|
||||
private fun showAddExtraMenu() {
|
||||
val popup = PopupMenu(requireContext(), binding.btAddExtra)
|
||||
ExtraType.entries.forEach { type ->
|
||||
popup.menu.add(type.name)
|
||||
}
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
viewModel.addExtra(ExtraType.valueOf(item.title.toString()))
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.intentDef.collect { intentDef ->
|
||||
if (!binding.atvAction.isFocused && binding.atvAction.text.toString() != (intentDef.action ?: "")) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
binding.atvAction.setText(intentDef.action, false)
|
||||
} else {
|
||||
binding.atvAction.setText(intentDef.action)
|
||||
}
|
||||
}
|
||||
if (!binding.tiData.isFocused && binding.tiData.text.toString() != (intentDef.data ?: "")) {
|
||||
binding.tiData.setText(intentDef.data)
|
||||
}
|
||||
if (!binding.atvMimeType.isFocused && binding.atvMimeType.text.toString() != (intentDef.mimeType ?: "")) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
binding.atvMimeType.setText(intentDef.mimeType, false)
|
||||
} else {
|
||||
binding.atvMimeType.setText(intentDef.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
updateCategories(intentDef.categories)
|
||||
updateExtras(intentDef.extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCategories(categories: List<String>) {
|
||||
if (binding.llCategories.childCount != categories.size) {
|
||||
binding.llCategories.removeAllViews()
|
||||
categories.forEachIndexed { index, category ->
|
||||
val itemBinding = ItemIntentCategoryBinding.inflate(
|
||||
layoutInflater,
|
||||
binding.llCategories,
|
||||
true,
|
||||
)
|
||||
itemBinding.atvCategory.setText(category)
|
||||
|
||||
fun validateCategory() {
|
||||
val currentText = itemBinding.atvCategory.text.toString()
|
||||
val isDuplicate = currentText.isNotEmpty() && categories.count { it == currentText } > 1
|
||||
itemBinding.tilCategory.error = when {
|
||||
currentText.isEmpty() -> getString(R.string.error_field_required)
|
||||
isDuplicate -> getString(R.string.error_duplicate_category)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
validateCategory()
|
||||
|
||||
val commonCategories = listOf(
|
||||
Intent.CATEGORY_DEFAULT,
|
||||
Intent.CATEGORY_BROWSABLE,
|
||||
Intent.CATEGORY_LAUNCHER,
|
||||
Intent.CATEGORY_HOME,
|
||||
Intent.CATEGORY_PREFERENCE,
|
||||
)
|
||||
itemBinding.atvCategory.setAdapter(
|
||||
ArrayAdapter(
|
||||
requireContext(),
|
||||
android.R.layout.simple_dropdown_item_1line,
|
||||
commonCategories,
|
||||
),
|
||||
)
|
||||
|
||||
itemBinding.atvCategory.doAfterTextChanged {
|
||||
if (itemBinding.atvCategory.hasFocus()) {
|
||||
viewModel.updateCategory(index, it.toString())
|
||||
validateCategory()
|
||||
}
|
||||
}
|
||||
itemBinding.ibRemoveCategory.setOnClickListener { viewModel.removeCategory(index) }
|
||||
}
|
||||
} else {
|
||||
for (i in 0 until categories.size) {
|
||||
val view = binding.llCategories.getChildAt(i)
|
||||
val itemBinding = ItemIntentCategoryBinding.bind(view)
|
||||
if (!itemBinding.atvCategory.isFocused && itemBinding.atvCategory.text.toString() != categories[i]) {
|
||||
itemBinding.atvCategory.setText(categories[i])
|
||||
}
|
||||
|
||||
val currentText = itemBinding.atvCategory.text.toString()
|
||||
val isDuplicate = currentText.isNotEmpty() && categories.count { it == currentText } > 1
|
||||
itemBinding.tilCategory.error = when {
|
||||
currentText.isEmpty() -> getString(R.string.error_field_required)
|
||||
isDuplicate -> getString(R.string.error_duplicate_category)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateExtras(extras: List<ExtraDef>) {
|
||||
// Check if we need to rebuild the list (size changed or types changed)
|
||||
val needsRebuild = binding.llExtras.childCount != extras.size || extras.indices.any { i ->
|
||||
val view = binding.llExtras.getChildAt(i)
|
||||
(view.tag as? ExtraType) != extras[i].type
|
||||
}
|
||||
|
||||
if (needsRebuild) {
|
||||
binding.llExtras.removeAllViews()
|
||||
extras.forEachIndexed { index, extra ->
|
||||
val itemBinding = ItemIntentExtraBinding.inflate(
|
||||
layoutInflater,
|
||||
binding.llExtras,
|
||||
true,
|
||||
)
|
||||
itemBinding.root.tag = extra.type
|
||||
itemBinding.tiExtraKey.setText(extra.key)
|
||||
itemBinding.tiExtraValue.setText(extra.value)
|
||||
|
||||
val hintRes = when (extra.type) {
|
||||
ExtraType.STRING -> R.string.hint_extra_value_string
|
||||
ExtraType.INT -> R.string.hint_extra_value_int
|
||||
ExtraType.LONG -> R.string.hint_extra_value_long
|
||||
ExtraType.FLOAT -> R.string.hint_extra_value_float
|
||||
ExtraType.DOUBLE -> R.string.hint_extra_value_double
|
||||
ExtraType.BOOLEAN -> R.string.hint_extra_value_boolean
|
||||
}
|
||||
itemBinding.tilExtraValue.placeholderText = getString(hintRes)
|
||||
|
||||
fun validate() {
|
||||
val key = itemBinding.tiExtraKey.text.toString()
|
||||
val isDuplicateKey = key.isNotEmpty() && extras.count { it.key == key } > 1
|
||||
itemBinding.tilExtraKey.error = when {
|
||||
key.isEmpty() -> getString(R.string.error_field_required)
|
||||
isDuplicateKey -> getString(R.string.error_duplicate_extra_key)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val value = itemBinding.tiExtraValue.text.toString()
|
||||
if (extra.type.isValid(value)) {
|
||||
itemBinding.tilExtraValue.error = null
|
||||
} else {
|
||||
itemBinding.tilExtraValue.error = when (extra.type) {
|
||||
ExtraType.INT -> getString(R.string.error_invalid_int)
|
||||
ExtraType.LONG -> getString(R.string.error_invalid_long)
|
||||
ExtraType.FLOAT -> getString(R.string.error_invalid_float)
|
||||
ExtraType.DOUBLE -> getString(R.string.error_invalid_double)
|
||||
ExtraType.BOOLEAN -> getString(R.string.error_invalid_boolean)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate()
|
||||
|
||||
itemBinding.tiExtraKey.doAfterTextChanged {
|
||||
if (itemBinding.tiExtraKey.hasFocus()) {
|
||||
val latestExtra = viewModel.intentDef.value.extras.getOrNull(index) ?: return@doAfterTextChanged
|
||||
viewModel.updateExtra(index, latestExtra.copy(key = it.toString()))
|
||||
}
|
||||
}
|
||||
itemBinding.tiExtraValue.doAfterTextChanged {
|
||||
if (itemBinding.tiExtraValue.hasFocus()) {
|
||||
val latestExtra = viewModel.intentDef.value.extras.getOrNull(index) ?: return@doAfterTextChanged
|
||||
viewModel.updateExtra(index, latestExtra.copy(value = it.toString()))
|
||||
validate()
|
||||
}
|
||||
}
|
||||
itemBinding.ibRemoveExtra.setOnClickListener { viewModel.removeExtra(index) }
|
||||
}
|
||||
} else {
|
||||
for (i in 0 until extras.size) {
|
||||
val view = binding.llExtras.getChildAt(i)
|
||||
val itemBinding = ItemIntentExtraBinding.bind(view)
|
||||
val extra = extras[i]
|
||||
|
||||
if (!itemBinding.tiExtraKey.isFocused && itemBinding.tiExtraKey.text.toString() != extra.key) {
|
||||
itemBinding.tiExtraKey.setText(extra.key)
|
||||
}
|
||||
if (!itemBinding.tiExtraValue.isFocused && (itemBinding.tiExtraValue.text.toString() != extra.value)) {
|
||||
itemBinding.tiExtraValue.setText(extra.value)
|
||||
}
|
||||
|
||||
val key = itemBinding.tiExtraKey.text.toString()
|
||||
val isDuplicateKey = key.isNotEmpty() && extras.count { it.key == key } > 1
|
||||
itemBinding.tilExtraKey.error = when {
|
||||
key.isEmpty() -> getString(R.string.error_field_required)
|
||||
isDuplicateKey -> getString(R.string.error_duplicate_extra_key)
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Re-validate in case validation state changed elsewhere
|
||||
val value = itemBinding.tiExtraValue.text.toString()
|
||||
if (extra.type.isValid(value)) {
|
||||
itemBinding.tilExtraValue.error = null
|
||||
} else if (itemBinding.tilExtraValue.error == null) {
|
||||
itemBinding.tilExtraValue.error = when (extra.type) {
|
||||
ExtraType.INT -> getString(R.string.error_invalid_int)
|
||||
ExtraType.LONG -> getString(R.string.error_invalid_long)
|
||||
ExtraType.FLOAT -> getString(R.string.error_invalid_float)
|
||||
ExtraType.DOUBLE -> getString(R.string.error_invalid_double)
|
||||
ExtraType.BOOLEAN -> getString(R.string.error_invalid_boolean)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "edit_intent_request"
|
||||
const val RESULT_INTENT_DEF = "result_intent_def"
|
||||
private const val ARG_INTENT_DEF = "arg_intent_def"
|
||||
|
||||
fun newInstance(intentDef: IntentDef): EditIntentDialogFragment {
|
||||
return EditIntentDialogFragment().apply {
|
||||
arguments = bundleOf(ARG_INTENT_DEF to intentDef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package de.szalkowski.activitylauncher.presentation.intent
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraDef
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraType
|
||||
import de.szalkowski.activitylauncher.domain.intent.IntentDef
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EditIntentViewModel @Inject constructor() : ViewModel() {
|
||||
private val _intentDef = MutableStateFlow(IntentDef())
|
||||
val intentDef: StateFlow<IntentDef> = _intentDef.asStateFlow()
|
||||
|
||||
val isIntentValid: StateFlow<Boolean> = _intentDef.map { def ->
|
||||
val categoriesValid = def.categories.all { it.isNotBlank() } &&
|
||||
def.categories.distinct().size == def.categories.size
|
||||
|
||||
val extrasValid = def.extras.all { extra ->
|
||||
extra.key.isNotBlank() && extra.type.isValid(extra.value)
|
||||
} && def.extras.map { it.key }.distinct().size == def.extras.size
|
||||
|
||||
categoriesValid && extrasValid
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
fun init(initialIntentDef: IntentDef) {
|
||||
_intentDef.value = initialIntentDef
|
||||
}
|
||||
|
||||
fun updateAction(action: String) {
|
||||
_intentDef.value = _intentDef.value.copy(action = action)
|
||||
}
|
||||
|
||||
fun updateData(data: String) {
|
||||
_intentDef.value = _intentDef.value.copy(data = data)
|
||||
}
|
||||
|
||||
fun updateMimeType(mimeType: String) {
|
||||
_intentDef.value = _intentDef.value.copy(mimeType = mimeType)
|
||||
}
|
||||
|
||||
fun addCategory() {
|
||||
val current = _intentDef.value
|
||||
_intentDef.value = current.copy(categories = current.categories + "")
|
||||
}
|
||||
|
||||
fun updateCategory(index: Int, category: String) {
|
||||
val current = _intentDef.value
|
||||
val updated = current.categories.toMutableList()
|
||||
if (index in updated.indices) {
|
||||
updated[index] = category
|
||||
_intentDef.value = current.copy(categories = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCategory(index: Int) {
|
||||
val current = _intentDef.value
|
||||
val updated = current.categories.toMutableList()
|
||||
if (index in updated.indices) {
|
||||
updated.removeAt(index)
|
||||
_intentDef.value = current.copy(categories = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun addExtra(type: ExtraType = ExtraType.STRING) {
|
||||
val current = _intentDef.value
|
||||
_intentDef.value = current.copy(extras = current.extras + ExtraDef("", "", type))
|
||||
}
|
||||
|
||||
fun updateExtra(index: Int, extra: ExtraDef) {
|
||||
val current = _intentDef.value
|
||||
val updated = current.extras.toMutableList()
|
||||
if (index in updated.indices) {
|
||||
updated[index] = extra
|
||||
_intentDef.value = current.copy(extras = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExtra(index: Int) {
|
||||
val current = _intentDef.value
|
||||
val updated = current.extras.toMutableList()
|
||||
if (index in updated.indices) {
|
||||
updated.removeAt(index)
|
||||
_intentDef.value = current.copy(extras = updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_edit.xml
Normal file
10
app/src/main/res/drawable/ic_edit.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_tune.xml
Normal file
10
app/src/main/res/drawable/ic_tune.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,17v2h6v-2H3zM3,5v2h10V5H3zM13,21v-2h8v-2h-8v-2h-2v6H13zM7,9v2H3v2h4v2h2V9H7zM21,13v-2H11v2H21zM15,9h2V7h4V5h-4V3h-2v6z"/>
|
||||
</vector>
|
||||
103
app/src/main/res/layout/dialog_edit_intent.xml
Normal file
103
app/src/main/res/layout/dialog_edit_intent.xml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAction"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_action"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/atvAction"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_action"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_data"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/tiData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilMimeType"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_mime_type"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/atvMimeType"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_mime_type"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_intent_categories"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llCategories"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btAddCategory"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_add_category"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_intent_extras"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llExtras"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btAddExtra"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_add_extra"
|
||||
android:layout_gravity="end" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
37
app/src/main/res/layout/item_intent_category.xml
Normal file
37
app/src/main/res/layout/item_intent_category.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="4dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilCategory"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_categories"
|
||||
app:layout_constraintEnd_toStartOf="@+id/ibRemoveCategory"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/atvCategory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_categories"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/ibRemoveCategory"
|
||||
style="?attr/materialIconButtonStyle"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:contentDescription="@string/context_action_favorite_remove"
|
||||
app:srcCompat="@drawable/ic_delete"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
60
app/src/main/res/layout/item_intent_extra.xml
Normal file
60
app/src/main/res/layout/item_intent_extra.xml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="4dp">
|
||||
|
||||
<androidx.constraintlayout.helper.widget.Flow
|
||||
android:id="@+id/flow"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:constraint_referenced_ids="tilExtraKey,tilExtraValue"
|
||||
app:flow_wrapMode="aligned"
|
||||
app:flow_horizontalStyle="spread"
|
||||
app:flow_horizontalGap="4dp"
|
||||
app:flow_verticalGap="4dp"
|
||||
app:flow_verticalAlign="top"
|
||||
app:layout_constraintEnd_toStartOf="@+id/ibRemoveExtra"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilExtraKey"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_extra_key"
|
||||
app:errorEnabled="true">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/tiExtraKey"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilExtraValue"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_intent_extra_value"
|
||||
app:errorEnabled="true">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/tiExtraValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/ibRemoveExtra"
|
||||
style="?attr/materialIconButtonStyle"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:contentDescription="@string/context_action_favorite_remove"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_delete" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_advanced"
|
||||
android:title="@string/action_advanced_properties"
|
||||
android:icon="@drawable/ic_tune"
|
||||
app:showAsAction="always" />
|
||||
<item
|
||||
android:id="@+id/action_favorite"
|
||||
android:title="@string/context_action_favorite_add"
|
||||
android:icon="@drawable/ic_favorite_border"
|
||||
app:showAsAction="ifRoom" />
|
||||
app:showAsAction="always" />
|
||||
<item
|
||||
android:id="@+id/action_share"
|
||||
android:title="@string/context_action_share"
|
||||
android:icon="@drawable/ic_share"
|
||||
app:showAsAction="ifRoom" />
|
||||
app:showAsAction="always" />
|
||||
</menu>
|
||||
|
|
|
|||
|
|
@ -19,15 +19,37 @@
|
|||
<string name="dialog_support" comment="Dialog message encouraging users to support the app">We\'ve added ads to help fund future development. Prefer no ads? Upgrade to Activity Launcher Pro on Google Play — it\'s fully open-source and supports continued work on the app. You can also get the app for free on GitHub. Support there is optional but helps us a lot.</string>
|
||||
<string name="error" comment="Generic error dialog title">Error</string>
|
||||
<string name="error_creating_shortcut" comment="Error message displayed when shortcut creation fails">Error creating shortcut</string>
|
||||
<string name="error_duplicate_category">Duplicate category</string>
|
||||
<string name="error_duplicate_extra_key">Duplicate key</string>
|
||||
<string name="error_field_required">Field required</string>
|
||||
<string name="error_icons" comment="Error message shown when app icons fail to load">Error loading icons</string>
|
||||
<string name="error_invalid_activity_link" comment="Error message for invalid deep link or activity reference">Invalid activity link provided</string>
|
||||
<string name="error_invalid_boolean">Invalid Boolean (true/false)</string>
|
||||
<string name="error_invalid_double">Invalid Double</string>
|
||||
<string name="error_invalid_float">Invalid Float</string>
|
||||
<string name="error_invalid_icon_format" comment="Error message when the selected icon file format is not supported">Error: invalid icon format</string>
|
||||
<string name="error_invalid_icon_resource" comment="Error message for invalid or non-existent icon resource">Error: invalid icon resource</string>
|
||||
<string name="error_invalid_int">Invalid Integer</string>
|
||||
<string name="error_invalid_long">Invalid Long</string>
|
||||
<string name="error_verbose_pin_shortcut" comment="Error message when the device launcher does not support pinning shortcuts">Current launcher does not support "PinShortcut". Unable to create shortcut.</string>
|
||||
<string name="exception_invalid_component_name" comment="Error message for dangerous or invalid component names. %s is the problematic component">Invalid or potentially harmful component name: %s</string>
|
||||
<string name="filter_hint" comment="Hint text in the filter search input field">package/action</string>
|
||||
<string name="label_class" comment="Form label for the activity class name field">Class</string>
|
||||
<string name="hint_extra_value_boolean">e.g. true or false</string>
|
||||
<string name="hint_extra_value_double">e.g. 1.23456789</string>
|
||||
<string name="hint_extra_value_float">e.g. 1.23</string>
|
||||
<string name="hint_extra_value_int">e.g. 123</string>
|
||||
<string name="hint_extra_value_long">e.g. 123456789</string>
|
||||
<string name="hint_extra_value_string">e.g. some text</string>
|
||||
<string name="label_icon" comment="Form label for the shortcut icon selection">Icon</string>
|
||||
<string name="label_intent_action">Action</string>
|
||||
<string name="label_intent_categories">Categories</string>
|
||||
<string name="label_intent_data">Data URI</string>
|
||||
<string name="label_intent_extra_key">Key</string>
|
||||
<string name="label_intent_extra_type">Type</string>
|
||||
<string name="label_intent_extra_value">Value</string>
|
||||
<string name="label_intent_extras">Extras</string>
|
||||
<string name="label_intent_mime_type">Mime Type</string>
|
||||
<string name="label_launch_plugins">Launch Plugins</string>
|
||||
<string name="label_name" comment="Form label for the shortcut name field">Name</string>
|
||||
<string name="label_package" comment="Form label for the package name field">Package</string>
|
||||
|
|
@ -53,6 +75,11 @@
|
|||
<string name="theme_light" comment="Theme option for light color scheme">Light Theme</string>
|
||||
<string name="title_dialog_disclaimer" comment="Dialog title for the warning dialog shown on first launch">Disclaimer</string>
|
||||
<string name="title_dialog_icon_picker" comment="Dialog title for selecting an icon when creating or editing a shortcut">Pick an icon</string>
|
||||
<string name="action_add_category">Add Category</string>
|
||||
<string name="action_add_extra">Add Extra</string>
|
||||
<string name="action_advanced_properties">Advanced Properties</string>
|
||||
<string name="action_edit_intent">Edit Intent</string>
|
||||
<string name="title_dialog_edit_intent">Edit Intent</string>
|
||||
<string name="title_dialog_support" comment="Dialog title for the support/donate dialog">Thanks for using Activity Launcher</string>
|
||||
<string name="title_fragment_activity_list" comment="Screen title showing activities within a selected package">Activities</string>
|
||||
<string name="title_fragment_package_list" comment="Screen title for the list of installed packages/apps">Installed packages</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package de.szalkowski.activitylauncher.core.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraDef
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraType
|
||||
import de.szalkowski.activitylauncher.domain.intent.IntentDef
|
||||
import org.junit.Test
|
||||
|
||||
class ActivityIntentTest {
|
||||
|
||||
@Test
|
||||
fun `getActivityIntent with IntentDef maps all fields correctly`() {
|
||||
val componentName = ComponentName("pkg", "cls")
|
||||
val intentDef = IntentDef(
|
||||
action = Intent.ACTION_VIEW,
|
||||
data = "https://example.com",
|
||||
mimeType = "text/plain",
|
||||
categories = listOf(Intent.CATEGORY_BROWSABLE),
|
||||
extras = listOf(
|
||||
ExtraDef("key_string", "value", ExtraType.STRING),
|
||||
ExtraDef("key_int", "123", ExtraType.INT),
|
||||
ExtraDef("key_bool", "true", ExtraType.BOOLEAN),
|
||||
),
|
||||
)
|
||||
|
||||
val intent = getActivityIntentFromIntentDef(componentName, intentDef)
|
||||
|
||||
// In unit tests without Robolectric, Intent is a stub and its methods return default values.
|
||||
// We'll verify that the method can be called without exception.
|
||||
assert(intent != null)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
package de.szalkowski.activitylauncher.presentation.activities
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import de.szalkowski.activitylauncher.R
|
||||
import de.szalkowski.activitylauncher.core.util.getActivityIntent
|
||||
import de.szalkowski.activitylauncher.core.util.getActivityIntentFromIntentDef
|
||||
import de.szalkowski.activitylauncher.core.util.getIntentDefFromActivityIntent
|
||||
import de.szalkowski.activitylauncher.domain.favorites.FavoritesRepository
|
||||
import de.szalkowski.activitylauncher.domain.launcher.IconLoader
|
||||
import de.szalkowski.activitylauncher.domain.model.LaunchRequest
|
||||
|
|
@ -32,7 +34,9 @@ import org.junit.After
|
|||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.MockedStatic
|
||||
import org.mockito.kotlin.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ActivityDetailsViewModelTest {
|
||||
|
|
@ -46,13 +50,11 @@ class ActivityDetailsViewModelTest {
|
|||
private val iconLoader: IconLoader = mock()
|
||||
private val recentsRepository: RecentsRepository = mock()
|
||||
private val settingsRepository: SettingsRepository = mock()
|
||||
private val componentName = createMockComponentName("com.test", "Activity")
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private fun createMockComponentName(pkg: String, cls: String): ComponentName = mock {
|
||||
on { packageName } doReturn pkg
|
||||
on { className } doReturn cls
|
||||
private val componentName: ComponentName = mock {
|
||||
on { packageName } doReturn "com.test"
|
||||
on { className } doReturn "Activity"
|
||||
}
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private val activityInfo = MyActivityInfo(
|
||||
componentName,
|
||||
|
|
@ -61,12 +63,20 @@ class ActivityDetailsViewModelTest {
|
|||
false,
|
||||
)
|
||||
|
||||
private lateinit var mockedUtil: MockedStatic<*>
|
||||
private lateinit var viewModel: ActivityDetailsViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
val utilClass = Class.forName("de.szalkowski.activitylauncher.core.util.ActivityIntentKt")
|
||||
mockedUtil = mockStatic(utilClass)
|
||||
|
||||
mockedUtil.`when`<Any> {
|
||||
getIntentDefFromActivityIntent(any())
|
||||
}.thenReturn(de.szalkowski.activitylauncher.domain.intent.IntentDef())
|
||||
|
||||
whenever(packageRepository.getActivity(any())).thenReturn(activityInfo)
|
||||
whenever(favoritesRepository.isFavorite(any())).thenReturn(false)
|
||||
whenever(getActivityIconUseCase.invoke(anyOrNull(), any())).thenReturn(mock<IconCompat>())
|
||||
|
|
@ -83,6 +93,7 @@ class ActivityDetailsViewModelTest {
|
|||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockedUtil.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
|
|
@ -105,34 +116,28 @@ class ActivityDetailsViewModelTest {
|
|||
|
||||
@Test
|
||||
fun `should launch activity`() {
|
||||
val mockIntent = mock<android.content.Intent>()
|
||||
val utilClass = Class.forName("de.szalkowski.activitylauncher.core.util.ActivityIntentKt")
|
||||
org.mockito.Mockito.mockStatic(utilClass).use { mockedUtil ->
|
||||
mockedUtil.`when`<android.content.Intent> {
|
||||
getActivityIntent(eq(componentName), any())
|
||||
}.thenReturn(mockIntent)
|
||||
val launchIntent = mock<Intent>()
|
||||
mockedUtil.`when`<Intent> {
|
||||
getActivityIntentFromIntentDef(anyOrNull(), anyOrNull())
|
||||
}.thenReturn(launchIntent)
|
||||
|
||||
viewModel.launchActivity()
|
||||
val captor = argumentCaptor<LaunchRequest>()
|
||||
verify(launchActivityUseCase).invoke(captor.capture())
|
||||
assertEquals(mockIntent, captor.firstValue.intent)
|
||||
}
|
||||
viewModel.launchActivity()
|
||||
val captor = argumentCaptor<LaunchRequest>()
|
||||
verify(launchActivityUseCase).invoke(captor.capture())
|
||||
assertNotNull(captor.firstValue.intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create shortcut`() {
|
||||
val mockIntent = mock<android.content.Intent>()
|
||||
val utilClass = Class.forName("de.szalkowski.activitylauncher.core.util.ActivityIntentKt")
|
||||
org.mockito.Mockito.mockStatic(utilClass).use { mockedUtil ->
|
||||
mockedUtil.`when`<android.content.Intent> {
|
||||
getActivityIntent(eq(componentName), any())
|
||||
}.thenReturn(mockIntent)
|
||||
val shortcutIntent = mock<Intent>()
|
||||
mockedUtil.`when`<Intent> {
|
||||
getActivityIntentFromIntentDef(anyOrNull(), anyOrNull())
|
||||
}.thenReturn(shortcutIntent)
|
||||
|
||||
viewModel.createShortcut()
|
||||
val captor = argumentCaptor<ShortcutRequest>()
|
||||
verify(createShortcutUseCase).invoke(captor.capture(), isNull())
|
||||
assertEquals(mockIntent, captor.firstValue.intent)
|
||||
}
|
||||
viewModel.createShortcut()
|
||||
val captor = argumentCaptor<ShortcutRequest>()
|
||||
verify(createShortcutUseCase).invoke(captor.capture(), isNull())
|
||||
assertNotNull(captor.firstValue.intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -140,7 +145,6 @@ class ActivityDetailsViewModelTest {
|
|||
whenever(launchActivityUseCase.getPlugins()).thenReturn(listOf(mock(), mock()))
|
||||
whenever(createShortcutUseCase.getPlugins()).thenReturn(listOf(mock(), mock()))
|
||||
|
||||
// Re-init viewModel to pick up new mock values
|
||||
val savedStateHandle = SavedStateHandle(mapOf("activityComponentName" to componentName))
|
||||
val newViewModel = ActivityDetailsViewModel(
|
||||
packageRepository, favoritesRepository, launchActivityUseCase,
|
||||
|
|
@ -157,7 +161,6 @@ class ActivityDetailsViewModelTest {
|
|||
whenever(launchActivityUseCase.getPlugins()).thenReturn(listOf(mock()))
|
||||
whenever(createShortcutUseCase.getPlugins()).thenReturn(listOf(mock()))
|
||||
|
||||
// Re-init viewModel to pick up new mock values
|
||||
val savedStateHandle = SavedStateHandle(mapOf("activityComponentName" to componentName))
|
||||
val newViewModel = ActivityDetailsViewModel(
|
||||
packageRepository, favoritesRepository, launchActivityUseCase,
|
||||
|
|
@ -171,8 +174,8 @@ class ActivityDetailsViewModelTest {
|
|||
|
||||
@Test
|
||||
fun `should load plugins on init`() {
|
||||
val launchPlugin = PluginInfo("Launch Plugin", createMockComponentName("pkg", "cls"), null)
|
||||
val shortcutPlugin = PluginInfo("Shortcut Plugin", createMockComponentName("pkg2", "cls2"), null)
|
||||
val launchPlugin = PluginInfo("Launch Plugin", mock { on { packageName } doReturn "pkg"; on { className } doReturn "cls" }, null)
|
||||
val shortcutPlugin = PluginInfo("Shortcut Plugin", mock { on { packageName } doReturn "pkg2"; on { className } doReturn "cls2" }, null)
|
||||
whenever(launchActivityUseCase.getPlugins()).thenReturn(listOf(launchPlugin))
|
||||
whenever(createShortcutUseCase.getPlugins()).thenReturn(listOf(shortcutPlugin))
|
||||
|
||||
|
|
@ -189,9 +192,15 @@ class ActivityDetailsViewModelTest {
|
|||
|
||||
@Test
|
||||
fun `should use selected launch plugin when launching`() {
|
||||
val pluginComp = createMockComponentName("pkg", "cls")
|
||||
val pluginComp: ComponentName = mock { on { packageName } doReturn "pkg"; on { className } doReturn "cls" }
|
||||
val launchPlugin = PluginInfo("Launch Plugin", pluginComp, null)
|
||||
whenever(launchActivityUseCase.getPlugins()).thenReturn(listOf(launchPlugin))
|
||||
|
||||
val launchIntent = mock<Intent>()
|
||||
mockedUtil.`when`<Intent> {
|
||||
getActivityIntentFromIntentDef(anyOrNull(), anyOrNull())
|
||||
}.thenReturn(launchIntent)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(mapOf("activityComponentName" to componentName))
|
||||
val newViewModel = ActivityDetailsViewModel(
|
||||
packageRepository, favoritesRepository, launchActivityUseCase,
|
||||
|
|
@ -200,28 +209,25 @@ class ActivityDetailsViewModelTest {
|
|||
)
|
||||
|
||||
newViewModel.selectLaunchPlugin(pluginComp)
|
||||
newViewModel.launchActivity()
|
||||
|
||||
val mockIntent = mock<android.content.Intent>()
|
||||
val utilClass = Class.forName("de.szalkowski.activitylauncher.core.util.ActivityIntentKt")
|
||||
org.mockito.Mockito.mockStatic(utilClass).use { mockedUtil ->
|
||||
mockedUtil.`when`<android.content.Intent> {
|
||||
getActivityIntent(eq(componentName), any())
|
||||
}.thenReturn(mockIntent)
|
||||
|
||||
newViewModel.launchActivity()
|
||||
|
||||
val captor = argumentCaptor<LaunchRequest>()
|
||||
verify(launchActivityUseCase).invoke(captor.capture())
|
||||
assertEquals(mockIntent, captor.firstValue.intent)
|
||||
assertEquals(pluginComp, captor.firstValue.launcherPlugin)
|
||||
}
|
||||
val captor = argumentCaptor<LaunchRequest>()
|
||||
verify(launchActivityUseCase).invoke(captor.capture())
|
||||
assertNotNull(captor.firstValue.intent)
|
||||
assertEquals(pluginComp, captor.firstValue.launcherPlugin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should use selected shortcut plugin when creating shortcut`() {
|
||||
val pluginComp = createMockComponentName("pkg2", "cls2")
|
||||
val pluginComp: ComponentName = mock { on { packageName } doReturn "pkg2"; on { className } doReturn "cls2" }
|
||||
val shortcutPlugin = PluginInfo("Shortcut Plugin", pluginComp, null)
|
||||
whenever(createShortcutUseCase.getPlugins()).thenReturn(listOf(shortcutPlugin))
|
||||
|
||||
val shortcutIntent = mock<Intent>()
|
||||
mockedUtil.`when`<Intent> {
|
||||
getActivityIntentFromIntentDef(anyOrNull(), anyOrNull())
|
||||
}.thenReturn(shortcutIntent)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(mapOf("activityComponentName" to componentName))
|
||||
val newViewModel = ActivityDetailsViewModel(
|
||||
packageRepository, favoritesRepository, launchActivityUseCase,
|
||||
|
|
@ -230,27 +236,24 @@ class ActivityDetailsViewModelTest {
|
|||
)
|
||||
|
||||
newViewModel.selectShortcutPlugin(pluginComp)
|
||||
newViewModel.createShortcut()
|
||||
|
||||
val mockIntent = mock<android.content.Intent>()
|
||||
val utilClass = Class.forName("de.szalkowski.activitylauncher.core.util.ActivityIntentKt")
|
||||
org.mockito.Mockito.mockStatic(utilClass).use { mockedUtil ->
|
||||
mockedUtil.`when`<android.content.Intent> {
|
||||
getActivityIntent(eq(componentName), any())
|
||||
}.thenReturn(mockIntent)
|
||||
|
||||
newViewModel.createShortcut()
|
||||
|
||||
val captor = argumentCaptor<ShortcutRequest>()
|
||||
verify(createShortcutUseCase).invoke(captor.capture(), eq(pluginComp))
|
||||
assertEquals(mockIntent, captor.firstValue.intent)
|
||||
}
|
||||
val captor = argumentCaptor<ShortcutRequest>()
|
||||
verify(createShortcutUseCase).invoke(captor.capture(), eq(pluginComp))
|
||||
assertNotNull(captor.firstValue.intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should pass launch plugin extra when creating shortcut`() {
|
||||
val pluginComp = createMockComponentName("pkg", "cls")
|
||||
val pluginComp: ComponentName = mock { on { packageName } doReturn "pkg"; on { className } doReturn "cls" }
|
||||
val launchPlugin = PluginInfo("Launch Plugin", pluginComp, null)
|
||||
whenever(launchActivityUseCase.getPlugins()).thenReturn(listOf(launchPlugin))
|
||||
|
||||
val shortcutIntent = mock<Intent>()
|
||||
mockedUtil.`when`<Intent> {
|
||||
getActivityIntentFromIntentDef(anyOrNull(), anyOrNull())
|
||||
}.thenReturn(shortcutIntent)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(mapOf("activityComponentName" to componentName))
|
||||
val newViewModel = ActivityDetailsViewModel(
|
||||
packageRepository, favoritesRepository, launchActivityUseCase,
|
||||
|
|
@ -259,21 +262,12 @@ class ActivityDetailsViewModelTest {
|
|||
)
|
||||
|
||||
newViewModel.selectLaunchPlugin(pluginComp)
|
||||
newViewModel.createShortcut()
|
||||
|
||||
val mockIntent = mock<android.content.Intent>()
|
||||
val utilClass = Class.forName("de.szalkowski.activitylauncher.core.util.ActivityIntentKt")
|
||||
org.mockito.Mockito.mockStatic(utilClass).use { mockedUtil ->
|
||||
mockedUtil.`when`<android.content.Intent> {
|
||||
getActivityIntent(eq(componentName), any())
|
||||
}.thenReturn(mockIntent)
|
||||
|
||||
newViewModel.createShortcut()
|
||||
|
||||
val captor = argumentCaptor<ShortcutRequest>()
|
||||
verify(createShortcutUseCase).invoke(captor.capture(), isNull())
|
||||
assertEquals(mockIntent, captor.firstValue.intent)
|
||||
assertEquals(pluginComp, captor.firstValue.launcherPlugin)
|
||||
}
|
||||
val captor = argumentCaptor<ShortcutRequest>()
|
||||
verify(createShortcutUseCase).invoke(captor.capture(), isNull())
|
||||
assertNotNull(captor.firstValue.intent)
|
||||
assertEquals(pluginComp, captor.firstValue.launcherPlugin)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -338,8 +332,8 @@ class ActivityDetailsViewModelTest {
|
|||
fun `should emit error message with debounce when icon loading fails`() = runTest {
|
||||
val iconRes = "invalid_icon"
|
||||
whenever(iconLoader.tryGetIcon(iconRes)).thenReturn(Result.failure(IconLoader.NullResourceException()))
|
||||
val mockIcon: IconCompat = mock()
|
||||
whenever(getActivityIconUseCase.invoke(null, componentName)).thenReturn(mockIcon)
|
||||
val fallbackIcon: IconCompat = mock()
|
||||
whenever(getActivityIconUseCase.invoke(null, componentName)).thenReturn(fallbackIcon)
|
||||
|
||||
val errorMessages = mutableListOf<Int>()
|
||||
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
|
|
@ -350,10 +344,10 @@ class ActivityDetailsViewModelTest {
|
|||
|
||||
// Immediately after update, error should NOT be there yet
|
||||
assertEquals(0, errorMessages.size)
|
||||
assertEquals(mockIcon, viewModel.editedIcon.value)
|
||||
assertEquals(fallbackIcon, viewModel.editedIcon.value)
|
||||
|
||||
// Advance time by 2 seconds
|
||||
advanceTimeBy(2000)
|
||||
advanceTimeBy(2000.milliseconds)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, errorMessages.size)
|
||||
|
|
@ -446,4 +440,15 @@ class ActivityDetailsViewModelTest {
|
|||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should update intent def`() {
|
||||
val intentDef = de.szalkowski.activitylauncher.domain.intent.IntentDef(
|
||||
action = "android.intent.action.VIEW",
|
||||
)
|
||||
|
||||
viewModel.updateIntentDef(intentDef)
|
||||
|
||||
assertEquals("android.intent.action.VIEW", viewModel.intentDef.value.action)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package de.szalkowski.activitylauncher.presentation.intent
|
||||
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraDef
|
||||
import de.szalkowski.activitylauncher.domain.intent.ExtraType
|
||||
import de.szalkowski.activitylauncher.domain.intent.IntentDef
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EditIntentViewModelTest {
|
||||
private lateinit var viewModel: EditIntentViewModel
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
viewModel = EditIntentViewModel()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateAction updates intentDef`() {
|
||||
viewModel.updateAction("new_action")
|
||||
assertEquals("new_action", viewModel.intentDef.value.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addExtra adds a new extra`() {
|
||||
viewModel.addExtra()
|
||||
assertEquals(1, viewModel.intentDef.value.extras.size)
|
||||
assertEquals(ExtraType.STRING, viewModel.intentDef.value.extras[0].type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateExtra updates specific extra`() {
|
||||
viewModel.addExtra()
|
||||
val newExtra = ExtraDef("key", "value", ExtraType.INT)
|
||||
viewModel.updateExtra(0, newExtra)
|
||||
assertEquals(newExtra, viewModel.intentDef.value.extras[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeCategory removes specific category`() {
|
||||
val initial = IntentDef(categories = listOf("cat1", "cat2"))
|
||||
viewModel.init(initial)
|
||||
viewModel.removeCategory(0)
|
||||
assertEquals(listOf("cat2"), viewModel.intentDef.value.categories)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isIntentValid checks for duplicates and empty keys`() = runTest {
|
||||
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.isIntentValid.collect()
|
||||
}
|
||||
|
||||
// Valid initial state
|
||||
assertTrue(viewModel.isIntentValid.value)
|
||||
|
||||
// Duplicate category
|
||||
viewModel.init(IntentDef(categories = listOf("cat", "cat")))
|
||||
assertFalse(viewModel.isIntentValid.value)
|
||||
|
||||
// Empty category
|
||||
viewModel.init(IntentDef(categories = listOf("")))
|
||||
assertFalse(viewModel.isIntentValid.value)
|
||||
|
||||
// Duplicate extra key
|
||||
viewModel.init(
|
||||
IntentDef(
|
||||
extras = listOf(
|
||||
ExtraDef("key", "val1"),
|
||||
ExtraDef("key", "val2"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(viewModel.isIntentValid.value)
|
||||
|
||||
// Empty extra key
|
||||
viewModel.init(IntentDef(extras = listOf(ExtraDef("", "val"))))
|
||||
assertFalse(viewModel.isIntentValid.value)
|
||||
|
||||
// Invalid extra value for type
|
||||
viewModel.init(IntentDef(extras = listOf(ExtraDef("key", "not_int", ExtraType.INT))))
|
||||
assertFalse(viewModel.isIntentValid.value)
|
||||
|
||||
// All valid
|
||||
viewModel.init(
|
||||
IntentDef(
|
||||
categories = listOf("cat1", "cat2"),
|
||||
extras = listOf(
|
||||
ExtraDef("key1", "val"),
|
||||
ExtraDef("key2", "123", ExtraType.INT),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(viewModel.isIntentValid.value)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue