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:
Adam M. Szalkowski 2026-07-07 15:58:47 +02:00
parent 43eb5b30b2
commit 635e9cb328
16 changed files with 1119 additions and 117 deletions

View file

@ -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,
)
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}