From 3aee137a3c4075fdae24b32fa9d35a1773827d3f Mon Sep 17 00:00:00 2001 From: Josia Pietsch Date: Tue, 18 Feb 2025 04:20:27 +0100 Subject: [PATCH] basic support for pinned shortcuts (see #45) TODO: Show pinned shortcuts in app list --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 10 ++ .../de/jrpie/android/launcher/Application.kt | 3 + .../de/jrpie/android/launcher/Functions.kt | 33 +++++ .../launcher/actions/ShortcutAction.kt | 57 ++++++++ .../actions/shortcuts/PinnedShortcutInfo.kt | 60 ++++++++ .../launcher/ui/PinShortcutActivity.kt | 128 ++++++++++++++++++ .../main/res/drawable/baseline_close_24.xml | 1 - .../res/drawable/baseline_favorite_24.xml | 1 - .../drawable/baseline_favorite_border_24.xml | 1 - .../drawable/baseline_flashlight_on_24.xml | 1 - .../main/res/drawable/baseline_lock_24.xml | 9 +- .../res/drawable/baseline_lock_open_24.xml | 1 - .../main/res/drawable/baseline_menu_24.xml | 10 +- .../res/drawable/baseline_more_horiz_24.xml | 10 +- .../drawable/baseline_not_interested_24.xml | 1 - .../drawable/baseline_notifications_24.xml | 1 - .../main/res/drawable/baseline_search_24.xml | 1 - .../res/drawable/baseline_security_24.xml | 1 - .../res/drawable/baseline_settings_24.xml | 1 - .../baseline_settings_applications_24.xml | 1 - .../res/drawable/baseline_skip_next_24.xml | 1 - .../drawable/baseline_skip_previous_24.xml | 1 - .../res/drawable/baseline_volume_down_24.xml | 1 - .../res/drawable/baseline_volume_up_24.xml | 1 - .../main/res/layout/activity_pin_shortcut.xml | 110 +++++++++++++++ .../main/res/layout/dialog_select_gesture.xml | 27 ++++ .../res/layout/dialog_select_gesture_row.xml | 53 ++++++++ app/src/main/res/values/strings.xml | 5 + build.gradle | 4 +- 30 files changed, 509 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt create mode 100644 app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt create mode 100644 app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt create mode 100644 app/src/main/res/layout/activity_pin_shortcut.xml create mode 100644 app/src/main/res/layout/dialog_select_gesture.xml create mode 100644 app/src/main/res/layout/dialog_select_gesture_row.xml diff --git a/app/build.gradle b/app/build.gradle index 6656d5e..c81509c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,6 +95,7 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'androidx.activity:activity:1.8.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3096d6d..93f6ce8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,16 @@ android:supportsRtl="true" android:theme="@style/launcherBaseTheme" tools:ignore="UnusedAttribute"> + + + + + + = VERSION_CODES.N_MR1) { + removeUnusedShortcuts(this) + } loadApps() } diff --git a/app/src/main/java/de/jrpie/android/launcher/Functions.kt b/app/src/main/java/de/jrpie/android/launcher/Functions.kt index 5f7e9c9..8fc95a3 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Functions.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Functions.kt @@ -9,7 +9,9 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.LauncherApps +import android.content.pm.LauncherApps.ShortcutQuery import android.content.pm.PackageManager +import android.content.pm.ShortcutInfo import android.net.Uri import android.os.Build import android.os.Bundle @@ -18,8 +20,11 @@ import android.os.UserManager import android.provider.Settings import android.util.Log import android.widget.Toast +import androidx.annotation.RequiresApi import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture +import de.jrpie.android.launcher.actions.ShortcutAction +import de.jrpie.android.launcher.actions.shortcuts.PinnedShortcutInfo import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.apps.getPrivateSpaceUser @@ -81,6 +86,34 @@ fun getUserFromId(userId: Int?, context: Context): UserHandle { return profiles.firstOrNull { it.hashCode() == userId } ?: profiles[0] } +@RequiresApi(Build.VERSION_CODES.N_MR1) +fun removeUnusedShortcuts(context: Context) { + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + fun getShortcuts(profile: UserHandle): List? { + return launcherApps.getShortcuts( + ShortcutQuery().apply { + setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) + }, + profile + ) + } + + val userManager = context.getSystemService(Service.USER_SERVICE) as UserManager + val boundActions: Set = + Gesture.entries.mapNotNull { Action.forGesture(it) as? ShortcutAction }.map { it.shortcut } + .toSet() + try { + userManager.userProfiles.filter { !userManager.isQuietModeEnabled(it) }.forEach { profile -> + getShortcuts(profile)?.groupBy { it.`package` }?.forEach { (p, shortcuts) -> + launcherApps.pinShortcuts(p, + shortcuts.filter { boundActions.contains(PinnedShortcutInfo(it)) } + .map { it.id }.toList(), + profile + ) + } + } + } catch (_: SecurityException) { } +} fun openInBrowser(url: String, context: Context) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt b/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt new file mode 100644 index 0000000..8517b1a --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt @@ -0,0 +1,57 @@ +package de.jrpie.android.launcher.actions + +import android.app.Service +import android.content.Context +import android.content.pm.LauncherApps +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.os.Build +import de.jrpie.android.launcher.actions.shortcuts.PinnedShortcutInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("action:shortcut") +class ShortcutAction(val shortcut: PinnedShortcutInfo) : Action { + + override fun invoke(context: Context, rect: Rect?): Boolean { + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + // TODO + return false + } + shortcut.getShortcutInfo(context)?.let { + launcherApps.startShortcut(it, rect, null) + } + + // TODO: handle null + return true + } + + override fun label(context: Context): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return "?" + } + + return shortcut.getShortcutInfo(context)?.longLabel?.toString() ?: "?" + } + + override fun getIcon(context: Context): Drawable? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return null + } + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + return shortcut.getShortcutInfo(context)?.let { launcherApps.getShortcutBadgedIconDrawable(it, 0) } + } + + override fun isAvailable(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return false + } + return shortcut.getShortcutInfo(context) != null + } + + override fun canReachSettings(): Boolean { + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt b/app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt new file mode 100644 index 0000000..796c737 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt @@ -0,0 +1,60 @@ +package de.jrpie.android.launcher.actions.shortcuts + +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.LauncherApps.ShortcutQuery +import android.content.pm.ShortcutInfo +import android.os.Build +import androidx.annotation.RequiresApi +import de.jrpie.android.launcher.getUserFromId +import kotlinx.serialization.Serializable + + +@RequiresApi(Build.VERSION_CODES.N_MR1) +@Serializable +class PinnedShortcutInfo( + val id: String, + val packageName: String, + val activityName: String, + val user: Int +) { + + constructor(info: ShortcutInfo) : this(info.id, info.`package`, info.activity?.className ?: "", info.userHandle.hashCode()) + + fun getShortcutInfo(context: Context): ShortcutInfo? { + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + + return launcherApps.getShortcuts( + ShortcutQuery().apply { + setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) + setPackage(packageName) + setActivity(ComponentName(packageName, activityName)) + setShortcutIds(listOf(id)) + }, + getUserFromId(user, context) + )?.firstOrNull() + } + + override fun equals(other: Any?): Boolean { + return (other as? PinnedShortcutInfo)?.let { + packageName == this.packageName && + activityName == this.activityName && + id == this.id && + user == this.user + } ?: false + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + packageName.hashCode() + result = 31 * result + activityName.hashCode() + result = 31 * result + user + return result + } + + override fun toString(): String { + return "PinnedShortcutInfo { package=$packageName, activity=$activityName, user=$user, id=$id}" + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt new file mode 100644 index 0000000..d19fe04 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt @@ -0,0 +1,128 @@ +package de.jrpie.android.launcher.ui + +import android.app.AlertDialog +import android.app.Service +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.LauncherApps.PinItemRequest +import android.content.res.Resources +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.actions.Gesture +import de.jrpie.android.launcher.actions.ShortcutAction +import de.jrpie.android.launcher.actions.shortcuts.PinnedShortcutInfo +import de.jrpie.android.launcher.databinding.ActivityPinShortcutBinding +import de.jrpie.android.launcher.preferences.LauncherPreferences + +class PinShortcutActivity : AppCompatActivity(), UIObject { + private lateinit var binding: ActivityPinShortcutBinding + + private var isBound = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + super.onCreate() + enableEdgeToEdge() + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + finish() + return + } + + binding = ActivityPinShortcutBinding.inflate(layoutInflater) + setContentView(binding.root) + + val launcherApps = getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + + val request = launcherApps.getPinItemRequest(intent) + if (request == null || request.requestType != PinItemRequest.REQUEST_TYPE_SHORTCUT) { + finish() + return + } + + binding.pinShortcutLabel.text = request.shortcutInfo!!.shortLabel ?: "?" + binding.pinShortcutLabel.setCompoundDrawables( + launcherApps.getShortcutBadgedIconDrawable(request.shortcutInfo, 0).also { + val size = (40 * resources.displayMetrics.density).toInt() + it.setBounds(0,0, size, size) + }, null, null, null) + + binding.pinShortcutButtonBind.setOnClickListener { + AlertDialog.Builder(this, R.style.AlertDialogCustom) + .setTitle(getString(R.string.pin_shortcut_button_bind)) + .setView(R.layout.dialog_select_gesture) + .setNegativeButton(android.R.string.cancel, null) + .create().also { it.show() }.let { dialog -> + val viewManager = LinearLayoutManager(dialog.context) + val viewAdapter = GestureRecyclerAdapter (dialog.context) { gesture -> + if (!isBound) { + isBound = true + request.accept() + } + val editor = LauncherPreferences.getSharedPreferences().edit() + ShortcutAction(PinnedShortcutInfo(request.shortcutInfo!!)).bindToGesture(editor, gesture.id) + editor.apply() + dialog.dismiss() + } + dialog.findViewById(R.id.dialog_select_gesture_recycler).apply { + setHasFixedSize(true) + layoutManager = viewManager + adapter = viewAdapter + } + } + } + + binding.pinShortcutClose.setOnClickListener { finish() } + } + + override fun onStart() { + super.onStart() + super.onStart() + } + + override fun getTheme(): Resources.Theme { + return modifyTheme(super.getTheme()) + } + + inner class GestureRecyclerAdapter(val context: Context, val onClick: (Gesture) -> Unit): RecyclerView.Adapter() { + val gestures = Gesture.entries.filter { it.isEnabled() }.toList() + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val label = itemView.findViewById(R.id.dialog_select_gesture_row_name) + val description = itemView.findViewById(R.id.dialog_select_gesture_row_description) + val icon = itemView.findViewById(R.id.dialog_select_gesture_row_icon) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view: View = inflater.inflate(R.layout.dialog_select_gesture_row, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val gesture = gestures[position] + holder.label.text = gesture.getLabel(context) + holder.description.text = gesture.getDescription(context) + holder.icon.setImageDrawable( + Action.forGesture(gesture)?.getIcon(context) + ) + holder.itemView.setOnClickListener { + onClick(gesture) + } + } + + override fun getItemCount(): Int { + return gestures.size + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_close_24.xml b/app/src/main/res/drawable/baseline_close_24.xml index 41350ac..2ab439d 100644 --- a/app/src/main/res/drawable/baseline_close_24.xml +++ b/app/src/main/res/drawable/baseline_close_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_favorite_24.xml b/app/src/main/res/drawable/baseline_favorite_24.xml index 4f9b020..5a612d2 100644 --- a/app/src/main/res/drawable/baseline_favorite_24.xml +++ b/app/src/main/res/drawable/baseline_favorite_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_favorite_border_24.xml b/app/src/main/res/drawable/baseline_favorite_border_24.xml index cecc9b0..14875dd 100644 --- a/app/src/main/res/drawable/baseline_favorite_border_24.xml +++ b/app/src/main/res/drawable/baseline_favorite_border_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_flashlight_on_24.xml b/app/src/main/res/drawable/baseline_flashlight_on_24.xml index e1326ae..16654cd 100644 --- a/app/src/main/res/drawable/baseline_flashlight_on_24.xml +++ b/app/src/main/res/drawable/baseline_flashlight_on_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_lock_24.xml b/app/src/main/res/drawable/baseline_lock_24.xml index 1e96180..8cb2d1f 100644 --- a/app/src/main/res/drawable/baseline_lock_24.xml +++ b/app/src/main/res/drawable/baseline_lock_24.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="960" - android:viewportHeight="960" - android:tint="?attr/colorControlNormal"> - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/baseline_lock_open_24.xml b/app/src/main/res/drawable/baseline_lock_open_24.xml index f0f6ea3..8d8e09b 100644 --- a/app/src/main/res/drawable/baseline_lock_open_24.xml +++ b/app/src/main/res/drawable/baseline_lock_open_24.xml @@ -1,7 +1,6 @@ + - + diff --git a/app/src/main/res/drawable/baseline_more_horiz_24.xml b/app/src/main/res/drawable/baseline_more_horiz_24.xml index a370298..061fae2 100644 --- a/app/src/main/res/drawable/baseline_more_horiz_24.xml +++ b/app/src/main/res/drawable/baseline_more_horiz_24.xml @@ -1,5 +1,11 @@ - + - + diff --git a/app/src/main/res/drawable/baseline_not_interested_24.xml b/app/src/main/res/drawable/baseline_not_interested_24.xml index 48ab05d..875f546 100644 --- a/app/src/main/res/drawable/baseline_not_interested_24.xml +++ b/app/src/main/res/drawable/baseline_not_interested_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_notifications_24.xml b/app/src/main/res/drawable/baseline_notifications_24.xml index b695693..ca969df 100644 --- a/app/src/main/res/drawable/baseline_notifications_24.xml +++ b/app/src/main/res/drawable/baseline_notifications_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_search_24.xml b/app/src/main/res/drawable/baseline_search_24.xml index ca9cbc0..9ba30e3 100644 --- a/app/src/main/res/drawable/baseline_search_24.xml +++ b/app/src/main/res/drawable/baseline_search_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_security_24.xml b/app/src/main/res/drawable/baseline_security_24.xml index 3c260ff..cd38b06 100644 --- a/app/src/main/res/drawable/baseline_security_24.xml +++ b/app/src/main/res/drawable/baseline_security_24.xml @@ -1,6 +1,5 @@ diff --git a/app/src/main/res/drawable/baseline_settings_24.xml b/app/src/main/res/drawable/baseline_settings_24.xml index 7cb5b17..4200acc 100644 --- a/app/src/main/res/drawable/baseline_settings_24.xml +++ b/app/src/main/res/drawable/baseline_settings_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_settings_applications_24.xml b/app/src/main/res/drawable/baseline_settings_applications_24.xml index f2d03cc..dd30af7 100644 --- a/app/src/main/res/drawable/baseline_settings_applications_24.xml +++ b/app/src/main/res/drawable/baseline_settings_applications_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_skip_next_24.xml b/app/src/main/res/drawable/baseline_skip_next_24.xml index 0091e03..9e203e0 100644 --- a/app/src/main/res/drawable/baseline_skip_next_24.xml +++ b/app/src/main/res/drawable/baseline_skip_next_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_skip_previous_24.xml b/app/src/main/res/drawable/baseline_skip_previous_24.xml index 0029a3e..832a188 100644 --- a/app/src/main/res/drawable/baseline_skip_previous_24.xml +++ b/app/src/main/res/drawable/baseline_skip_previous_24.xml @@ -1,7 +1,6 @@ diff --git a/app/src/main/res/drawable/baseline_volume_down_24.xml b/app/src/main/res/drawable/baseline_volume_down_24.xml index 78b51d3..1a34ad9 100644 --- a/app/src/main/res/drawable/baseline_volume_down_24.xml +++ b/app/src/main/res/drawable/baseline_volume_down_24.xml @@ -2,7 +2,6 @@ android:width="24dp" android:height="24dp" android:autoMirrored="true" - android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/baseline_volume_up_24.xml b/app/src/main/res/drawable/baseline_volume_up_24.xml index 6737fa6..f147499 100644 --- a/app/src/main/res/drawable/baseline_volume_up_24.xml +++ b/app/src/main/res/drawable/baseline_volume_up_24.xml @@ -2,7 +2,6 @@ android:width="24dp" android:height="24dp" android:autoMirrored="true" - android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/layout/activity_pin_shortcut.xml b/app/src/main/res/layout/activity_pin_shortcut.xml new file mode 100644 index 0000000..c401b42 --- /dev/null +++ b/app/src/main/res/layout/activity_pin_shortcut.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +