diff --git a/README.md b/README.md index 47fe7a99aa8a58045f15c40c585ead224231556a..8bc7658a2dcf5a943a4a15041fb40ae92cb3e620 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ This app is a native client for openHAB which allows easy access to your sitemap <a href="https://github.com/openhab/openhab-android/releases"><img src="assets/direct-apk-download.png" alt="Get it on GitHub" height="80"></a> ## Features -* Control your openHAB server and [openHAB Cloud instance](https://github.com/openhab/openhab-cloud) -* Receive notifications from openHAB Cloud +* Control your openHAB server and/or [openHAB Cloud instance](https://github.com/openhab/openhab-cloud), e.g., an account with [myopenHAB](http://www.myopenhab.org/) +* Receive notifications through an openHAB Cloud connection, [read more](https://www.openhab.org/docs/configuration/actions.html#cloud-notification-actions) * Change items via NFC tags * Send voice commands to openHAB -* Send alarm clock time to openHAB -* Supports wall mounted tablets +* [Send alarm clock time to openHAB](https://www.openhab.org/docs/apps/android.html#alarm-clock) +* [Supports wall mounted tablets](https://www.openhab.org/docs/apps/android.html#permanent-deployment) +* [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) action plugin included <img src="docs/images/main-menu.png" alt="Demo Overview" width=200px> <img src="docs/images/widget-overview.png" alt="Widget Overview" width=200px> <img src="docs/images/maps.png" alt="Google Maps Widget" width=200px> diff --git a/assets/store_descriptions/en-US/strings.xml b/assets/store_descriptions/en-US/strings.xml index 06429f9df50ed5fd2f5cb3f13d7389d3679c3e1d..4b2ce3052a8f238d6b7be74c36dc5c9de3fb7a34 100644 --- a/assets/store_descriptions/en-US/strings.xml +++ b/assets/store_descriptions/en-US/strings.xml @@ -13,6 +13,7 @@ <security>• Security: ZoneMinder, DSC, ...</security> <open_protocols>• Open Protocols: HTTP, TCP/UDP, MQTT, Serial, ...</open_protocols> <special_useCases>• Special UseCases: Minecraft, Tesla Car, Weather Services, ...</special_useCases> + <automation_apps>• Automation apps: Includes plugins for Tasker and Locale</automation_apps> <oss_community>Open Source Community</oss_community> <forum>The openHAB open source initiative strongly supports its vibrant community. The forum with over 13,000 registered users is a place to find guidance, help and inspiration. Join the openHAB community forum over at https://community.openhab.org</forum> diff --git a/assets/store_descriptions/generate_and_validate.py b/assets/store_descriptions/generate_and_validate.py index 03939cd4023ff57d39289ff2f1d0391db4237213..38444be2074775436f53249d95aab1dc4735a92c 100755 --- a/assets/store_descriptions/generate_and_validate.py +++ b/assets/store_descriptions/generate_and_validate.py @@ -21,7 +21,7 @@ def getString(key): string = root.findall(key)[0].text if emptyStringPattern.match(string): string = getEnglishString(key) - except TypeError: + except (TypeError, IndexError): string = getEnglishString(key) return(string) @@ -57,6 +57,7 @@ for file in appStoreStringsFiles: fullDescription += getString('home_entertainment') + "\n" fullDescription += getString('security') + "\n" fullDescription += getString('open_protocols') + "\n" + fullDescription += getString('automation_apps') + "\n" fullDescription += getString('special_useCases') + "\n\n" fullDescription += "<b>" + getString('oss_community') + "</b>\n\n" fullDescription += getString('forum') + "\n" diff --git a/docs/USAGE.md b/docs/USAGE.md index 058b8cc4c1eb4ee7b0b51016f169f2f916370f3a..47c3acb4f43300f99cb8136ea54e53d3619966d9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -28,6 +28,7 @@ The app follows the basic principles of the other openHAB UIs, like Basic UI, an * Send voice commands to openHAB * [Send alarm clock time to openHAB](#alarm-clock) * [Supports wall mounted tablets](#permanent-deployment) +* [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) action plugin included <div class="row"> <img src="images/main-menu.png" alt="Demo Overview" width=200px> <img src="images/widget-overview.png" alt="Widget Overview" width=200px> <img src="images/maps.png" alt="Google Maps Widget" width=200px> diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index ea81ce73c152d38c8339391f1d14be05cfd1b63a..fc2072f0e49beaca53c6df5eae2d85cf24cb1c5e 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -77,6 +77,13 @@ android:name="de.duenndns.ssl.MemorizingActivity" android:excludeFromRecents="true" android:theme="@style/Theme.AppCompat.Translucent" /> + <activity android:name=".ui.ItemPickerActivity" + android:label="@string/item_picker" > + <intent-filter> + <action android:name="com.twofortyfouram.locale.intent.action.EDIT_CONDITION" /> + <action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" /> + </intent-filter> + </activity> <service android:name=".core.VoiceService" @@ -119,6 +126,10 @@ <intent-filter> <action android:name="android.intent.action.LOCALE_CHANGED" /> </intent-filter> + <intent-filter> + <action android:name="com.twofortyfouram.locale.intent.action.QUERY_CONDITION" /> + <action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" /> + </intent-filter> </receiver> <activity diff --git a/mobile/src/main/java/org/openhab/habdroid/background/BackgroundTasksManager.java b/mobile/src/main/java/org/openhab/habdroid/background/BackgroundTasksManager.java index 9113279df6abb096714bf1b07747e92177bcb0da..b892224dd154756c7c7a949c623a7e8e14865834 100644 --- a/mobile/src/main/java/org/openhab/habdroid/background/BackgroundTasksManager.java +++ b/mobile/src/main/java/org/openhab/habdroid/background/BackgroundTasksManager.java @@ -6,6 +6,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Build; +import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.preference.PreferenceManager; @@ -22,8 +23,10 @@ import androidx.work.WorkManager; import org.openhab.habdroid.R; import org.openhab.habdroid.model.NfcTag; +import org.openhab.habdroid.ui.ItemPickerActivity; import org.openhab.habdroid.ui.widget.ItemUpdatingPreference; import org.openhab.habdroid.util.Constants; +import org.openhab.habdroid.util.TaskerIntent; import org.openhab.habdroid.util.Util; import java.util.Arrays; @@ -39,6 +42,7 @@ public class BackgroundTasksManager extends BroadcastReceiver { private static final String WORKER_TAG_ITEM_UPLOADS = "itemUploads"; static final String WORKER_TAG_PREFIX_NFC = "nfc-"; + private static final String WORKER_TAG_PREFIX_TASKER = "tasker-"; static final List<String> KNOWN_KEYS = Arrays.asList( Constants.PREFERENCE_ALARM_CLOCK @@ -66,19 +70,37 @@ public class BackgroundTasksManager extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); Log.d(TAG, "onReceive() with intent " + intent.getAction()); - if (AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED.equals(intent.getAction())) { + if (AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED.equals(action)) { Log.d(TAG, "Alarm clock changed"); scheduleWorker(context, Constants.PREFERENCE_ALARM_CLOCK); - } else if (Intent.ACTION_LOCALE_CHANGED.equals(intent.getAction())) { + } else if (Intent.ACTION_LOCALE_CHANGED.equals(action)) { Log.d(TAG, "Locale changed, recreate notification channels"); NotificationUpdateObserver.createNotificationChannels(context); - } else if (ACTION_RETRY_UPLOAD.equals(intent.getAction())) { + } else if (ACTION_RETRY_UPLOAD.equals(action)) { List<RetryInfo> retryInfos = intent.getParcelableArrayListExtra(EXTRA_RETRY_INFOS); for (RetryInfo info : retryInfos) { enqueueItemUpload(info.mTag, info.mItemName, info.mValue); } + } else if (TaskerIntent.ACTION_QUERY_CONDITION.equals(action) + || TaskerIntent.ACTION_FIRE_SETTING.equals(action)) { + if (!PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.PREFERENCE_TASKER_PLUGIN_ENABLED, false)) { + Log.d(TAG, "Tasker plugin is disabled"); + return; + } + Bundle bundle = intent.getBundleExtra(TaskerIntent.EXTRA_BUNDLE); + if (bundle == null) { + return; + } + String itemName = bundle.getString(ItemPickerActivity.EXTRA_ITEM_NAME); + String state = bundle.getString(ItemPickerActivity.EXTRA_ITEM_STATE); + if (TextUtils.isEmpty(itemName) || TextUtils.isEmpty(state)) { + return; + } + enqueueItemUpload(WORKER_TAG_PREFIX_TASKER + itemName, itemName, state); } } diff --git a/mobile/src/main/java/org/openhab/habdroid/core/connection/ConnectionFactory.java b/mobile/src/main/java/org/openhab/habdroid/core/connection/ConnectionFactory.java index e8af6577262d113f9e7d262b08e6ef649663459e..2c3634a5e0f1a7d87b01dd100684122f274802bf 100644 --- a/mobile/src/main/java/org/openhab/habdroid/core/connection/ConnectionFactory.java +++ b/mobile/src/main/java/org/openhab/habdroid/core/connection/ConnectionFactory.java @@ -17,6 +17,7 @@ import android.security.KeyChain; import android.security.KeyChainException; import android.util.Log; import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import androidx.core.util.Pair; import de.duenndns.ssl.MemorizingTrustManager; @@ -159,6 +160,7 @@ public final class ConnectionFactory extends BroadcastReceiver implements * * It MUST NOT be called from the main thread. */ + @WorkerThread public static void waitForInitialization() { sInstance.triggerConnectionUpdateIfNeededAndPending(); synchronized (sInstance.mInitializationLock) { diff --git a/mobile/src/main/java/org/openhab/habdroid/model/Item.java b/mobile/src/main/java/org/openhab/habdroid/model/Item.java index e2908ac87eb2e961c9a730b08b0c4bca2b2db051..17291ca4ec33f990af1d97d0395e5cbea860ac69 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/Item.java +++ b/mobile/src/main/java/org/openhab/habdroid/model/Item.java @@ -47,6 +47,8 @@ public abstract class Item implements Parcelable { public abstract String name(); public abstract String label(); + @Nullable + public abstract String category(); public abstract Type type(); @Nullable public abstract Type groupType(); @@ -67,6 +69,7 @@ public abstract class Item implements Parcelable { abstract static class Builder { public abstract Builder name(String name); public abstract Builder label(String label); + public abstract Builder category(String category); public abstract Builder type(Type type); public abstract Builder groupType(Type type); public abstract Builder state(@Nullable ParsedState state); @@ -197,6 +200,7 @@ public abstract class Item implements Parcelable { .name(name) .label(jsonObject.optString("label", name)) .link(jsonObject.optString("link", null)) + .category(jsonObject.optString("category", null)) .members(members) .options(options) .state(ParsedState.from(state, numberPattern)) diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/AbstractBaseActivity.java b/mobile/src/main/java/org/openhab/habdroid/ui/AbstractBaseActivity.java index 3f9bb081fa2d7929ef553cb6afb0ad1c174fccfd..144a9268f53f6ecff8858555eba84c958d2bc0ab 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/AbstractBaseActivity.java +++ b/mobile/src/main/java/org/openhab/habdroid/ui/AbstractBaseActivity.java @@ -16,6 +16,7 @@ import org.openhab.habdroid.util.Util; public abstract class AbstractBaseActivity extends AppCompatActivity { private static final String TAG = AbstractBaseActivity.class.getSimpleName(); + private boolean mForceNonFullscreen = false; @Override @CallSuper @@ -41,6 +42,15 @@ public abstract class AbstractBaseActivity extends AppCompatActivity { checkFullscreen(); } + /** + * Activities, that aren't called from an app component directly, e.g. through a third-party app + * can use this function to avoid being shown in full screen. Must be called before + * {@link #onCreate(Bundle)} + */ + protected void forceNonFullscreen() { + mForceNonFullscreen = true; + } + protected void checkFullscreen() { checkFullscreen(isFullscreenEnabled()); } @@ -50,7 +60,7 @@ public abstract class AbstractBaseActivity extends AppCompatActivity { final int flags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN; - if (isEnabled) { + if (isEnabled && !mForceNonFullscreen) { uiOptions |= flags; } else { uiOptions &= ~flags; diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerActivity.java b/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..fe2635ba31a3cd8b4923d40f37a2738ac3212c0a --- /dev/null +++ b/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerActivity.java @@ -0,0 +1,322 @@ +package org.openhab.habdroid.ui; + +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.TextView; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.Toolbar; +import androidx.core.view.MenuItemCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import okhttp3.Call; +import okhttp3.Headers; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.openhab.habdroid.R; +import org.openhab.habdroid.core.connection.Connection; +import org.openhab.habdroid.core.connection.ConnectionFactory; +import org.openhab.habdroid.core.connection.exception.ConnectionException; +import org.openhab.habdroid.model.Item; +import org.openhab.habdroid.ui.widget.DividerItemDecoration; +import org.openhab.habdroid.util.AsyncHttpClient; +import org.openhab.habdroid.util.Constants; +import org.openhab.habdroid.util.SuggestedCommandsFactory; +import org.openhab.habdroid.util.TaskerIntent; +import org.openhab.habdroid.util.Util; + +import java.util.ArrayList; +import java.util.List; + +public class ItemPickerActivity extends AbstractBaseActivity + implements SwipeRefreshLayout.OnRefreshListener, View.OnClickListener, + ItemPickerAdapter.ItemClickListener, SearchView.OnQueryTextListener { + private static final String TAG = ItemPickerActivity.class.getSimpleName(); + + public static final String EXTRA_ITEM_NAME = "itemName"; + public static final String EXTRA_ITEM_STATE = "itemState"; + + private Call mRequestHandle; + private String mInitialHightlightItemName; + private ItemPickerAdapter mItemPickerAdapter; + private RecyclerView mRecyclerView; + private LinearLayoutManager mLayoutManager; + private SwipeRefreshLayout mSwipeLayout; + private View mEmptyView; + private TextView mEmptyMessage; + private TextView mRetryButton; + private SuggestedCommandsFactory mSuggestedCommandsFactory; + private boolean mIsDisabled; + private SharedPreferences mSharedPreferences; + + @Override + protected void onCreate(Bundle savedInstanceState) { + forceNonFullscreen(); + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_item_picker); + + Toolbar toolbar = findViewById(R.id.openhab_toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + mSwipeLayout = findViewById(R.id.swipe_container); + mSwipeLayout.setOnRefreshListener(this); + Util.applySwipeLayoutColors(mSwipeLayout, R.attr.colorPrimary, R.attr.colorAccent); + + mRecyclerView = findViewById(android.R.id.list); + mEmptyView = findViewById(android.R.id.empty); + mEmptyMessage = findViewById(R.id.empty_message); + mRetryButton = findViewById(R.id.retry_button); + mRetryButton.setOnClickListener(this); + + mItemPickerAdapter = new ItemPickerAdapter(this, this); + mLayoutManager = new LinearLayoutManager(this); + + mRecyclerView.setLayoutManager(mLayoutManager); + mRecyclerView.addItemDecoration(new DividerItemDecoration(this)); + mRecyclerView.setAdapter(mItemPickerAdapter); + + Bundle editItem = getIntent().getBundleExtra(TaskerIntent.EXTRA_BUNDLE); + if (editItem != null && !TextUtils.isEmpty(editItem.getString(EXTRA_ITEM_NAME))) { + mInitialHightlightItemName = editItem.getString(EXTRA_ITEM_NAME); + } + + mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); + + if (!mSharedPreferences.getBoolean(Constants.PREFERENCE_TASKER_PLUGIN_ENABLED, false)) { + mIsDisabled = true; + updateViewVisibility(false, false, true); + } + } + + @Override + public void onResume() { + super.onResume(); + Log.d(TAG, "onResume()"); + loadItems(); + } + + @Override + public void onPause() { + super.onPause(); + Log.d(TAG, "onPause()"); + // Cancel request for items if there was any + if (mRequestHandle != null) { + mRequestHandle.cancel(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.item_picker, menu); + + final MenuItem searchItem = menu.findItem(R.id.app_bar_search); + final SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchItem); + searchView.setOnQueryTextListener(this); + + return true; + } + + public boolean onOptionsItemSelected(MenuItem item) { + Log.d(TAG, "onOptionsItemSelected()"); + if (item.getItemId() == android.R.id.home) { + finish(false, null, null); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void loadItems() { + if (mIsDisabled) { + return; + } + Connection connection; + try { + connection = ConnectionFactory.getUsableConnection(); + } catch (ConnectionException e) { + connection = null; + } + if (connection == null) { + updateViewVisibility(false, true, false); + return; + } + + mItemPickerAdapter.clear(); + updateViewVisibility(true, false, false); + + final AsyncHttpClient client = connection.getAsyncHttpClient(); + mRequestHandle = client.get("rest/items", new AsyncHttpClient.StringResponseHandler() { + @Override + public void onSuccess(String responseBody, Headers headers) { + try { + ArrayList<Item> items = new ArrayList<>(); + JSONArray jsonArray = new JSONArray(responseBody); + for (int i = 0; i < jsonArray.length(); i++) { + JSONObject itemJson = jsonArray.getJSONObject(i); + Item item = Item.fromJson(itemJson); + if (!item.readOnly()) { + items.add(item); + } + } + Log.d(TAG, "Item request success, got " + items.size() + " items"); + mItemPickerAdapter.setItems(items); + handleInitialHighlight(); + updateViewVisibility(false, false, false); + } catch (JSONException e) { + Log.d(TAG, "Item response could not be parsed", e); + updateViewVisibility(false, true, false); + } + } + + @Override + public void onFailure(Request request, int statusCode, Throwable error) { + updateViewVisibility(false, true, false); + Log.e(TAG, "Item request failure", error); + } + }); + } + + @Override + public void onClick(View view) { + if (view == mRetryButton) { + if (mIsDisabled) { + mSharedPreferences + .edit() + .putBoolean(Constants.PREFERENCE_TASKER_PLUGIN_ENABLED, true) + .apply(); + mIsDisabled = false; + } + loadItems(); + } + } + + @Override + public void onItemClicked(Item item) { + if (item == null) { + return; + } + + if (mSuggestedCommandsFactory == null) { + mSuggestedCommandsFactory = new SuggestedCommandsFactory(this, true); + } + + SuggestedCommandsFactory.SuggestedCommands suggestedCommands = + mSuggestedCommandsFactory.fill(item); + + List<String> labels = suggestedCommands.labels; + List<String> commands = suggestedCommands.commands; + + if (suggestedCommands.shouldShowCustom) { + labels.add(getString(R.string.item_picker_custom)); + } + + final String[] labelArray = labels.toArray(new String[0]); + new AlertDialog.Builder(this) + .setTitle(R.string.item_picker_dialog_title) + .setItems(labelArray, (dialog, which) -> { + if (which == labelArray.length - 1 && suggestedCommands.shouldShowCustom) { + final EditText input = new EditText(this); + input.setInputType(suggestedCommands.inputTypeFlags); + AlertDialog customDialog = new AlertDialog.Builder(this) + .setTitle(getString(R.string.item_picker_custom)) + .setView(input) + .setPositiveButton(android.R.string.ok, (dialog1, which1) -> { + finish(true, item, input.getText().toString()); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + input.setOnFocusChangeListener((v, hasFocus) -> { + int mode = hasFocus + ? WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE + : WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN; + customDialog.getWindow().setSoftInputMode(mode); + }); + } else { + finish(true, item, commands.get(which)); + } + }) + .show(); + } + + private void finish(boolean success, Item item, String state) { + Intent intent = new Intent(); + + if (success) { + String blurb = getString(R.string.item_picker_blurb, item.label(), item.name(), state); + intent.putExtra(TaskerIntent.EXTRA_STRING_BLURB, blurb); + + Bundle bundle = new Bundle(); + bundle.putString(EXTRA_ITEM_NAME, item.name()); + bundle.putString(EXTRA_ITEM_STATE, state); + intent.putExtra(TaskerIntent.EXTRA_BUNDLE, bundle); + } + + int resultCode = success ? RESULT_OK : RESULT_CANCELED; + setResult(resultCode, intent); + finish(); + } + + @Override + public void onRefresh() { + loadItems(); + } + + private void handleInitialHighlight() { + if (TextUtils.isEmpty(mInitialHightlightItemName)) { + return; + } + + final int position = mItemPickerAdapter.findPositionForName(mInitialHightlightItemName); + if (position >= 0) { + mLayoutManager.scrollToPositionWithOffset(position, 0); + mRecyclerView.postDelayed(() -> mItemPickerAdapter.highlightItem(position), 600); + } + + mInitialHightlightItemName = null; + } + + private void updateViewVisibility(boolean loading, boolean loadError, boolean isDisabled) { + boolean showEmpty = isDisabled + || (!loading && (mItemPickerAdapter.getItemCount() == 0 || loadError)); + mRecyclerView.setVisibility(showEmpty ? View.GONE : View.VISIBLE); + mEmptyView.setVisibility(showEmpty ? View.VISIBLE : View.GONE); + mSwipeLayout.setRefreshing(loading); + @StringRes int message; + if (loadError) { + message = R.string.item_picker_list_error; + } else if (isDisabled) { + message = R.string.settings_tasker_plugin_summary; + } else { + message = R.string.item_picker_list_empty; + } + mEmptyMessage.setText(message); + mRetryButton.setText(isDisabled ? R.string.turn_on : R.string.try_again_button); + mRetryButton.setVisibility(loadError || isDisabled ? View.VISIBLE : View.GONE); + } + + @Override + public boolean onQueryTextSubmit(String query) { + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + mItemPickerAdapter.filter(newText); + return true; + } +} diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.java b/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..a9ef0d7b8a5cf9b7e23c2a2ad2c5debf6f7f3225 --- /dev/null +++ b/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.java @@ -0,0 +1,174 @@ +package org.openhab.habdroid.ui; + +import android.content.Context; +import android.net.Uri; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.recyclerview.widget.RecyclerView; + +import org.openhab.habdroid.R; +import org.openhab.habdroid.core.connection.Connection; +import org.openhab.habdroid.core.connection.ConnectionFactory; +import org.openhab.habdroid.core.connection.exception.ConnectionException; +import org.openhab.habdroid.model.Item; +import org.openhab.habdroid.ui.widget.WidgetImageView; +import org.openhab.habdroid.util.Constants; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public class ItemPickerAdapter extends RecyclerView.Adapter<ItemPickerAdapter.ItemViewHolder> + implements View.OnClickListener { + public interface ItemClickListener { + void onItemClicked(Item item); + } + + private ArrayList<Item> mFilteredItems = new ArrayList<>(); + private ArrayList<Item> mAllItems = new ArrayList<>(); + private ItemClickListener mItemClickListener; + private final LayoutInflater mInflater; + private int mHighlightedPosition = -1; + private static String mIconFormat; + + public ItemPickerAdapter(Context context, ItemClickListener itemClickListener) { + super(); + mIconFormat = PreferenceManager.getDefaultSharedPreferences(context) + .getString(Constants.PREFERENCE_ICON_FORMAT, ""); + mInflater = LayoutInflater.from(context); + mItemClickListener = itemClickListener; + } + + public void setItems(List<Item> items) { + mFilteredItems.clear(); + mFilteredItems.addAll(items); + Collections.sort(mFilteredItems, new ItemNameComparator()); + mAllItems.clear(); + mAllItems.addAll(mFilteredItems); + notifyDataSetChanged(); + } + + public void filter(String filter) { + mFilteredItems.clear(); + String searchTerm = filter.toLowerCase(); + for (Item item : mAllItems) { + if (item.name().toLowerCase().contains(searchTerm) + || item.label().toLowerCase().contains(searchTerm) + || item.type().toString().toLowerCase().contains(searchTerm)) { + mFilteredItems.add(item); + } + } + notifyDataSetChanged(); + } + + private class ItemNameComparator implements Comparator<Item> { + public int compare(Item left, Item right) { + return left.name().compareToIgnoreCase(right.name()); + } + } + + public void clear() { + mFilteredItems.clear(); + notifyDataSetChanged(); + } + + public int findPositionForName(String name) { + for (int i = 0; i < mFilteredItems.size(); i++) { + if (TextUtils.equals(mFilteredItems.get(i).name(), name)) { + return i; + } + } + return -1; + } + + public void highlightItem(int position) { + mHighlightedPosition = position; + notifyItemChanged(position); + } + + @NonNull + @Override + public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new ItemViewHolder(mInflater, parent); + } + + @Override + public void onBindViewHolder(ItemViewHolder holder, int position) { + holder.bind(mFilteredItems.get(position)); + holder.itemView.setOnClickListener(mItemClickListener != null ? this : null); + + if (position == mHighlightedPosition) { + final View v = holder.itemView; + v.post(() -> { + if (v.getBackground() != null) { + final int centerX = v.getWidth() / 2; + final int centerY = v.getHeight() / 2; + DrawableCompat.setHotspot(v.getBackground(), centerX, centerY); + } + v.setPressed(true); + v.setPressed(false); + mHighlightedPosition = -1; + }); + } + } + + @Override + public int getItemCount() { + return mFilteredItems.size(); + } + + public static class ItemViewHolder extends RecyclerView.ViewHolder { + final TextView mItemNameView; + final TextView mItemLabelView; + final WidgetImageView mIconView; + final TextView mItemTypeView; + + public ItemViewHolder(LayoutInflater inflater, ViewGroup parent) { + super(inflater.inflate(R.layout.itempickerlist_item, parent, false)); + mItemNameView = itemView.findViewById(R.id.itemName); + mItemLabelView = itemView.findViewById(R.id.itemLabel); + mItemTypeView = itemView.findViewById(R.id.itemType); + mIconView = itemView.findViewById(R.id.itemIcon); + itemView.setTag(this); + } + + public void bind(Item item) { + mItemNameView.setText(item.name()); + mItemLabelView.setText(item.label()); + mItemTypeView.setText(item.type().toString()); + + Connection connection; + try { + connection = ConnectionFactory.getUsableConnection(); + } catch (ConnectionException e) { + connection = null; + } + if (item.category() != null && connection != null) { + String iconUrl = String.format(Locale.US, "images/%s.%s", + Uri.encode(item.category()), + TextUtils.isEmpty(mIconFormat) ? "png" : mIconFormat); + mIconView.setImageUrl(connection, iconUrl, mIconView.getResources() + .getDimensionPixelSize(R.dimen.notificationlist_icon_size), 2000); + } else { + mIconView.setImageResource(R.drawable.ic_openhab_appicon_24dp); + } + } + } + + @Override + public void onClick(View view) { + ItemPickerAdapter.ItemViewHolder holder = (ItemPickerAdapter.ItemViewHolder) view.getTag(); + int position = holder.getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + mItemClickListener.onItemClicked(mFilteredItems.get(position)); + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/PreferencesActivity.java b/mobile/src/main/java/org/openhab/habdroid/ui/PreferencesActivity.java index a4d9be700acffb1727491734a2439e8f79946a61..5fbb4597a2b1586df574bf210dee9c30c5b068f5 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/PreferencesActivity.java +++ b/mobile/src/main/java/org/openhab/habdroid/ui/PreferencesActivity.java @@ -12,6 +12,7 @@ package org.openhab.habdroid.ui; import android.app.FragmentManager; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.media.Ringtone; import android.media.RingtoneManager; @@ -252,6 +253,8 @@ public class PreferencesActivity extends AbstractBaseActivity { final Preference alarmClockPrefCat = findPreference(Constants.PREFERENCE_SEND_DEVICE_INFO_CAT); final Preference alarmClockPref = findPreference(Constants.PREFERENCE_ALARM_CLOCK); + final Preference taskerPref = + findPreference(Constants.PREFERENCE_TASKER_PLUGIN_ENABLED); final Preference vibrationPref = findPreference(Constants.PREFERENCE_NOTIFICATION_VIBRATION); final Preference ringtoneVibrationPref = @@ -319,6 +322,10 @@ public class PreferencesActivity extends AbstractBaseActivity { return true; }); + if (!getPreferenceBool(taskerPref, false) && !isAutomationAppInstalled()) { + getParent(taskerPref).removePreference(taskerPref); + } + ringtonePref.setOnPreferenceChangeListener((pref, newValue) -> { updateRingtonePreferenceSummary(pref, newValue); return true; @@ -408,6 +415,23 @@ public class PreferencesActivity extends AbstractBaseActivity { } } + private boolean isAutomationAppInstalled() { + String[] packageNames = {"net.dinglisch.android.taskerm", "com.twofortyfouram.locale"}; + + for (String packageName : packageNames) { + try { + if (getActivity().getPackageManager().getApplicationInfo(packageName, 0) + .enabled) { + return true; + } + } catch (PackageManager.NameNotFoundException ignored) { + // ignored + } + } + + return false; + } + /** * @author https://stackoverflow.com/a/17633389 */ diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetListFragment.java b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetListFragment.java index 83fda2c51f37e6b85435c371e6133ae21a628e84..b7ed798a3053e0a7b5986aa43da57010e9451998 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetListFragment.java +++ b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetListFragment.java @@ -44,14 +44,13 @@ import org.openhab.habdroid.core.connection.exception.ConnectionException; import org.openhab.habdroid.model.Item; import org.openhab.habdroid.model.LabeledValue; import org.openhab.habdroid.model.LinkedPage; -import org.openhab.habdroid.model.ParsedState; import org.openhab.habdroid.model.Widget; import org.openhab.habdroid.ui.widget.RecyclerViewSwipeRefreshLayout; import org.openhab.habdroid.util.CacheManager; import org.openhab.habdroid.util.Constants; +import org.openhab.habdroid.util.SuggestedCommandsFactory; import org.openhab.habdroid.util.Util; -import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -76,6 +75,7 @@ public class WidgetListFragment extends Fragment private String mTitle; private RecyclerViewSwipeRefreshLayout mRefreshLayout; private String mHighlightedPageLink; + private SuggestedCommandsFactory mSuggestedCommandsFactory; @Override public void onCreate(Bundle savedInstanceState) { @@ -127,78 +127,14 @@ public class WidgetListFragment extends Fragment @Override public boolean onItemLongClicked(final Widget widget) { - ArrayList<String> labels = new ArrayList<>(); - ArrayList<String> commands = new ArrayList<>(); - - if (widget.item() != null) { - // If the widget has mappings, we will populate names and commands with - // values from those mappings - if (widget.hasMappingsOrItemOptions()) { - for (LabeledValue mapping : widget.getMappingsOrItemOptions()) { - labels.add(mapping.label()); - commands.add(mapping.value()); - } - // Else we only can do it for Switch widget with On/Off/Toggle commands - } else if (widget.type() == Widget.Type.Switch) { - Item item = widget.item(); - if (item.isOfTypeOrGroupType(Item.Type.Switch)) { - labels.add(getString(R.string.nfc_action_on)); - commands.add("ON"); - labels.add(getString(R.string.nfc_action_off)); - commands.add("OFF"); - labels.add(getString(R.string.nfc_action_toggle)); - commands.add("TOGGLE"); - } else if (item.isOfTypeOrGroupType(Item.Type.Rollershutter)) { - labels.add(getString(R.string.nfc_action_up)); - commands.add("UP"); - labels.add(getString(R.string.nfc_action_down)); - commands.add("DOWN"); - labels.add(getString(R.string.nfc_action_toggle)); - commands.add("TOGGLE"); - } - } else if (widget.type() == Widget.Type.Colorpicker) { - labels.add(getString(R.string.nfc_action_on)); - commands.add("ON"); - labels.add(getString(R.string.nfc_action_off)); - commands.add("OFF"); - labels.add(getString(R.string.nfc_action_toggle)); - commands.add("TOGGLE"); - if (widget.state() != null) { - labels.add(getString(R.string.nfc_action_current_color)); - commands.add(widget.state().asString()); - } - } else if (widget.type() == Widget.Type.Setpoint - || widget.type() == Widget.Type.Slider) { - if (widget.state() != null && widget.state().asNumber() != null) { - ParsedState.NumberState state = widget.state().asNumber(); - - String currentState = state.toString(); - labels.add(currentState); - commands.add(currentState); - - String minValue = ParsedState.NumberState.withValue(state, widget.minValue()) - .toString(); - if (!currentState.equals(minValue)) { - labels.add(minValue); - commands.add(minValue); - } - - String maxValue = ParsedState.NumberState.withValue(state, widget.maxValue()) - .toString(); - if (!currentState.equals(maxValue)) { - labels.add(maxValue); - commands.add(maxValue); - } - - if (widget.switchSupport()) { - labels.add(getString(R.string.nfc_action_on)); - commands.add("ON"); - labels.add(getString(R.string.nfc_action_off)); - commands.add("OFF"); - } - } - } + if (mSuggestedCommandsFactory == null) { + mSuggestedCommandsFactory = new SuggestedCommandsFactory(getContext(), false); } + SuggestedCommandsFactory.SuggestedCommands suggestedCommands = + mSuggestedCommandsFactory.fill(widget); + + List<String> labels = suggestedCommands.labels; + List<String> commands = suggestedCommands.commands; if (widget.linkedPage() != null) { labels.add(getString(R.string.nfc_action_to_sitemap_page)); diff --git a/mobile/src/main/java/org/openhab/habdroid/util/Constants.java b/mobile/src/main/java/org/openhab/habdroid/util/Constants.java index fec435fbc8232988c67b1d69bd8f7e2efb8df665..aee46b12b931de152c8559be310e9b041c760aff 100644 --- a/mobile/src/main/java/org/openhab/habdroid/util/Constants.java +++ b/mobile/src/main/java/org/openhab/habdroid/util/Constants.java @@ -42,6 +42,7 @@ public class Constants { public static final String PREFERENCE_ALARM_CLOCK = "alarmClock"; public static final String PREFERENCE_SEND_DEVICE_INFO_PREFIX = "sendDeviceInfoPrefix"; public static final String PREFERENCE_SEND_DEVICE_INFO_CAT = "sendDeviceInfoCat"; + public static final String PREFERENCE_TASKER_PLUGIN_ENABLED = "taskerPlugin"; public static final String PREV_SERVER_FLAGS = "prevServerFlags"; diff --git a/mobile/src/main/java/org/openhab/habdroid/util/SuggestedCommandsFactory.java b/mobile/src/main/java/org/openhab/habdroid/util/SuggestedCommandsFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..8e888178f25c32bd2b190b1f8906101e78455674 --- /dev/null +++ b/mobile/src/main/java/org/openhab/habdroid/util/SuggestedCommandsFactory.java @@ -0,0 +1,174 @@ +package org.openhab.habdroid.util; + +import android.content.Context; +import android.text.InputType; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.openhab.habdroid.R; +import org.openhab.habdroid.model.Item; +import org.openhab.habdroid.model.LabeledValue; +import org.openhab.habdroid.model.ParsedState; +import org.openhab.habdroid.model.Widget; + +import java.util.ArrayList; +import java.util.List; + +public class SuggestedCommandsFactory { + private static final int INPUT_TYPE_DECIMAL_NUMBER = InputType.TYPE_CLASS_NUMBER + | InputType.TYPE_NUMBER_FLAG_DECIMAL; + private static final int INPUT_TYPE_SINGED_DECIMAL_NUMBER = INPUT_TYPE_DECIMAL_NUMBER + | InputType.TYPE_NUMBER_FLAG_SIGNED; + private Context mContext; + private boolean mShowUndef; + + public SuggestedCommandsFactory(Context context, boolean showUndef) { + mContext = context; + mShowUndef = showUndef; + } + + public SuggestedCommands fill(@Nullable Widget widget) { + SuggestedCommands suggestedCommands = new SuggestedCommands(); + if (widget == null || widget.item() == null) { + return suggestedCommands; + } + + if (widget.hasMappingsOrItemOptions()) { + for (LabeledValue mapping : widget.getMappingsOrItemOptions()) { + add(suggestedCommands, mapping.value(), mapping.label()); + } + } + + if (widget.type() == Widget.Type.Setpoint || widget.type() == Widget.Type.Slider) { + if (widget.state() != null && widget.state().asNumber() != null) { + ParsedState.NumberState state = widget.state().asNumber(); + add(suggestedCommands, state.toString()); + add(suggestedCommands, ParsedState.NumberState.withValue(state, + widget.minValue()).toString()); + add(suggestedCommands, ParsedState.NumberState.withValue(state, + widget.maxValue()).toString()); + if (widget.switchSupport()) { + addOnOffCommands(suggestedCommands); + } + } + } + + return fill(widget.item(), suggestedCommands); + } + + public SuggestedCommands fill(@Nullable Item item) { + return fill(item, null); + } + + private SuggestedCommands fill(@Nullable Item item, SuggestedCommands suggestedCommands) { + if (suggestedCommands == null) { + suggestedCommands = new SuggestedCommands(); + } + if (item == null) { + return suggestedCommands; + } + + if (item.isOfTypeOrGroupType(Item.Type.Color)) { + addOnOffCommands(suggestedCommands); + addIncreaseDecreaseCommands(suggestedCommands); + if (item.state() != null) { + add(suggestedCommands, item.state().asString(), R.string.nfc_action_current_color); + } + addCommonPercentCommands(suggestedCommands); + } else if (item.isOfTypeOrGroupType(Item.Type.Contact)) { + // Contact items cannot receive commands + suggestedCommands.shouldShowCustom = false; + } else if (item.isOfTypeOrGroupType(Item.Type.Dimmer)) { + addOnOffCommands(suggestedCommands); + addIncreaseDecreaseCommands(suggestedCommands); + addCommonPercentCommands(suggestedCommands); + suggestedCommands.inputTypeFlags = INPUT_TYPE_SINGED_DECIMAL_NUMBER; + } else if (item.isOfTypeOrGroupType(Item.Type.Number)) { + // Don't suggest numbers that might be totally out of context if there's already + // at least one command + if (suggestedCommands.commands.isEmpty()) { + addCommonNumberCommands(suggestedCommands); + } + suggestedCommands.inputTypeFlags = INPUT_TYPE_SINGED_DECIMAL_NUMBER; + } else if (item.isOfTypeOrGroupType(Item.Type.NumberWithDimension)) { + if (item.state() != null && item.state().asNumber() != null) { + add(suggestedCommands, item.state().asNumber().toString()); + } + } else if (item.isOfTypeOrGroupType(Item.Type.Player)) { + add(suggestedCommands, "PLAY", R.string.nfc_action_play); + add(suggestedCommands, "PAUSE", R.string.nfc_action_pause); + add(suggestedCommands, "NEXT", R.string.nfc_action_next); + add(suggestedCommands, "PREVIOUS", R.string.nfc_action_previous); + add(suggestedCommands, "REWIND", R.string.nfc_action_rewind); + add(suggestedCommands, "FASTFORWARD", R.string.nfc_action_fastforward); + suggestedCommands.shouldShowCustom = false; + } else if (item.isOfTypeOrGroupType(Item.Type.Rollershutter)) { + add(suggestedCommands, "UP", R.string.nfc_action_up); + add(suggestedCommands, "DOWN", R.string.nfc_action_down); + add(suggestedCommands, "TOGGLE", R.string.nfc_action_toggle); + add(suggestedCommands, "MOVE", R.string.nfc_action_move); + add(suggestedCommands, "STOP", R.string.nfc_action_stop); + addCommonPercentCommands(suggestedCommands); + suggestedCommands.inputTypeFlags = INPUT_TYPE_DECIMAL_NUMBER; + } else if (item.isOfTypeOrGroupType(Item.Type.StringItem)) { + if (mShowUndef) { + add(suggestedCommands, "", R.string.nfc_action_empty_string); + add(suggestedCommands, "UNDEF", R.string.nfc_action_undefined); + } + } else if (item.isOfTypeOrGroupType(Item.Type.Switch)) { + addOnOffCommands(suggestedCommands); + suggestedCommands.shouldShowCustom = false; + } else { + if (mShowUndef) { + add(suggestedCommands, "UNDEF", R.string.nfc_action_undefined); + } + } + + return suggestedCommands; + } + + private void add(SuggestedCommands suggestedCommands, String commandAndLabel) { + add(suggestedCommands, commandAndLabel, commandAndLabel); + } + + private void add(SuggestedCommands suggestedCommands, String command, @StringRes int label) { + add(suggestedCommands, command, mContext.getString(label)); + } + + private void add(SuggestedCommands suggestedCommands, String command, String label) { + if (!suggestedCommands.commands.contains(command)) { + suggestedCommands.commands.add(command); + suggestedCommands.labels.add(label); + } + } + + private void addCommonNumberCommands(SuggestedCommands suggestedCommands) { + for (String command : new String[]{"0", "33", "50", "66", "100"}) { + add(suggestedCommands, command); + } + } + + private void addCommonPercentCommands(SuggestedCommands suggestedCommands) { + for (String command : new String[]{"0", "33", "50", "66", "100"}) { + add(suggestedCommands, command, String.format("%s %%", command)); + } + } + + private void addOnOffCommands(SuggestedCommands suggestedCommands) { + add(suggestedCommands, "ON", R.string.nfc_action_on); + add(suggestedCommands, "OFF", R.string.nfc_action_off); + add(suggestedCommands, "TOGGLE", R.string.nfc_action_toggle); + } + + private void addIncreaseDecreaseCommands(SuggestedCommands suggestedCommands) { + add(suggestedCommands, "INCREASE", R.string.nfc_action_increase); + add(suggestedCommands, "DECREASE", R.string.nfc_action_decrease); + } + + public class SuggestedCommands { + public List<String> commands = new ArrayList<>(); + public List<String> labels = new ArrayList<>(); + public boolean shouldShowCustom = true; + public int inputTypeFlags = InputType.TYPE_CLASS_TEXT; + } +} diff --git a/mobile/src/main/java/org/openhab/habdroid/util/TaskerIntent.java b/mobile/src/main/java/org/openhab/habdroid/util/TaskerIntent.java new file mode 100644 index 0000000000000000000000000000000000000000..ed60889f4735cb214dc02dfd4a693013442b10e5 --- /dev/null +++ b/mobile/src/main/java/org/openhab/habdroid/util/TaskerIntent.java @@ -0,0 +1,292 @@ +/* + * android-plugin-api-for-locale https://github.com/twofortyfouram/android-plugin-api-for-locale + * Copyright 2014 two forty four a.m. LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openhab.habdroid.util; + +import androidx.annotation.NonNull; + +/** + * Contains Intent constants necessary for interacting with the plug-in API for Locale. + */ +public class TaskerIntent { + /** + * <p>{@code Intent} action sent by the host to create or + * edit a plug-in condition. When the host sends this {@code Intent}, it + * will be explicit (i.e. sent directly to the package and class of the plug-in's + * {@code Activity}).</p> + * <p>The {@code Intent} MAY contain + * {@link #EXTRA_BUNDLE} and {@link #EXTRA_STRING_BLURB} that was previously set by the {@code + * Activity} result of ACTION_EDIT_CONDITION.</p> + * <p>There SHOULD be only one {@code Activity} per APK that implements this + * {@code Intent}. If a single APK wishes to export multiple plug-ins, it + * MAY implement multiple Activity instances that implement this + * {@code Intent}, however there must only be a single + * {@link #ACTION_QUERY_CONDITION} receiver. In such a scenario, it is the + * responsibility of the Activity to store enough data in + * {@link #EXTRA_BUNDLE} to allow this receiver to disambiguate which + * "plug-in" is being queried. To avoid user confusion, it is recommended + * that only a single plug-in be implemented per APK.</p> + * + * @see TaskerIntent#EXTRA_BUNDLE + * @see TaskerIntent#EXTRA_STRING_BREADCRUMB + */ + @NonNull + public static final String ACTION_EDIT_CONDITION + = "com.twofortyfouram.locale.intent.action.EDIT_CONDITION"; //$NON-NLS-1$ + + /** + * <p>Ordered {@code Intent} action broadcast by the host to query + * a plug-in condition. When the host broadcasts this {@code Intent}, it will + * be explicit (i.e. directly to the package and class of the plug-in's + * {@code BroadcastReceiver}).</p> + * <p>The {@code Intent} MUST contain a + * {@link #EXTRA_BUNDLE} that was previously set by the {@code Activity} + * result of {@link #ACTION_EDIT_CONDITION}. + * </p> + * <p> + * Since this is an ordered broadcast, the plug-in's receiver MUST set an + * appropriate result code from {@link #RESULT_CONDITION_SATISFIED}, + * {@link #RESULT_CONDITION_UNSATISFIED}, or + * {@link #RESULT_CONDITION_UNKNOWN}.</p> + * <p> + * There MUST be only one {@code BroadcastReceiver} per APK that implements + * an Intent-filter for this action. + * </p> + * + * @see TaskerIntent#EXTRA_BUNDLE + * @see TaskerIntent#RESULT_CONDITION_SATISFIED + * @see TaskerIntent#RESULT_CONDITION_UNSATISFIED + * @see TaskerIntent#RESULT_CONDITION_UNKNOWN + */ + @NonNull + public static final String ACTION_QUERY_CONDITION + = "com.twofortyfouram.locale.intent.action.QUERY_CONDITION"; //$NON-NLS-1$ + + /** + * <p> + * {@code Intent} action sent by the host to create or + * edit a plug-in setting. When the host sends this {@code Intent}, it + * will be sent explicit (i.e. sent directly to the package and class of the plug-in's + * {@code Activity}).</p> + * <p>The {@code Intent} MAY contain a {@link #EXTRA_BUNDLE} and {@link + * #EXTRA_STRING_BLURB} + * that was previously set by the {@code Activity} result of + * ACTION_EDIT_SETTING.</p> + * <p> + * There SHOULD be only one {@code Activity} per APK that implements this + * {@code Intent}. If a single APK wishes to export multiple plug-ins, it + * MAY implement multiple Activity instances that implement this + * {@code Intent}, however there must only be a single + * {@link #ACTION_FIRE_SETTING} receiver. In such a scenario, it is the + * responsibility of the Activity to store enough data in + * {@link #EXTRA_BUNDLE} to allow this receiver to disambiguate which + * "plug-in" is being fired. To avoid user confusion, it is recommended that + * only a single plug-in be implemented per APK. + * </p> + * + * @see TaskerIntent#EXTRA_BUNDLE + * @see TaskerIntent#EXTRA_STRING_BREADCRUMB + */ + @NonNull + public static final String ACTION_EDIT_SETTING + = "com.twofortyfouram.locale.intent.action.EDIT_SETTING"; //$NON-NLS-1$ + + /** + * <p> + * {@code Intent} action broadcast by the host to fire a + * plug-in setting. When the host broadcasts this {@code Intent}, it will be + * explicit (i.e. sent directly to the package and class of the plug-in's + * {@code BroadcastReceiver}).</p> + * <p>The {@code Intent} MUST contain a + * {@link #EXTRA_BUNDLE} that was previously set by the {@code Activity} + * result of {@link #ACTION_EDIT_SETTING}.</p> + * <p>There MUST be only one {@code BroadcastReceiver} per APK that implements + * an Intent-filter for this action.</p> + * + * @see TaskerIntent#EXTRA_BUNDLE + */ + @NonNull + public static final String ACTION_FIRE_SETTING + = "com.twofortyfouram.locale.intent.action.FIRE_SETTING"; //$NON-NLS-1$ + + /** + * <p>Implicit broadcast {@code Intent} action to notify the host(s) that a plug-in + * condition is requesting a query it via + * {@link #ACTION_QUERY_CONDITION}. This merely serves as a hint to the host + * that a condition wants to be queried. There is no guarantee as to when or + * if the plug-in will be queried after this action is broadcast. If + * the host does not respond to the plug-in condition after a + * ACTION_REQUEST_QUERY Intent is sent, the plug-in SHOULD shut + * itself down and stop requesting requeries. A lack of response from the host + * indicates that the host is not currently interested in this plug-in. When + * the host becomes interested in the plug-in again, the host will send + * {@link #ACTION_QUERY_CONDITION}.</p> + * <p> + * The extra {@link #EXTRA_STRING_ACTIVITY_CLASS_NAME} MUST be included, otherwise the host will + * ignore this {@code Intent}. + * </p> + * <p> + * Plug-in conditions SHOULD NOT use this unless there is some sort of + * asynchronous event that has occurred, such as a broadcast {@code Intent} + * being received by the plug-in. Plug-ins SHOULD NOT periodically request a + * requery as a way of implementing polling behavior. + * </p> + * <p> + * Hosts MAY throttle plug-ins that request queries too frequently. + * </p> + * + * @see TaskerIntent#EXTRA_STRING_ACTIVITY_CLASS_NAME + */ + @NonNull + public static final String ACTION_REQUEST_QUERY + = "com.twofortyfouram.locale.intent.action.REQUEST_QUERY"; //$NON-NLS-1$ + + /** + * <p> + * Type: {@code String}. + * </p> + * <p> + * Maps to a {@code String} that represents the {@code Activity} bread crumb + * path. + * </p> + */ + @NonNull + public static final String EXTRA_STRING_BREADCRUMB + = "com.twofortyfouram.locale.intent.extra.BREADCRUMB"; //$NON-NLS-1$ + + /** + * <p> + * Type: {@code String}. + * </p> + * <p> + * Maps to a {@code String} that represents a blurb. This is returned as an + * {@code Activity} result extra from the Activity started with {@link #ACTION_EDIT_CONDITION} + * or + * {@link #ACTION_EDIT_SETTING}. + * </p> + * <p> + * The blurb is a concise description displayed to the user of what the + * plug-in is configured to do. + * </p> + */ + @NonNull + public static final String EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB"; + //$NON-NLS-1$ + + /** + * <p> + * Type: {@code Bundle}. + * </p> + * <p> + * Maps to a {@code Bundle} that contains all of a plug-in's extras to later be used when + * querying or firing the plug-in. + * </p> + * <p> + * Plug-ins MUST NOT store {@link android.os.Parcelable} objects in this {@code Bundle} + * , because {@code Parcelable} is not a long-term storage format.</p> + * <p> + * Plug-ins MUST NOT store any serializable object that is not exposed by + * the Android SDK. Plug-ins SHOULD NOT store any serializable object that is not available + * across all Android API levels that the plug-in supports. Doing could cause previously saved + * plug-ins to fail during backup and restore. + * </p> + * <p> + * When the Bundle is serialized by the host, the maximum size of the serialized Bundle MUST be + * less than 25 kilobytes (base-10). While the serialization mechanism used by the host is + * opaque to the plug-in, in general plug-ins should just make their Bundle reasonably compact. + * In Android, Intent extras are limited to about 500 kilobytes, although the exact + * size is not specified by the Android public API. If an Intent exceeds that size, the extras + * will be silently dropped by Android. In Android 4.4 KitKat, the maximum amount of data that + * can be written to a ContentProvider during a ContentProviderOperation was reduced to + * less than 300 kilobytes. The maximum bundle size here was chosen to allow several large + * plug-ins to be added to a single batch of operations before overflow occurs. + * </p> + * <p>If a plug-in needs to store large amounts of data, the plug-in should consider + * implementing its own internal storage mechanism. The Bundle can then contain a small token + * that the plug-in uses as a lookup key in its own internal storage mechanism.</p> + */ + @NonNull + public static final String EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"; + //$NON-NLS-1$ + + /** + * <p> + * Type: {@code String}. + * </p> + * <p> + * Maps to a {@code String} that is the fully qualified class name of a plug-in's + * {@code Activity}. + * </p> + * + * @see TaskerIntent#ACTION_REQUEST_QUERY + */ + @NonNull + public static final String EXTRA_STRING_ACTIVITY_CLASS_NAME = + "com.twofortyfouram.locale.intent.extra.ACTIVITY"; + //$NON-NLS-1$ + + /** + * Ordered broadcast result code indicating that a plug-in condition's state + * is satisfied (true). + * + * @see TaskerIntent#ACTION_QUERY_CONDITION + */ + public static final int RESULT_CONDITION_SATISFIED = 16; + + /** + * Ordered broadcast result code indicating that a plug-in condition's state + * is not satisfied (false). + * + * @see TaskerIntent#ACTION_QUERY_CONDITION + */ + public static final int RESULT_CONDITION_UNSATISFIED = 17; + + /** + * <p> + * Ordered broadcast result code indicating that a plug-in condition's state + * is unknown (neither true nor false). + * </p> + * <p> + * If a condition returns UNKNOWN, then the host will use the last known + * return value on a best-effort basis. Best-effort means that the host may + * not persist known values forever (e.g. last known values could + * hypothetically be cleared after a device reboot or a restart of the + * host's process. If there is no last known return value, then unknown is + * treated as not satisfied (false). + * </p> + * <p> + * The purpose of an UNKNOWN result is to allow a plug-in condition more + * than 10 seconds to process a query. A {@code BroadcastReceiver} MUST + * return within 10 seconds, otherwise it will be killed by Android. A + * plug-in that needs more than 10 seconds might initially return + * RESULT_CONDITION_UNKNOWN, subsequently request a requery, and + * then return either {@link #RESULT_CONDITION_SATISFIED} or + * {@link #RESULT_CONDITION_UNSATISFIED}. + * </p> + * + * @see TaskerIntent#ACTION_QUERY_CONDITION + */ + public static final int RESULT_CONDITION_UNKNOWN = 18; + + /** + * Private constructor prevents instantiation. + * + * @throws UnsupportedOperationException because this class cannot be + * instantiated. + */ + private TaskerIntent() { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } +} \ No newline at end of file diff --git a/mobile/src/main/res/drawable/ic_search_white_24dp.xml b/mobile/src/main/res/drawable/ic_search_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..c927d3d226214bab4c9d3c29eb406b16abd7fddb --- /dev/null +++ b/mobile/src/main/res/drawable/ic_search_white_24dp.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path + android:fillColor="#FFFFFF" + android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/> +</vector> diff --git a/mobile/src/main/res/layout/activity_item_picker.xml b/mobile/src/main/res/layout/activity_item_picker.xml new file mode 100644 index 0000000000000000000000000000000000000000..6bcc42524441117bf1a2449d658c7f7f7059c3b4 --- /dev/null +++ b/mobile/src/main/res/layout/activity_item_picker.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.appcompat.widget.Toolbar + android:id="@+id/openhab_toolbar" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + android:background="?attr/colorPrimary" + android:elevation="8dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:popupTheme="@style/ThemeOverlay.AppCompat.Light" + app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" /> + + <androidx.swiperefreshlayout.widget.SwipeRefreshLayout + android:id="@+id/swipe_container" + android:layout_width="match_parent" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/openhab_toolbar"> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.recyclerview.widget.RecyclerView + android:id="@android:id/list" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:visibility="gone" /> + + <LinearLayout + android:id="@android:id/empty" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:orientation="vertical" + tools:visibility="visible"> + + <androidx.appcompat.widget.AppCompatImageView + android:id="@+id/watermark" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:padding="16dp" + app:tint="@color/empty_list_text_color" + app:tintMode="src_in" + app:srcCompat="@drawable/ic_connection_error" /> + + <TextView + android:id="@+id/empty_message" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:padding="16dp" + android:textAppearance="?android:attr/textAppearanceMedium" + android:textColor="@color/empty_list_text_color" + android:textAlignment="center" + tools:text="Some error occured" /> + + <TextView + android:id="@+id/retry_button" + style="?attr/buttonBarButtonStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + tools:text="@string/try_again_button" /> + + </LinearLayout> + </FrameLayout> + </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/mobile/src/main/res/layout/itempickerlist_item.xml b/mobile/src/main/res/layout/itempickerlist_item.xml new file mode 100644 index 0000000000000000000000000000000000000000..f773a988061aaacb7ea1d97154170c65fff9387e --- /dev/null +++ b/mobile/src/main/res/layout/itempickerlist_item.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?attr/listPreferredItemHeight" + android:background="?attr/selectableItemBackground" + android:focusable="false" + android:orientation="horizontal"> + + <org.openhab.habdroid.ui.widget.WidgetImageView + android:id="@+id/itemIcon" + android:layout_width="@dimen/notificationlist_icon_size" + android:layout_height="@dimen/notificationlist_icon_size" + android:layout_marginLeft="16dp" + android:layout_marginRight="16dp" + android:layout_gravity="center_vertical" + android:scaleType="centerInside" + app:progressIndicator="@drawable/ic_openhab_appicon_24dp" + app:fallback="@drawable/ic_openhab_appicon_24dp" + tools:src="@drawable/ic_openhab_appicon_24dp" /> + + <RelativeLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_gravity="center_vertical" + android:layout_marginRight="16dp" + android:layout_marginEnd="16dp"> + + <TextView + android:id="@+id/itemName" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.AppCompat.Body1" + tools:text="AndroidAlarmClock" /> + + <TextView + android:id="@+id/itemLabel" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@id/itemName" + android:layout_alignParentStart="true" + android:layout_alignParentLeft="true" + android:textAppearance="@style/TextAppearance.AppCompat.Caption" + tools:text="Alarm clock" /> + + <TextView + android:id="@+id/itemType" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@id/itemName" + android:layout_toEndOf="@id/itemLabel" + android:layout_toRightOf="@id/itemLabel" + android:layout_alignParentEnd="true" + android:layout_alignParentRight="true" + android:gravity="end|right" + android:textAppearance="@style/TextAppearance.AppCompat.Caption" + android:textStyle="bold" + tools:text="Number" /> + + </RelativeLayout> + +</LinearLayout> diff --git a/mobile/src/main/res/menu/item_picker.xml b/mobile/src/main/res/menu/item_picker.xml new file mode 100644 index 0000000000000000000000000000000000000000..f1791ec6b0e0ebdb7d09d690354576c4c349c80b --- /dev/null +++ b/mobile/src/main/res/menu/item_picker.xml @@ -0,0 +1,10 @@ +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <item + android:id="@+id/app_bar_search" + android:icon="@drawable/ic_search_white_24dp" + android:title="@string/search" + app:showAsAction="always" + app:actionViewClass="androidx.appcompat.widget.SearchView" /> +</menu> \ No newline at end of file diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index c68fa3cf977783f3ea5a1c26434ece5f9d652deb..8fbab974f65cc2db35cfe6ce5ec03e1825f698c8 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -207,6 +207,18 @@ <string name="nfc_action_off">Off</string> <string name="nfc_action_down">Down</string> <string name="nfc_action_toggle">Toggle</string> + <string name="nfc_action_undefined">Undefined</string> + <string name="nfc_action_empty_string">Empty string</string> + <string name="nfc_action_increase">Increase</string> + <string name="nfc_action_decrease">Decrease</string> + <string name="nfc_action_play">Play</string> + <string name="nfc_action_pause">Pause</string> + <string name="nfc_action_next">Next</string> + <string name="nfc_action_previous">Previous</string> + <string name="nfc_action_rewind">Rewind</string> + <string name="nfc_action_fastforward">Fast forward</string> + <string name="nfc_action_move">Move</string> + <string name="nfc_action_stop">Stop</string> <string name="nfc_activate">Activate</string> <string name="nfc_action_current_color">Current color</string> <string name="nfc_action_to_sitemap_page">Navigate to Sitemap page</string> @@ -288,4 +300,16 @@ <string name="app_intro_skip_button">SKIP</string> <!-- Intro "DONE" button --> <string name="app_intro_done_button">DONE</string> + + <!-- Item picker --> + <string name="item_picker_list_empty">No Items found</string> + <string name="item_picker_list_error">An error occurred while loading Items</string> + <string name="item_picker_custom">Custom</string> + <string name="item_picker_dialog_title">Select state</string> + <string name="item_picker">openHAB Items</string> + <string name="item_picker_blurb">Set \"%s\" (%s) to \"%s\"</string> + <string name="search">Search</string> + <string name="settings_tasker_plugin">Tasker integration</string> + <string name="settings_tasker_plugin_summary">Enable Tasker action plugin which allows updating Items via Tasker or other compatible apps</string> + <string name="turn_on">Turn on</string> </resources> diff --git a/mobile/src/main/res/xml/preferences.xml b/mobile/src/main/res/xml/preferences.xml index b7117b10550bbf89964ed75791f5182a113c97f9..092e8ca7b067cf833315fa7fa3dbac57b94cf860 100644 --- a/mobile/src/main/res/xml/preferences.xml +++ b/mobile/src/main/res/xml/preferences.xml @@ -101,13 +101,19 @@ android:icon="@drawable/ic_alarm_grey_24dp" /> </PreferenceCategory> <PreferenceCategory android:title="@string/settings_misc_title"> + <SwitchPreference + android:defaultValue="false" + android:key="taskerPlugin" + android:title="@string/settings_tasker_plugin" + android:summary="@string/settings_tasker_plugin_summary" + android:icon="@drawable/ic_none" /> <RingtonePreference android:key="default_openhab_alertringtone" android:persistent="true" android:ringtoneType="ringtone|notification" android:showSilent="true" android:title="@string/settings_ringtone" - android:icon="@drawable/ic_bell_ring_outline_grey_24dp" />c + android:icon="@drawable/ic_bell_ring_outline_grey_24dp" /> <ListPreference android:key="default_openhab_notification_vibration" android:title="@string/settings_notification_vibration"