diff --git a/README.md b/README.md index 8ebda657b24abb18eaf1274633cde21eeb552218..47fe7a99aa8a58045f15c40c585ead224231556a 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Before producing any amount of code please have a look at [contribution guidelin ## Build flavors -An optional build flavor "foss" is available for distribution through F-Droid. This build has map view, FCM and crash reporting removed and will not be able to receive push notifications from openHAB Cloud. +An optional build flavor "foss" is available for distribution through F-Droid. This build has FCM and crash reporting removed and will not be able to receive push notifications from openHAB Cloud. For using map view support in the "full" build flavor, you need to visit the [Maps API page](https://developers.google.com/maps/android) and generate an API key via the 'Get a key' button at the top. Then add a line in the following format to the 'gradle.properties' file (either in the same directory as this readme file, or in $HOME/.gradle): `mapsApiKey=<key>`, replacing `<key>` with the API key you just obtained. diff --git a/assets/store_descriptions/en-US/strings.xml b/assets/store_descriptions/en-US/strings.xml index 08b1ef7e60832258bdc0e862ef8bf00e90862be5..e3fc457a50c9b1f4a45d68795fbdc7398b6b5c32 100644 --- a/assets/store_descriptions/en-US/strings.xml +++ b/assets/store_descriptions/en-US/strings.xml @@ -25,7 +25,7 @@ <important_note>Important note</important_note> <oh_server>You need an openHAB server for this app</oh_server> <short_description>Vendor and technology agnostic open source home automation</short_description> - <fdroid>The builds on F-Droid have map view support, FCM and crash reporting removed and will not be able to receive push notifications from openHAB Cloud.</fdroid> + <fdroid>The builds on F-Droid have FCM and crash reporting removed and will not be able to receive push notifications from openHAB Cloud.</fdroid> <fdroid_beta><![CDATA[You can install the beta version alongside the <a href="https://f-droid.org/packages/org.openhab.habdroid/">stable version</a>.]]></fdroid_beta> <fdroid_privacy_policy>Privacy policy: https://www.openhabfoundation.org/privacy.html</fdroid_privacy_policy> <fdroid_anti_features>Anti Features</fdroid_anti_features> diff --git a/mobile/build.gradle b/mobile/build.gradle index 323f1960f1a16562b142f94a7333d2c54786d613..ea9bbdefc8f0d1aeec12b90acdc949db1f5cb481 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -112,8 +112,9 @@ dependencies { transitive false exclude group: 'com.intellij', module: 'annotations' } - // Google Maps + // MapView support fullImplementation 'com.google.android.gms:play-services-maps:16.1.0' + fossImplementation 'org.osmdroid:osmdroid-android:6.1.0' // FCM fullImplementation 'com.google.firebase:firebase-messaging:17.6.0' diff --git a/mobile/src/androidTest/java/org/openhab/habdroid/ui/BasicWidgetTest.java b/mobile/src/androidTest/java/org/openhab/habdroid/ui/BasicWidgetTest.java index 9ba7fc2154d46b598446cf7d0c6da6fac21f740b..43d4d816014cd077b06c301b6a2308d53120f227 100644 --- a/mobile/src/androidTest/java/org/openhab/habdroid/ui/BasicWidgetTest.java +++ b/mobile/src/androidTest/java/org/openhab/habdroid/ui/BasicWidgetTest.java @@ -15,10 +15,8 @@ import org.hamcrest.Description; import org.hamcrest.Matcher; import org.junit.Test; import org.junit.runner.RunWith; -import org.openhab.habdroid.BuildConfig; import org.openhab.habdroid.R; import org.openhab.habdroid.TestWithoutIntro; -import org.openhab.habdroid.util.Util; import static androidx.test.espresso.Espresso.onData; import static androidx.test.espresso.Espresso.onView; @@ -100,12 +98,10 @@ public class BasicWidgetTest extends TestWithoutIntro { .perform(scrollToPosition(10)) .check(matches(atPositionOnView(10, isDisplayed(), R.id.stop_button))); - if (Util.isFlavorFull()) { - // check whether map view is displayed - recyclerView - .perform(scrollToPosition(13)) - .check(matches(atPositionOnView(13, isDisplayed(), "MapView"))); - } + // check whether map view is displayed + recyclerView + .perform(scrollToPosition(13)) + .check(matches(atPositionOnView(13, isDisplayed(), R.id.mapview))); } public interface ChildViewCallback { diff --git a/mobile/src/foss/java/org/openhab/habdroid/ui/MapViewHelper.java b/mobile/src/foss/java/org/openhab/habdroid/ui/MapViewHelper.java index 3b5db246078293321bccba8149c6802aa737b8ff..69e6a2fa1a6aba9c933641831e2cf6587140490f 100644 --- a/mobile/src/foss/java/org/openhab/habdroid/ui/MapViewHelper.java +++ b/mobile/src/foss/java/org/openhab/habdroid/ui/MapViewHelper.java @@ -1,13 +1,259 @@ package org.openhab.habdroid.ui; +import android.app.AlertDialog; +import android.content.Context; +import android.content.res.Resources; +import android.location.Location; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.util.DisplayMetrics; +import android.util.Log; import android.view.LayoutInflater; import android.view.ViewGroup; +import androidx.core.content.ContextCompat; +import org.openhab.habdroid.R; import org.openhab.habdroid.core.connection.Connection; +import org.openhab.habdroid.model.Item; +import org.openhab.habdroid.model.ParsedState; +import org.openhab.habdroid.model.Widget; +import org.openhab.habdroid.util.Util; +import org.osmdroid.api.IMapController; +import org.osmdroid.config.Configuration; +import org.osmdroid.events.MapEventsReceiver; +import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.CopyrightOverlay; +import org.osmdroid.views.overlay.MapEventsOverlay; +import org.osmdroid.views.overlay.Marker; + +import java.util.ArrayList; +import java.util.Locale; public class MapViewHelper { + private static final String TAG = MapViewHelper.class.getSimpleName(); + public static WidgetAdapter.ViewHolder createViewHolder(LayoutInflater inflater, ViewGroup parent, Connection connection, WidgetAdapter.ColorMapper colorMapper) { - return new WidgetAdapter.GenericViewHolder(inflater, parent, connection, colorMapper); + Context context = inflater.getContext(); + Configuration.getInstance().load(context, + PreferenceManager.getDefaultSharedPreferences(context)); + return new OsmViewHolder(inflater, parent, connection, colorMapper); + } + + private static class OsmViewHolder extends WidgetAdapter.LabeledItemBaseViewHolder + implements Marker.OnMarkerDragListener { + private final MapView mMapView; + private final Handler mHandler; + private final int mRowHeightPixels; + private Item mBoundItem; + private boolean mStarted; + + public OsmViewHolder(LayoutInflater inflater, ViewGroup parent, + Connection connection, WidgetAdapter.ColorMapper colorMapper) { + super(inflater, parent, R.layout.openhabwidgetlist_mapitem, connection, colorMapper); + mHandler = new Handler(); + mMapView = itemView.findViewById(R.id.mapview); + mMapView.setTileSource(TileSourceFactory.MAPNIK); + + mMapView.setVerticalMapRepetitionEnabled(false); + mMapView.getOverlays().add(new CopyrightOverlay(itemView.getContext())); + mMapView.setBuiltInZoomControls(false); + mMapView.setMultiTouchControls(false); + mMapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { + @Override + public boolean singleTapConfirmedHelper(GeoPoint p) { + openPopup(); + return true; + } + + @Override + public boolean longPressHelper(GeoPoint p) { + return false; + } + })); + + applyPositionAndLabelWhenReady(mMapView, 15.0f, false, false); + + final Resources res = itemView.getContext().getResources(); + mRowHeightPixels = res.getDimensionPixelSize(R.dimen.row_height); + } + + @Override + public void bind(Widget widget) { + super.bind(widget); + + ViewGroup.LayoutParams lp = mMapView.getLayoutParams(); + int rows = widget.height() > 0 ? widget.height() : 5; + int desiredHeightPixels = rows * mRowHeightPixels; + if (lp.height != desiredHeightPixels) { + lp.height = desiredHeightPixels; + mMapView.setLayoutParams(lp); + } + + mBoundItem = widget.item(); + applyPositionAndLabelWhenReady(mMapView, 15.0f, false, false); + } + + @Override + public void start() { + super.start(); + if (!mStarted) { + mMapView.onResume(); + mStarted = true; + } + } + + @Override + public void stop() { + super.stop(); + if (mStarted) { + mMapView.onPause(); + mStarted = false; + } + } + + @Override + public void onMarkerDragStart(Marker marker) { + // no-op, we're interested in drag end only + } + + @Override + public void onMarkerDrag(Marker marker) { + // no-op, we're interested in drag end only + } + + @Override + public void onMarkerDragEnd(Marker marker) { + String newState = String.format(Locale.US, "%f,%f", + marker.getPosition().getLatitude(), marker.getPosition().getLongitude()); + String item = marker.getId(); + Util.sendItemCommand(mConnection.getAsyncHttpClient(), item, newState); + } + + private void openPopup() { + final MapView mapView = new MapView(itemView.getContext()); + + AlertDialog dialog = new AlertDialog.Builder(itemView.getContext()) + .setView(mapView) + .setCancelable(true) + .setNegativeButton(R.string.close, null) + .create(); + + dialog.setOnDismissListener(dialogInterface -> mapView.onPause()); + dialog.setCanceledOnTouchOutside(true); + dialog.show(); + + mapView.setBuiltInZoomControls(true); + mapView.setMultiTouchControls(true); + mapView.setVerticalMapRepetitionEnabled(false); + mapView.getOverlays().add(new CopyrightOverlay(itemView.getContext())); + mapView.onResume(); + applyPositionAndLabelWhenReady(mapView, 16.0f, true, true); + } + + private void applyPositionAndLabelWhenReady(MapView mapView, float zoomLevel, + boolean allowDrag, boolean allowScroll) { + mHandler.post(() -> applyPositionAndLabel(mapView, zoomLevel, allowDrag, allowScroll)); + } + + private void applyPositionAndLabel(MapView mapView, float zoomLevel, boolean allowDrag, + boolean allowScroll) { + if (mBoundItem == null) { + return; + } + boolean canDragMarker = allowDrag && !mBoundItem.readOnly(); + if (!mBoundItem.members().isEmpty()) { + ArrayList<GeoPoint> positions = new ArrayList<>(); + for (Item item : mBoundItem.members()) { + GeoPoint position = toGeoPoint(item.state()); + if (position != null) { + setMarker(mapView, position, item, item.label(), canDragMarker, + this); + positions.add(position); + } + } + + if (!positions.isEmpty()) { + double north = -90; + double south = 90; + double west = 180; + double east = -180; + for (GeoPoint position : positions) { + north = Math.max(position.getLatitude(), north); + south = Math.min(position.getLatitude(), south); + + west = Math.min(position.getLongitude(), west); + east = Math.max(position.getLongitude(), east); + } + + Log.d(TAG, String.format("North %f, south %f, west %f, east %f", + north, south, west, east)); + BoundingBox boundingBox = new BoundingBox(north, east, south, west); + int extraPixel = (int) convertDpToPixel(24f, mapView.getContext()); + try { + mapView.zoomToBoundingBox(boundingBox, false, extraPixel); + } catch (Exception e) { + Log.d(TAG, "Error applying markers", e); + } + if (!allowScroll) { + mapView.setScrollableAreaLimitLongitude(west, east, extraPixel); + mapView.setScrollableAreaLimitLatitude(north, south, extraPixel); + } + } + } else { + GeoPoint position = toGeoPoint(mBoundItem.state()); + if (position != null) { + setMarker(mapView, position, mBoundItem, mLabelView.getText(), canDragMarker, + this); + moveCamera(mapView, zoomLevel, position); + if (!allowScroll) { + mapView.setScrollableAreaLimitLatitude(position.getLatitude(), + position.getLatitude(), 0); + mapView.setScrollableAreaLimitLongitude(position.getLongitude(), + position.getLongitude(), 0); + } + } + } + } + + /** + * This method converts dp unit to equivalent pixels, depending on device density. + * + * @param dp A value in dp (density independent pixels) unit. Which we need to convert into pixels + * @param context Context to get resources and device specific display metrics + * @return A float value to represent px equivalent to dp depending on device density + */ + private static float convertDpToPixel(float dp, Context context){ + return dp * ((float) context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT); + } + + private static void moveCamera(MapView mapView, float zoom, GeoPoint geoPoint) { + IMapController mapController = mapView.getController(); + mapController.setZoom(zoom); + mapController.setCenter(geoPoint); + } + + private static void setMarker(MapView mapView, GeoPoint position, Item item, + CharSequence label, boolean canDrag, + Marker.OnMarkerDragListener onMarkerDragListener) { + Marker marker = new Marker(mapView); + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM); + marker.setDraggable(canDrag); + marker.setPosition(position); + marker.setTitle(label != null ? label.toString() : null); + marker.setId(item.link()); + marker.setOnMarkerDragListener(onMarkerDragListener); + marker.setIcon(ContextCompat.getDrawable(mapView.getContext(), + R.drawable.ic_location_on_red_24dp)); + mapView.getOverlays().add(marker); + } + + private static GeoPoint toGeoPoint(ParsedState state) { + Location location = state != null ? state.asLocation() : null; + return location != null ? new GeoPoint(location) : null; + } } -} +} \ No newline at end of file diff --git a/mobile/src/foss/res/drawable/ic_location_on_red_24dp.xml b/mobile/src/foss/res/drawable/ic_location_on_red_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..1a61d96435f9251668da3beb5811a1deaa381136 --- /dev/null +++ b/mobile/src/foss/res/drawable/ic_location_on_red_24dp.xml @@ -0,0 +1,5 @@ +<vector android:height="36dp" android:tint="#EA4335" + android:viewportHeight="24.0" android:viewportWidth="24.0" + android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#FF000000" android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z"/> +</vector> diff --git a/mobile/src/foss/res/layout/openhabwidgetlist_mapitem.xml b/mobile/src/foss/res/layout/openhabwidgetlist_mapitem.xml new file mode 100644 index 0000000000000000000000000000000000000000..d03cbffc39b5ac7698fe57052906d3ed4e29737c --- /dev/null +++ b/mobile/src/foss/res/layout/openhabwidgetlist_mapitem.xml @@ -0,0 +1,43 @@ +<?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" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingLeft="@dimen/widgetlist_item_left_margin" + android:paddingRight="@dimen/widgetlist_item_right_margin" + android:background="?android:activatedBackgroundIndicator" + android:paddingStart="@dimen/widgetlist_item_left_margin" + android:paddingEnd="@dimen/widgetlist_item_right_margin" + android:orientation="vertical"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <org.openhab.habdroid.ui.widget.WidgetImageView + android:id="@+id/widgeticon" + android:layout_width="32dp" + android:layout_height="32dp" + android:padding="4dp" /> + + <TextView + android:id="@+id/widgetlabel" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:layout_marginLeft="4dp" + android:layout_marginStart="4dp" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="?android:attr/textAppearanceSmall" /> + + </LinearLayout> + + <org.osmdroid.views.MapView + android:id="@+id/mapview" + android:layout_width="match_parent" + android:layout_height="180dp" + android:layout_margin="4dp" /> + +</LinearLayout> diff --git a/mobile/src/full/res/layout/openhabwidgetlist_mapitem.xml b/mobile/src/full/res/layout/openhabwidgetlist_mapitem.xml index 7ddd9337d15690e4925390972b73ea6fa24fd52a..681078a3e030e7773fb729be90f49145161f805e 100644 --- a/mobile/src/full/res/layout/openhabwidgetlist_mapitem.xml +++ b/mobile/src/full/res/layout/openhabwidgetlist_mapitem.xml @@ -34,13 +34,11 @@ </LinearLayout> - <!-- tag used by unit test, don't remove --> <com.google.android.gms.maps.MapView android:id="@+id/mapview" android:layout_width="match_parent" android:layout_height="180dp" android:layout_margin="4dp" - android:tag="MapView" app:liteMode="true" /> </LinearLayout>