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..5cb56e8 --- /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.queryBroadcastReceivers(intent, PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA)); + } else { + resolveInfos = packageManager.queryBroadcastReceivers(intent, PackageManager.GET_META_DATA); + } + + if (resolveInfos == null) { + return Collections.emptyList(); + } + + List components = new ArrayList<>(); + for (ResolveInfo info : resolveInfos) { + if (info.activityInfo == null || info.activityInfo.packageName == null || info.activityInfo.name == null) { + continue; + } + components.add(new ComponentName(info.activityInfo.packageName, info.activityInfo.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..cf4651a --- /dev/null +++ b/sample-provider/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + 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); + } +}