From cd68aed8faf65bcffa280c962b4768d7f34c5407 Mon Sep 17 00:00:00 2001 From: Conor Noonan Date: Wed, 18 Feb 2026 11:22:04 +0800 Subject: [PATCH 1/2] Add support for external action providers Allow third-party apps to register as external action providers that can be triggered by Di2 switch events. Providers are discovered via intent services, communicate actions over broadcast intents, and appear as options in the switch preference lists. Includes a sample-provider module demonstrating the provider API. Co-Authored-By: Claude Opus 4.6 --- .../ki2/data/action/Ki2ActionEvent.java | 77 ++++ .../valterc/ki2/external/ExternalAction.java | 47 ++ .../external/ExternalActionDescriptor.java | 38 ++ .../ki2/external/ExternalActionManager.java | 424 ++++++++++++++++++ .../ki2/external/ExternalActionTarget.java | 28 ++ .../com/valterc/ki2/input/InputManager.java | 104 +++-- .../com/valterc/ki2/services/Ki2Service.java | 53 ++- .../preference/SwitchListPreference.java | 83 +++- app/src/main/res/values/strings.xml | 1 + sample-provider/build.gradle | 21 + sample-provider/src/main/AndroidManifest.xml | 28 ++ .../SampleActionProviderReceiver.java | 111 +++++ .../SampleActionProviderService.java | 17 + 13 files changed, 996 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/valterc/ki2/data/action/Ki2ActionEvent.java create mode 100644 app/src/main/java/com/valterc/ki2/external/ExternalAction.java create mode 100644 app/src/main/java/com/valterc/ki2/external/ExternalActionDescriptor.java create mode 100644 app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java create mode 100644 app/src/main/java/com/valterc/ki2/external/ExternalActionTarget.java create mode 100644 sample-provider/build.gradle create mode 100644 sample-provider/src/main/AndroidManifest.xml create mode 100644 sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderReceiver.java create mode 100644 sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java diff --git a/app/src/main/java/com/valterc/ki2/data/action/Ki2ActionEvent.java b/app/src/main/java/com/valterc/ki2/data/action/Ki2ActionEvent.java new file mode 100644 index 0000000..2a6d1e9 --- /dev/null +++ b/app/src/main/java/com/valterc/ki2/data/action/Ki2ActionEvent.java @@ -0,0 +1,77 @@ +package com.valterc.ki2.data.action; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.valterc.ki2.external.ExternalActionTarget; + +public class Ki2ActionEvent { + + public enum Type { + KAROO, + EXTERNAL + } + + @NonNull + private final Type type; + @Nullable + private final KarooActionEvent karooActionEvent; + @Nullable + private final ExternalActionTarget externalActionTarget; + private final int replicate; + + private Ki2ActionEvent(@NonNull Type type, + @Nullable KarooActionEvent karooActionEvent, + @Nullable ExternalActionTarget externalActionTarget, + int replicate) { + this.type = type; + this.karooActionEvent = karooActionEvent; + this.externalActionTarget = externalActionTarget; + this.replicate = replicate; + } + + @NonNull + public static Ki2ActionEvent forKaroo(@NonNull KarooActionEvent event) { + return new Ki2ActionEvent(Type.KAROO, event, null, event.getReplicate()); + } + + @NonNull + public static Ki2ActionEvent forExternal(@NonNull ExternalActionTarget target) { + return new Ki2ActionEvent(Type.EXTERNAL, null, target, 1); + } + + @NonNull + public static Ki2ActionEvent forExternal(@NonNull ExternalActionTarget target, int replicate) { + return new Ki2ActionEvent(Type.EXTERNAL, null, target, replicate); + } + + @NonNull + public Type getType() { + return type; + } + + @Nullable + public KarooActionEvent getKarooActionEvent() { + return karooActionEvent; + } + + @Nullable + public ExternalActionTarget getExternalActionTarget() { + return externalActionTarget; + } + + public int getReplicate() { + return replicate; + } + + @NonNull + public Ki2ActionEvent withReplicate(int newReplicate) { + if (type == Type.KAROO && karooActionEvent != null) { + return new Ki2ActionEvent(type, new KarooActionEvent(karooActionEvent, newReplicate), null, newReplicate); + } + if (type == Type.EXTERNAL && externalActionTarget != null) { + return new Ki2ActionEvent(type, null, externalActionTarget, newReplicate); + } + return this; + } +} diff --git a/app/src/main/java/com/valterc/ki2/external/ExternalAction.java b/app/src/main/java/com/valterc/ki2/external/ExternalAction.java new file mode 100644 index 0000000..0bf5d3a --- /dev/null +++ b/app/src/main/java/com/valterc/ki2/external/ExternalAction.java @@ -0,0 +1,47 @@ +package com.valterc.ki2.external; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class ExternalAction { + + public static final int SWITCH_CH1 = 1; + public static final int SWITCH_CH2 = 1 << 1; + public static final int SWITCH_CH3 = 1 << 2; + public static final int SWITCH_CH4 = 1 << 3; + public static final int SWITCH_ALL = SWITCH_CH1 | SWITCH_CH2 | SWITCH_CH3 | SWITCH_CH4; + + @NonNull + private final String actionId; + @NonNull + private final String label; + @Nullable + private final String iconUri; + private final int allowedSwitches; + + public ExternalAction(@NonNull String actionId, @NonNull String label, @Nullable String iconUri, int allowedSwitches) { + this.actionId = actionId; + this.label = label; + this.iconUri = iconUri; + this.allowedSwitches = allowedSwitches; + } + + @NonNull + public String getActionId() { + return actionId; + } + + @NonNull + public String getLabel() { + return label; + } + + @Nullable + public String getIconUri() { + return iconUri; + } + + public int getAllowedSwitches() { + return allowedSwitches == 0 ? SWITCH_ALL : allowedSwitches; + } +} diff --git a/app/src/main/java/com/valterc/ki2/external/ExternalActionDescriptor.java b/app/src/main/java/com/valterc/ki2/external/ExternalActionDescriptor.java new file mode 100644 index 0000000..4db1296 --- /dev/null +++ b/app/src/main/java/com/valterc/ki2/external/ExternalActionDescriptor.java @@ -0,0 +1,38 @@ +package com.valterc.ki2.external; + +import android.content.ComponentName; + +import androidx.annotation.NonNull; + +public class ExternalActionDescriptor { + + @NonNull + private final ComponentName providerComponent; + @NonNull + private final String appLabel; + @NonNull + private final ExternalAction action; + + public ExternalActionDescriptor(@NonNull ComponentName providerComponent, + @NonNull String appLabel, + @NonNull ExternalAction action) { + this.providerComponent = providerComponent; + this.appLabel = appLabel; + this.action = action; + } + + @NonNull + public ComponentName getProviderComponent() { + return providerComponent; + } + + @NonNull + public String getAppLabel() { + return appLabel; + } + + @NonNull + public ExternalAction getAction() { + return action; + } +} diff --git a/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java b/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java new file mode 100644 index 0000000..51f0485 --- /dev/null +++ b/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java @@ -0,0 +1,424 @@ +package com.valterc.ki2.external; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import timber.log.Timber; + +public class ExternalActionManager { + + public static final String ACTION_PROVIDER = "com.valterc.ki2.ACTION_PROVIDER"; + public static final String PREFERENCE_PREFIX = "external:"; + + public static final String ACTION_LIST_ACTIONS = "com.valterc.ki2.action.LIST_ACTIONS"; + public static final String ACTION_ACTIONS_RESULT = "com.valterc.ki2.action.ACTIONS_RESULT"; + public static final String ACTION_ACTIONS_CHANGED = "com.valterc.ki2.action.ACTIONS_CHANGED"; + public static final String ACTION_PERFORM = "com.valterc.ki2.action.PERFORM"; + + public static final String EXTRA_RESULT_ACTION = "com.valterc.ki2.extra.RESULT_ACTION"; + public static final String EXTRA_REQUEST_ID = "com.valterc.ki2.extra.REQUEST_ID"; + public static final String EXTRA_ACTIONS = "com.valterc.ki2.extra.ACTIONS"; + public static final String EXTRA_ACTION_ID = "com.valterc.ki2.extra.ACTION_ID"; + public static final String EXTRA_DEVICE_ID = "com.valterc.ki2.extra.DEVICE_ID"; + public static final String EXTRA_SWITCH_TYPE = "com.valterc.ki2.extra.SWITCH_TYPE"; + public static final String EXTRA_SWITCH_COMMAND = "com.valterc.ki2.extra.SWITCH_COMMAND"; + public static final String EXTRA_SWITCH_REPEAT = "com.valterc.ki2.extra.SWITCH_REPEAT"; + public static final String EXTRA_TIMESTAMP = "com.valterc.ki2.extra.TIMESTAMP"; + public static final String EXTRA_PROVIDER_PACKAGE = "com.valterc.ki2.extra.PROVIDER_PACKAGE"; + + private static final long LIST_ACTIONS_TIMEOUT_MS = 4000; + + private static ExternalActionManager instance; + + private final Context context; + private final PackageManager packageManager; + private final Map providers; + private final List cachedActions; + private final Set listeners; + private final Handler mainHandler; + private boolean started; + + private final BroadcastReceiver resultReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + switch (intent.getAction()) { + case ACTION_ACTIONS_RESULT: + handleActionsResult(intent); + break; + case ACTION_ACTIONS_CHANGED: + handleActionsChanged(intent); + break; + } + } + }; + + private ExternalActionManager(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.packageManager = this.context.getPackageManager(); + this.providers = new HashMap<>(); + this.cachedActions = new ArrayList<>(); + this.listeners = new HashSet<>(); + this.mainHandler = new Handler(Looper.getMainLooper()); + } + + public static synchronized ExternalActionManager getInstance(@NonNull Context context) { + if (instance == null) { + instance = new ExternalActionManager(context); + } + return instance; + } + + public void start() { + if (!started) { + started = true; + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_ACTIONS_RESULT); + filter.addAction(ACTION_ACTIONS_CHANGED); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(resultReceiver, filter, Context.RECEIVER_EXPORTED); + } else { + context.registerReceiver(resultReceiver, filter); + } + } + refreshProviders(); + } + + public void shutdown() { + if (started) { + started = false; + try { + context.unregisterReceiver(resultReceiver); + } catch (Exception e) { + // ignore + } + } + synchronized (providers) { + providers.clear(); + } + synchronized (cachedActions) { + cachedActions.clear(); + } + } + + public void refreshProviders() { + List discovered = discoverProviders(); + Set discoveredSet = new HashSet<>(discovered); + + List newProviders = new ArrayList<>(); + synchronized (providers) { + providers.entrySet().removeIf(entry -> !discoveredSet.contains(entry.getKey())); + + for (ComponentName componentName : discovered) { + if (!providers.containsKey(componentName)) { + ProviderRecord record = new ProviderRecord(componentName); + providers.put(componentName, record); + newProviders.add(componentName); + } + } + } + + for (ComponentName componentName : newProviders) { + requestActions(componentName); + } + } + + public void addListener(@NonNull Listener listener) { + synchronized (listeners) { + listeners.add(listener); + } + } + + public void removeListener(@NonNull Listener listener) { + synchronized (listeners) { + listeners.remove(listener); + } + } + + @NonNull + public List getExternalActionsSnapshot() { + synchronized (cachedActions) { + return new ArrayList<>(cachedActions); + } + } + + public void performAction(@NonNull ExternalActionTarget target, + @NonNull String deviceId, + int switchType, + int switchCommand, + int switchRepeat, + long timestamp) { + synchronized (providers) { + if (!providers.containsKey(target.getProviderComponent())) { + Timber.w("External action provider missing: %s", target.getProviderComponent()); + return; + } + } + + Intent intent = new Intent(ACTION_PERFORM); + intent.setPackage(target.getProviderComponent().getPackageName()); + intent.putExtra(EXTRA_ACTION_ID, target.getActionId()); + intent.putExtra(EXTRA_DEVICE_ID, deviceId); + intent.putExtra(EXTRA_SWITCH_TYPE, switchType); + intent.putExtra(EXTRA_SWITCH_COMMAND, switchCommand); + intent.putExtra(EXTRA_SWITCH_REPEAT, switchRepeat); + intent.putExtra(EXTRA_TIMESTAMP, timestamp); + + context.sendBroadcast(intent); + } + + public static boolean isExternalPreferenceValue(@Nullable String value) { + return value != null && value.startsWith(PREFERENCE_PREFIX); + } + + @Nullable + public static ExternalActionTarget parsePreferenceValue(@Nullable String value) { + if (value == null || !value.startsWith(PREFERENCE_PREFIX)) { + return null; + } + + String payload = value.substring(PREFERENCE_PREFIX.length()); + int actionSeparator = payload.lastIndexOf('#'); + if (actionSeparator <= 0 || actionSeparator >= payload.length() - 1) { + return null; + } + + String componentString = payload.substring(0, actionSeparator); + String actionId = payload.substring(actionSeparator + 1); + ComponentName componentName = ComponentName.unflattenFromString(componentString); + if (componentName == null) { + return null; + } + + return new ExternalActionTarget(componentName, actionId); + } + + @NonNull + public static String toPreferenceValue(@NonNull ComponentName componentName, @NonNull String actionId) { + return PREFERENCE_PREFIX + componentName.flattenToString() + "#" + actionId; + } + + @NonNull + private List discoverProviders() { + Intent intent = new Intent(ACTION_PROVIDER); + List resolveInfos; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + resolveInfos = packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA)); + } else { + resolveInfos = packageManager.queryIntentServices(intent, PackageManager.GET_META_DATA); + } + + if (resolveInfos == null) { + return Collections.emptyList(); + } + + List components = new ArrayList<>(); + for (ResolveInfo info : resolveInfos) { + if (info.serviceInfo == null || info.serviceInfo.packageName == null || info.serviceInfo.name == null) { + continue; + } + components.add(new ComponentName(info.serviceInfo.packageName, info.serviceInfo.name)); + } + + return components; + } + + private void requestActions(@NonNull ComponentName componentName) { + String requestId = UUID.randomUUID().toString(); + + synchronized (providers) { + ProviderRecord record = providers.get(componentName); + if (record != null) { + record.pendingRequestId = requestId; + } + } + + Intent intent = new Intent(ACTION_LIST_ACTIONS); + intent.setPackage(componentName.getPackageName()); + intent.putExtra(EXTRA_RESULT_ACTION, ACTION_ACTIONS_RESULT); + intent.putExtra(EXTRA_REQUEST_ID, requestId); + + context.sendBroadcast(intent); + + mainHandler.postDelayed(() -> { + boolean timedOut = false; + synchronized (providers) { + ProviderRecord record = providers.get(componentName); + if (record != null && requestId.equals(record.pendingRequestId)) { + record.pendingRequestId = null; + timedOut = true; + } + } + if (timedOut) { + Timber.w("LIST_ACTIONS timed out for %s (request %s)", componentName, requestId); + } + }, LIST_ACTIONS_TIMEOUT_MS); + } + + private void handleActionsResult(@NonNull Intent intent) { + String requestId = intent.getStringExtra(EXTRA_REQUEST_ID); + String actionsJson = intent.getStringExtra(EXTRA_ACTIONS); + + if (requestId == null || actionsJson == null) { + return; + } + + ComponentName matchedComponent = null; + synchronized (providers) { + for (Map.Entry entry : providers.entrySet()) { + if (requestId.equals(entry.getValue().pendingRequestId)) { + matchedComponent = entry.getKey(); + entry.getValue().pendingRequestId = null; + break; + } + } + } + + if (matchedComponent == null) { + Timber.w("No provider matched request ID: %s", requestId); + return; + } + + List actions = parseActionsJson(actionsJson); + + synchronized (providers) { + ProviderRecord record = providers.get(matchedComponent); + if (record != null) { + record.actions.clear(); + record.actions.addAll(actions); + } + } + + updateCachedActions(); + } + + private void handleActionsChanged(@NonNull Intent intent) { + String providerPackage = intent.getStringExtra(EXTRA_PROVIDER_PACKAGE); + if (providerPackage == null) { + return; + } + + synchronized (providers) { + for (Map.Entry entry : providers.entrySet()) { + if (providerPackage.equals(entry.getKey().getPackageName())) { + requestActions(entry.getKey()); + return; + } + } + } + + Timber.w("ACTIONS_CHANGED from unknown provider: %s", providerPackage); + } + + @NonNull + private List parseActionsJson(@NonNull String json) { + List actions = new ArrayList<>(); + try { + JSONArray array = new JSONArray(json); + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + String actionId = obj.getString("action_id"); + String label = obj.getString("label"); + String iconUri = obj.optString("icon_uri", null); + int allowedSwitches = obj.optInt("allowed_switches", ExternalAction.SWITCH_ALL); + actions.add(new ExternalAction(actionId, label, iconUri, allowedSwitches)); + } + } catch (JSONException e) { + Timber.w(e, "Failed to parse external actions JSON"); + } + return actions; + } + + private void updateCachedActions() { + List updated = new ArrayList<>(); + synchronized (providers) { + for (ProviderRecord record : providers.values()) { + if (record.actions.isEmpty()) { + continue; + } + for (ExternalAction action : record.actions) { + updated.add(new ExternalActionDescriptor(record.componentName, record.appLabel, action)); + } + } + } + + synchronized (cachedActions) { + cachedActions.clear(); + cachedActions.addAll(updated); + } + notifyListeners(); + } + + private void notifyListeners() { + List snapshot; + synchronized (listeners) { + snapshot = new ArrayList<>(listeners); + } + if (snapshot.isEmpty()) { + return; + } + mainHandler.post(() -> { + for (Listener listener : snapshot) { + try { + listener.onActionsUpdated(); + } catch (Exception e) { + Timber.w(e, "External action listener failed"); + } + } + }); + } + + private class ProviderRecord { + final ComponentName componentName; + final String appLabel; + final List actions; + String pendingRequestId; + + ProviderRecord(@NonNull ComponentName componentName) { + this.componentName = componentName; + this.appLabel = resolveAppLabel(componentName); + this.actions = new ArrayList<>(); + } + } + + @NonNull + private String resolveAppLabel(@NonNull ComponentName componentName) { + try { + return packageManager.getApplicationLabel( + packageManager.getApplicationInfo(componentName.getPackageName(), 0) + ).toString(); + } catch (Exception e) { + return componentName.getPackageName(); + } + } + + public interface Listener { + void onActionsUpdated(); + } +} diff --git a/app/src/main/java/com/valterc/ki2/external/ExternalActionTarget.java b/app/src/main/java/com/valterc/ki2/external/ExternalActionTarget.java new file mode 100644 index 0000000..6f1c22b --- /dev/null +++ b/app/src/main/java/com/valterc/ki2/external/ExternalActionTarget.java @@ -0,0 +1,28 @@ +package com.valterc.ki2.external; + +import android.content.ComponentName; + +import androidx.annotation.NonNull; + +public class ExternalActionTarget { + + @NonNull + private final ComponentName providerComponent; + @NonNull + private final String actionId; + + public ExternalActionTarget(@NonNull ComponentName providerComponent, @NonNull String actionId) { + this.providerComponent = providerComponent; + this.actionId = actionId; + } + + @NonNull + public ComponentName getProviderComponent() { + return providerComponent; + } + + @NonNull + public String getActionId() { + return actionId; + } +} diff --git a/app/src/main/java/com/valterc/ki2/input/InputManager.java b/app/src/main/java/com/valterc/ki2/input/InputManager.java index 95f7c50..50765ef 100644 --- a/app/src/main/java/com/valterc/ki2/input/InputManager.java +++ b/app/src/main/java/com/valterc/ki2/input/InputManager.java @@ -8,12 +8,15 @@ import androidx.preference.PreferenceManager; import com.valterc.ki2.R; -import com.valterc.ki2.data.action.KarooActionEvent; -import com.valterc.ki2.data.switches.SwitchCommand; -import com.valterc.ki2.data.switches.SwitchCommandType; -import com.valterc.ki2.data.switches.SwitchEvent; -import com.valterc.ki2.data.switches.SwitchType; -import com.valterc.ki2.data.action.KarooAction; +import com.valterc.ki2.data.action.KarooActionEvent; +import com.valterc.ki2.data.action.Ki2ActionEvent; +import com.valterc.ki2.data.switches.SwitchCommand; +import com.valterc.ki2.data.switches.SwitchCommandType; +import com.valterc.ki2.data.switches.SwitchEvent; +import com.valterc.ki2.data.switches.SwitchType; +import com.valterc.ki2.data.action.KarooAction; +import com.valterc.ki2.external.ExternalActionManager; +import com.valterc.ki2.external.ExternalActionTarget; import java.util.HashMap; import java.util.Map; @@ -292,34 +295,73 @@ public InputManager(Context context) { new Pair<>(context.getString(R.string.preference_switch_ch4_hold), context.getString(R.string.default_preference_switch))); } - @Nullable - private KarooActionEvent getKarooActionEvent(SwitchEvent switchEvent) { - Pair preferencePair = preferenceMap.get(new Pair<>(switchEvent.getType(), switchEvent.getCommand().getCommandType())); - if (preferencePair == null) { - return null; - } + @Nullable + private KarooActionEvent getKarooActionEvent(SwitchEvent switchEvent) { + Pair preferencePair = preferenceMap.get(new Pair<>(switchEvent.getType(), switchEvent.getCommand().getCommandType())); + if (preferencePair == null) { + return null; + } String preference = preferences.getString(preferencePair.first, preferencePair.second); if (preference == null) { return null; } - BiFunction, KarooActionEvent> keyFunction = preferenceToSwitchKeyMap.get(preference); - if (keyFunction == null) { - Timber.w("Invalid karoo command from combination, switch: %s, command type: %s", switchEvent.getType(), switchEvent.getCommand().getCommandType()); - return null; - } - - return keyFunction.apply(switchEvent, this::getKarooActionEvent); - } - - @Nullable - public KarooActionEvent onSwitch(SwitchEvent switchEvent) { - if (switchEvent == null) { - return null; - } - - return getKarooActionEvent(switchEvent); - } - -} + BiFunction, KarooActionEvent> keyFunction = preferenceToSwitchKeyMap.get(preference); + if (keyFunction == null) { + Timber.w("Invalid karoo command from combination, switch: %s, command type: %s", switchEvent.getType(), switchEvent.getCommand().getCommandType()); + return null; + } + + return keyFunction.apply(switchEvent, this::getKarooActionEvent); + } + + @Nullable + public Ki2ActionEvent onSwitch(SwitchEvent switchEvent) { + if (switchEvent == null) { + return null; + } + + Pair preferencePair = preferenceMap.get(new Pair<>(switchEvent.getType(), switchEvent.getCommand().getCommandType())); + if (preferencePair == null) { + return null; + } + + String preference = preferences.getString(preferencePair.first, preferencePair.second); + if (preference == null) { + return null; + } + + if ("double_press_duplicate_single_press".equals(preference)) { + SwitchEvent singlePressEvent = new SwitchEvent(switchEvent.getType(), SwitchCommand.SINGLE_CLICK, switchEvent.getRepeat()); + Ki2ActionEvent baseEvent = onSwitch(singlePressEvent); + if (baseEvent == null) { + return null; + } + return baseEvent.withReplicate(2); + } + + if ("repeat_single_press".equals(preference)) { + if (switchEvent.getCommand() == SwitchCommand.LONG_PRESS_UP) { + return null; + } + SwitchEvent singlePressEvent = new SwitchEvent(switchEvent.getType(), SwitchCommand.SINGLE_CLICK, switchEvent.getRepeat()); + return onSwitch(singlePressEvent); + } + + if (ExternalActionManager.isExternalPreferenceValue(preference)) { + ExternalActionTarget target = ExternalActionManager.parsePreferenceValue(preference); + if (target == null) { + return null; + } + return Ki2ActionEvent.forExternal(target); + } + + KarooActionEvent karooActionEvent = getKarooActionEvent(switchEvent); + if (karooActionEvent == null) { + return null; + } + return Ki2ActionEvent.forKaroo(karooActionEvent); + } + +} diff --git a/app/src/main/java/com/valterc/ki2/services/Ki2Service.java b/app/src/main/java/com/valterc/ki2/services/Ki2Service.java index 890ae19..702159c 100644 --- a/app/src/main/java/com/valterc/ki2/services/Ki2Service.java +++ b/app/src/main/java/com/valterc/ki2/services/Ki2Service.java @@ -23,6 +23,7 @@ import com.valterc.ki2.ant.scanner.AntScanner; import com.valterc.ki2.ant.scanner.IAntScanListener; import com.valterc.ki2.data.action.KarooActionEvent; +import com.valterc.ki2.data.action.Ki2ActionEvent; import com.valterc.ki2.data.command.CommandType; import com.valterc.ki2.data.configuration.ConfigurationStore; import com.valterc.ki2.data.connection.ConnectionDataManager; @@ -46,6 +47,8 @@ import com.valterc.ki2.data.shifting.ShiftingInfo; import com.valterc.ki2.data.switches.SwitchEvent; import com.valterc.ki2.data.update.ReleaseInfo; +import com.valterc.ki2.external.ExternalActionManager; +import com.valterc.ki2.external.ExternalActionTarget; import com.valterc.ki2.input.InputManager; import com.valterc.ki2.services.callbacks.IActionCallback; import com.valterc.ki2.services.callbacks.IBatteryCallback; @@ -507,6 +510,15 @@ public void onReceive(final Context context, final Intent intent) { } }; + private final BroadcastReceiver receiverPackageUpdates = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (externalActionManager != null) { + externalActionManager.refreshProviders(); + } + } + }; + private MessageManager messageManager; private AntManager antManager; private AntScanner antScanner; @@ -516,6 +528,7 @@ public void onReceive(final Context context, final Intent intent) { private ConnectionsDataManager connectionsDataManager; private InputManager inputManager; private BackgroundUpdateChecker backgroundUpdateChecker; + private ExternalActionManager externalActionManager; private PreferencesStore preferencesStore; private DevicePreferencesStore devicePreferencesStore; @@ -536,6 +549,8 @@ public void onCreate() { deviceStore = new DeviceStore(this); connectionsDataManager = new ConnectionsDataManager(); inputManager = new InputManager(this); + externalActionManager = ExternalActionManager.getInstance(this); + externalActionManager.start(); backgroundUpdateChecker = new BackgroundUpdateChecker(this, this); preferencesStore = new PreferencesStore(this, this::onPreferences); devicePreferencesStore = new DevicePreferencesStore(this, this::onDevicePreferences); @@ -546,6 +561,12 @@ public void onCreate() { registerReceiver(receiverReconnectDevices, new IntentFilter("io.hammerhead.action.RECONNECT_DEVICES"), Context.RECEIVER_EXPORTED); registerReceiver(receiverInRide, new IntentFilter("io.hammerhead.action.IN_RIDE"), Context.RECEIVER_EXPORTED); + IntentFilter packageFilter = new IntentFilter(); + packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); + packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); + packageFilter.addAction(Intent.ACTION_PACKAGE_CHANGED); + packageFilter.addDataScheme("package"); + registerReceiver(receiverPackageUpdates, packageFilter, Context.RECEIVER_EXPORTED); Timber.i("Service created"); } @@ -572,6 +593,10 @@ public void onDestroy() { unregisterReceiver(receiverReconnectDevices); unregisterReceiver(receiverInRide); + unregisterReceiver(receiverPackageUpdates); + if (externalActionManager != null) { + externalActionManager.shutdown(); + } super.onDestroy(); } @@ -693,11 +718,31 @@ public void onData(DeviceId deviceId, DataType dataType, Parcelable data) { (callback, se) -> callback.onSwitchEvent(deviceId, se)); } - KarooActionEvent actionEvent = inputManager.onSwitch(switchEvent); + Ki2ActionEvent actionEvent = inputManager.onSwitch(switchEvent); if (actionEvent != null) { - broadcastData(callbackListAction, - () -> actionEvent, - (callback, ke) -> callback.onActionEvent(deviceId, ke)); + if (actionEvent.getType() == Ki2ActionEvent.Type.KAROO) { + KarooActionEvent karooActionEvent = actionEvent.getKarooActionEvent(); + if (karooActionEvent != null) { + broadcastData(callbackListAction, + () -> karooActionEvent, + (callback, ke) -> callback.onActionEvent(deviceId, ke)); + } + } else if (actionEvent.getType() == Ki2ActionEvent.Type.EXTERNAL) { + ExternalActionTarget target = actionEvent.getExternalActionTarget(); + if (target != null && externalActionManager != null) { + String deviceIdStr = deviceId.toString(); + int switchType = switchEvent.getType().getValue(); + int switchCommand = switchEvent.getCommand().getCommandNumber(); + int switchRepeat = switchEvent.getRepeat(); + long timestamp = System.currentTimeMillis(); + int replicate = Math.max(1, actionEvent.getReplicate()); + for (int i = 0; i < replicate; i++) { + externalActionManager.performAction(target, + deviceIdStr, switchType, switchCommand, + switchRepeat, timestamp); + } + } + } } break; diff --git a/app/src/main/java/com/valterc/ki2/views/preference/SwitchListPreference.java b/app/src/main/java/com/valterc/ki2/views/preference/SwitchListPreference.java index afd32e8..70451f1 100644 --- a/app/src/main/java/com/valterc/ki2/views/preference/SwitchListPreference.java +++ b/app/src/main/java/com/valterc/ki2/views/preference/SwitchListPreference.java @@ -11,9 +11,15 @@ import androidx.preference.ListPreference; import com.valterc.ki2.data.message.AudioAlertMessage; +import com.valterc.ki2.external.ExternalAction; +import com.valterc.ki2.external.ExternalActionDescriptor; +import com.valterc.ki2.external.ExternalActionManager; import com.valterc.ki2.services.IKi2Service; import com.valterc.ki2.services.Ki2Service; +import java.util.ArrayList; +import java.util.List; + import timber.log.Timber; @SuppressWarnings("unused") @@ -33,6 +39,10 @@ public void onServiceDisconnected(ComponentName name) { private IKi2Service service; private boolean serviceBound; + private CharSequence[] baseEntries; + private CharSequence[] baseEntryValues; + private ExternalActionManager externalActionManager; + private final ExternalActionManager.Listener actionsListener = this::rebuildEntries; public SwitchListPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); @@ -87,6 +97,9 @@ public void setValue(String value) { public void onAttached() { super.onAttached(); serviceBound = getContext().bindService(Ki2Service.getIntent(), serviceConnection, Context.BIND_AUTO_CREATE); + externalActionManager = ExternalActionManager.getInstance(getContext()); + externalActionManager.addListener(actionsListener); + rebuildEntries(); } @Override @@ -101,5 +114,73 @@ public void onDetached() { // ignore } } + + if (externalActionManager != null) { + externalActionManager.removeListener(actionsListener); + } + } + + private void rebuildEntries() { + if (baseEntries == null) { + baseEntries = getEntries(); + baseEntryValues = getEntryValues(); + } + + if (baseEntries == null || baseEntryValues == null) { + return; + } + + if (externalActionManager == null) { + externalActionManager = ExternalActionManager.getInstance(getContext()); + } + + int switchMask = getSwitchMaskForPreferenceKey(getKey()); + List externalActions = externalActionManager.getExternalActionsSnapshot(); + + List entries = new ArrayList<>(); + List values = new ArrayList<>(); + + for (int i = 0; i < baseEntries.length; i++) { + entries.add(baseEntries[i]); + values.add(baseEntryValues[i]); + } + + for (ExternalActionDescriptor descriptor : externalActions) { + ExternalAction action = descriptor.getAction(); + if (!isAllowedForSwitch(action, switchMask)) { + continue; + } + entries.add(action.getLabel() + " (" + descriptor.getAppLabel() + ")"); + values.add(ExternalActionManager.toPreferenceValue(descriptor.getProviderComponent(), action.getActionId())); + } + + setEntries(entries.toArray(new CharSequence[0])); + setEntryValues(values.toArray(new CharSequence[0])); + } + + private boolean isAllowedForSwitch(@NonNull ExternalAction action, int switchMask) { + if (switchMask == 0) { + return true; + } + return (action.getAllowedSwitches() & switchMask) != 0; + } + + private int getSwitchMaskForPreferenceKey(@Nullable String key) { + if (key == null) { + return 0; + } + if (key.contains("CH1")) { + return ExternalAction.SWITCH_CH1; + } + if (key.contains("CH2")) { + return ExternalAction.SWITCH_CH2; + } + if (key.contains("CH3")) { + return ExternalAction.SWITCH_CH3; + } + if (key.contains("CH4")) { + return ExternalAction.SWITCH_CH4; + } + return 0; } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9418221..628624c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ Dismiss Battery of %1$s at %2$d%% Di2 low battery + From %s Update complete Update complete, please restart Karoo Ki2 updated diff --git a/sample-provider/build.gradle b/sample-provider/build.gradle new file mode 100644 index 0000000..4089327 --- /dev/null +++ b/sample-provider/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.valterc.ki2.sampleprovider' + compileSdk 34 + + defaultConfig { + applicationId 'com.valterc.ki2.sampleprovider' + minSdk 26 + targetSdk 34 + versionCode 1 + versionName '1.0' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} diff --git a/sample-provider/src/main/AndroidManifest.xml b/sample-provider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3704c3a --- /dev/null +++ b/sample-provider/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderReceiver.java b/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderReceiver.java new file mode 100644 index 0000000..fba5528 --- /dev/null +++ b/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderReceiver.java @@ -0,0 +1,111 @@ +package com.valterc.ki2.sampleprovider; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class SampleActionProviderReceiver extends BroadcastReceiver { + + private static final String TAG = "SampleActionProvider"; + + private static final String ACTION_LIST_ACTIONS = "com.valterc.ki2.action.LIST_ACTIONS"; + private static final String ACTION_ACTIONS_CHANGED = "com.valterc.ki2.action.ACTIONS_CHANGED"; + private static final String ACTION_PERFORM = "com.valterc.ki2.action.PERFORM"; + + private static final String EXTRA_RESULT_ACTION = "com.valterc.ki2.extra.RESULT_ACTION"; + private static final String EXTRA_REQUEST_ID = "com.valterc.ki2.extra.REQUEST_ID"; + private static final String EXTRA_ACTIONS = "com.valterc.ki2.extra.ACTIONS"; + private static final String EXTRA_PROVIDER_PACKAGE = "com.valterc.ki2.extra.PROVIDER_PACKAGE"; + private static final String EXTRA_ACTION_ID = "com.valterc.ki2.extra.ACTION_ID"; + private static final String EXTRA_DEVICE_ID = "com.valterc.ki2.extra.DEVICE_ID"; + private static final String EXTRA_SWITCH_TYPE = "com.valterc.ki2.extra.SWITCH_TYPE"; + private static final String EXTRA_SWITCH_COMMAND = "com.valterc.ki2.extra.SWITCH_COMMAND"; + private static final String EXTRA_SWITCH_REPEAT = "com.valterc.ki2.extra.SWITCH_REPEAT"; + private static final String EXTRA_TIMESTAMP = "com.valterc.ki2.extra.TIMESTAMP"; + + private static final int SWITCH_CH1 = 1; + private static final int SWITCH_ALL = 15; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + + switch (intent.getAction()) { + case ACTION_LIST_ACTIONS: + handleListActions(context, intent); + break; + case ACTION_PERFORM: + handlePerformAction(intent); + break; + } + } + + private void handleListActions(Context context, Intent intent) { + String resultAction = intent.getStringExtra(EXTRA_RESULT_ACTION); + String requestId = intent.getStringExtra(EXTRA_REQUEST_ID); + if (resultAction == null || requestId == null) { + return; + } + + try { + JSONArray actions = new JSONArray(); + + JSONObject toggle = new JSONObject(); + toggle.put("action_id", "sample_toggle"); + toggle.put("label", "Sample Toggle"); + toggle.put("icon_uri", JSONObject.NULL); + toggle.put("allowed_switches", SWITCH_ALL); + actions.put(toggle); + + JSONObject ch1Only = new JSONObject(); + ch1Only.put("action_id", "ch1_only"); + ch1Only.put("label", "CH1 Only Action"); + ch1Only.put("icon_uri", JSONObject.NULL); + ch1Only.put("allowed_switches", SWITCH_CH1); + actions.put(ch1Only); + + Intent response = new Intent(resultAction); + response.setPackage("com.valterc.ki2"); + response.putExtra(EXTRA_REQUEST_ID, requestId); + response.putExtra(EXTRA_ACTIONS, actions.toString()); + context.sendBroadcast(response); + } catch (JSONException e) { + Log.e(TAG, "Failed to build actions JSON", e); + } + } + + private void handlePerformAction(Intent intent) { + String actionId = intent.getStringExtra(EXTRA_ACTION_ID); + String deviceId = intent.getStringExtra(EXTRA_DEVICE_ID); + int switchType = intent.getIntExtra(EXTRA_SWITCH_TYPE, 0); + int switchCommand = intent.getIntExtra(EXTRA_SWITCH_COMMAND, 0); + int switchRepeat = intent.getIntExtra(EXTRA_SWITCH_REPEAT, 0); + long timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0); + + Log.i(TAG, "performAction: " + actionId + + " device=" + deviceId + + " switchType=" + switchType + + " switchCommand=" + switchCommand + + " switchRepeat=" + switchRepeat + + " timestamp=" + timestamp); + } + + /** + * Notify Ki2 that this provider's available actions have changed. + * Call this when actions are added, removed, or modified (e.g. after a + * configuration change) so Ki2 re-queries the action list. + */ + public static void notifyActionsChanged(Context context) { + Intent intent = new Intent(ACTION_ACTIONS_CHANGED); + intent.setPackage("com.valterc.ki2"); + intent.putExtra(EXTRA_PROVIDER_PACKAGE, context.getPackageName()); + context.sendBroadcast(intent); + } +} diff --git a/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java b/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java new file mode 100644 index 0000000..02e5270 --- /dev/null +++ b/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java @@ -0,0 +1,17 @@ +package com.valterc.ki2.sampleprovider; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Minimal service used only for Ki2 discovery via queryIntentServices(). + * All action handling is done by {@link SampleActionProviderReceiver}. + */ +public class SampleActionProviderService extends Service { + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} From 4fcc8a4e27c0315b6e32773fa0780ed81715d4eb Mon Sep 17 00:00:00 2001 From: Conor Noonan Date: Wed, 18 Feb 2026 14:41:51 +0800 Subject: [PATCH 2/2] Remove stub service, use receiver for provider discovery Use queryBroadcastReceivers() instead of queryIntentServices() so the ACTION_PROVIDER intent-filter lives on the receiver directly. This eliminates the need for a separate stub service class in providers. Co-Authored-By: Claude Opus 4.6 --- .../ki2/external/ExternalActionManager.java | 8 ++++---- sample-provider/src/main/AndroidManifest.xml | 13 +++---------- .../SampleActionProviderService.java | 17 ----------------- 3 files changed, 7 insertions(+), 31 deletions(-) delete mode 100644 sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java diff --git a/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java b/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java index 51f0485..5cb56e8 100644 --- a/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java +++ b/app/src/main/java/com/valterc/ki2/external/ExternalActionManager.java @@ -229,9 +229,9 @@ private List discoverProviders() { Intent intent = new Intent(ACTION_PROVIDER); List resolveInfos; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - resolveInfos = packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA)); + resolveInfos = packageManager.queryBroadcastReceivers(intent, PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA)); } else { - resolveInfos = packageManager.queryIntentServices(intent, PackageManager.GET_META_DATA); + resolveInfos = packageManager.queryBroadcastReceivers(intent, PackageManager.GET_META_DATA); } if (resolveInfos == null) { @@ -240,10 +240,10 @@ private List discoverProviders() { List components = new ArrayList<>(); for (ResolveInfo info : resolveInfos) { - if (info.serviceInfo == null || info.serviceInfo.packageName == null || info.serviceInfo.name == null) { + if (info.activityInfo == null || info.activityInfo.packageName == null || info.activityInfo.name == null) { continue; } - components.add(new ComponentName(info.serviceInfo.packageName, info.serviceInfo.name)); + components.add(new ComponentName(info.activityInfo.packageName, info.activityInfo.name)); } return components; diff --git a/sample-provider/src/main/AndroidManifest.xml b/sample-provider/src/main/AndroidManifest.xml index 3704c3a..cf4651a 100644 --- a/sample-provider/src/main/AndroidManifest.xml +++ b/sample-provider/src/main/AndroidManifest.xml @@ -4,20 +4,13 @@ - - - - - - - - + + + diff --git a/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java b/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java deleted file mode 100644 index 02e5270..0000000 --- a/sample-provider/src/main/java/com/valterc/ki2/sampleprovider/SampleActionProviderService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.valterc.ki2.sampleprovider; - -import android.app.Service; -import android.content.Intent; -import android.os.IBinder; - -/** - * Minimal service used only for Ki2 discovery via queryIntentServices(). - * All action handling is done by {@link SampleActionProviderReceiver}. - */ -public class SampleActionProviderService extends Service { - - @Override - public IBinder onBind(Intent intent) { - return null; - } -}