diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 9167327a9c..b3b955f59c 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -38,6 +38,8 @@ //--> <uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> + <uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES"/> + <uses-permission android:name="androidx.car.app.ACCESS_SURFACE"/> <queries> <intent> @@ -723,6 +725,14 @@ android:name="app.organicmaps.background.OsmUploadService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/> + <service + android:name="app.organicmaps.car.NavigationCarAppService" + android:exported="true"> + <intent-filter> + <action android:name="androidx.car.app.CarAppService" /> + <category android:name="androidx.car.app.category.NAVIGATION" /> + </intent-filter> + </service> <!-- Catches app upgraded intent --> <receiver @@ -748,5 +758,13 @@ <!-- Disable Google's anonymous stats collection --> <meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" /> + <!-- For android Auto --> + <meta-data android:name="com.google.android.gms.car.application" + android:resource="@xml/automotive_app_desc"/> + + <meta-data + android:name="androidx.car.app.minCarApiLevel" + android:value="1"/> + </application> </manifest> diff --git a/android/build.gradle b/android/build.gradle index 96c989afd2..8f23e41966 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -84,6 +84,7 @@ dependencies { implementation 'androidx.annotation:annotation:1.5.0' implementation 'androidx.appcompat:appcompat:1.7.0-alpha01' + implementation 'androidx.car.app:app:1.3.0-rc01' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.fragment:fragment:1.5.4' // Lifecycle is added as a workaround for duplicate classes error caused by some outdated dependency: @@ -97,6 +98,8 @@ dependencies { implementation 'androidx.work:work-runtime:2.7.1' implementation 'com.google.android.material:material:1.8.0-alpha02' implementation 'com.google.code.gson:gson:2.10' + // Fix for app/organicmaps/util/FileUploadWorker.java:14: error: cannot access ListenableFuture + implementation 'com.google.guava:guava:29.0-android' implementation 'com.timehop.stickyheadersrecyclerview:library:0.4.3@aar' implementation 'com.github.devnullorthrow:MPAndroidChart:3.2.0-alpha' implementation 'net.jcip:jcip-annotations:1.0' diff --git a/android/gradle.properties b/android/gradle.properties index c9cf2e5f8f..5608450632 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -propMinSdkVersion=21 +propMinSdkVersion=23 propTargetSdkVersion=33 propCompileSdkVersion=33 propBuildToolsVersion=33.0.0 diff --git a/android/res/drawable/ic_check_box.xml b/android/res/drawable/ic_check_box.xml new file mode 100644 index 0000000000..50101a198b --- /dev/null +++ b/android/res/drawable/ic_check_box.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z"/> +</vector> diff --git a/android/res/drawable/ic_check_box_checked.xml b/android/res/drawable/ic_check_box_checked.xml new file mode 100644 index 0000000000..5c0babf540 --- /dev/null +++ b/android/res/drawable/ic_check_box_checked.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/> +</vector> diff --git a/android/res/xml/automotive_app_desc.xml b/android/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000000..83b683397c --- /dev/null +++ b/android/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<automotiveApp> + <uses name="template" /> +</automotiveApp> diff --git a/android/src/app/organicmaps/car/NavigationCarAppService.java b/android/src/app/organicmaps/car/NavigationCarAppService.java new file mode 100644 index 0000000000..3bcadafb78 --- /dev/null +++ b/android/src/app/organicmaps/car/NavigationCarAppService.java @@ -0,0 +1,30 @@ +package app.organicmaps.car; + +import androidx.annotation.NonNull; +import androidx.car.app.CarAppService; +import androidx.car.app.Session; +import androidx.car.app.validation.HostValidator; + +import app.organicmaps.BuildConfig; + +public final class NavigationCarAppService extends CarAppService +{ + @NonNull + @Override + public HostValidator createHostValidator() + { + if (BuildConfig.DEBUG) + return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR; + + return new HostValidator.Builder(getApplicationContext()) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build(); + } + + @NonNull + @Override + public Session onCreateSession() + { + return new NavigationSession(); + } +} diff --git a/android/src/app/organicmaps/car/NavigationSession.java b/android/src/app/organicmaps/car/NavigationSession.java new file mode 100644 index 0000000000..e500aa5f16 --- /dev/null +++ b/android/src/app/organicmaps/car/NavigationSession.java @@ -0,0 +1,69 @@ +package app.organicmaps.car; + +import android.content.Intent; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.car.app.Screen; +import androidx.car.app.Session; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; + +import app.organicmaps.MwmApplication; +import app.organicmaps.car.screens.ErrorScreen; +import app.organicmaps.car.screens.NavigationScreen; + +import java.io.IOException; + +public final class NavigationSession extends Session implements DefaultLifecycleObserver +{ + private static final String TAG = NavigationSession.class.getSimpleName(); + + private final SurfaceRenderer mSurfaceRenderer; + private boolean mInitFailed = false; + + public NavigationSession() + { + getLifecycle().addObserver(this); + mSurfaceRenderer = new SurfaceRenderer(getCarContext(), getLifecycle()); + } + + @NonNull + @Override + public Screen onCreateScreen(@NonNull Intent intent) + { + Log.d(TAG, "onCreateScreen()"); + if (mInitFailed) + return new ErrorScreen(getCarContext()); + + return new NavigationScreen(getCarContext(), mSurfaceRenderer); + } + + @Override + public void onCreate(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onCreate()"); + init(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onResume()"); + init(); + } + + private void init() + { + mInitFailed = false; + MwmApplication app = MwmApplication.from(getCarContext()); + try + { + app.init(); + } catch (IOException e) + { + mInitFailed = true; + Log.e(TAG, "Failed to initialize the app."); + } + } +} diff --git a/android/src/app/organicmaps/car/SurfaceRenderer.java b/android/src/app/organicmaps/car/SurfaceRenderer.java new file mode 100644 index 0000000000..7db089925e --- /dev/null +++ b/android/src/app/organicmaps/car/SurfaceRenderer.java @@ -0,0 +1,172 @@ +package app.organicmaps.car; + +import android.graphics.Rect; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.car.app.AppManager; +import androidx.car.app.CarContext; +import androidx.car.app.CarToast; +import androidx.car.app.SurfaceCallback; +import androidx.car.app.SurfaceContainer; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +import app.organicmaps.Map; +import app.organicmaps.R; + +public class SurfaceRenderer implements DefaultLifecycleObserver, SurfaceCallback +{ + private static final String TAG = SurfaceRenderer.class.getSimpleName(); + + private final CarContext mCarContext; + private final Map mMap; + + @Nullable + private Rect mVisibleArea; + @Nullable + private Rect mStableArea; + + public SurfaceRenderer(@NonNull CarContext carContext, @NonNull Lifecycle lifecycle) + { + Log.d(TAG, "SurfaceRenderer()"); + mCarContext = carContext; + mMap = new Map(); + lifecycle.addObserver(this); + } + + @Override + public void onSurfaceAvailable(@NonNull SurfaceContainer surfaceContainer) + { + Log.d(TAG, "Surface available " + surfaceContainer); + mMap.onSurfaceCreated( + mCarContext, + surfaceContainer.getSurface(), + new Rect(0, 0, surfaceContainer.getWidth(), surfaceContainer.getHeight()), + surfaceContainer.getDpi() + ); + } + + @Override + public void onVisibleAreaChanged(@NonNull Rect visibleArea) + { + Log.d(TAG, "Visible area changed. stableArea: " + mStableArea + " visibleArea:" + visibleArea); + mVisibleArea = visibleArea; + } + + @Override + public void onStableAreaChanged(@NonNull Rect stableArea) + { + Log.d(TAG, "Stable area changed. stableArea: " + mStableArea + " visibleArea:" + mVisibleArea); + mStableArea = stableArea; + } + + @Override + public void onSurfaceDestroyed(@NonNull SurfaceContainer surfaceContainer) + { + Log.d(TAG, "Surface destroyed"); + mMap.onSurfaceDestroyed(false, true); + } + + @Override + public void onCreate(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onCreate"); + mCarContext.getCarService(AppManager.class).setSurfaceCallback(this); + + // TODO: Properly process deep links from other apps on AA. + boolean launchByDeepLink = false; + mMap.onCreate(launchByDeepLink); + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onStart"); + mMap.onStart(); + mMap.setCallbackUnsupported(this::reportUnsupported); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onStop"); + mMap.onStop(); + mMap.setCallbackUnsupported(null); + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onPause"); + mMap.onPause(mCarContext); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) + { + Log.d(TAG, "onResume"); + mMap.onResume(); + } + + @Override + public void onScroll(float distanceX, float distanceY) + { + Log.d(TAG, "onScroll: distanceX: " + distanceX + ", distanceY: " + distanceY); + mMap.onScroll(distanceX, distanceY); + } + + @Override + public void onFling(float velocityX, float velocityY) + { + Log.d(TAG, "onFling: velocityX: " + velocityX + ", velocityY: " + velocityY); + } + + public void onZoomIn() + { + Map.zoomIn(); + } + + public void onZoomOut() + { + Map.zoomOut(); + } + + @Override + public void onScale(float focusX, float focusY, float scaleFactor) + { + Log.d(TAG, "onScale: focusX: " + focusX + ", focusY: " + focusY + ", scaleFactor: " + scaleFactor); + float x = focusX; + float y = focusY; + + Rect visibleArea = mVisibleArea; + if (visibleArea != null) + { + // If a focal point value is negative, use the center point of the visible area. + if (x < 0) + x = visibleArea.centerX(); + if (y < 0) + y = visibleArea.centerY(); + } + + final boolean animated = Float.compare(scaleFactor, 2f) == 0; + + Map.onScale(scaleFactor, x, y, animated); + } + + @Override + public void onClick(float x, float y) + { + Log.d(TAG, "onClick: x: " + x + ", y: " + y); + Map.onTouch(x, y); + } + + private void reportUnsupported() + { + String message = mCarContext.getString(R.string.unsupported_phone); + Log.e(TAG, mCarContext.getString(R.string.unsupported_phone)); + CarToast.makeText(mCarContext, message, CarToast.LENGTH_LONG).show(); + } +} diff --git a/android/src/app/organicmaps/car/UiHelpers.java b/android/src/app/organicmaps/car/UiHelpers.java new file mode 100644 index 0000000000..c7980b89c6 --- /dev/null +++ b/android/src/app/organicmaps/car/UiHelpers.java @@ -0,0 +1,47 @@ +package app.organicmaps.car; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.CarToast; +import androidx.car.app.ScreenManager; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.CarIcon; +import androidx.car.app.navigation.model.MapController; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.car.screens.settings.SettingsScreen; + +public final class UiHelpers +{ + public static ActionStrip createSettingsActionStrip(@NonNull CarContext context, @NonNull SurfaceRenderer surfaceRenderer) + { + final CarIcon iconSettings = new CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.ic_settings)).build(); + final Action settings = new Action.Builder().setIcon(iconSettings).setOnClickListener( + () -> context.getCarService(ScreenManager.class).push(new SettingsScreen(context, surfaceRenderer)) + ).build(); + return new ActionStrip.Builder().addAction(settings).build(); + } + + public static MapController createMapController(@NonNull CarContext context, @NonNull SurfaceRenderer surfaceRenderer) + { + final CarIcon iconPlus = new CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.ic_plus)).build(); + final CarIcon iconMinus = new CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.ic_minus)).build(); + final CarIcon iconLocation = new CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.ic_not_follow)).build(); + + final Action panAction = new Action.Builder(Action.PAN).build(); + final Action location = new Action.Builder().setIcon(iconLocation).setOnClickListener( + () -> CarToast.makeText(context, "Location", CarToast.LENGTH_LONG).show() + ).build(); + final Action zoomIn = new Action.Builder().setIcon(iconPlus).setOnClickListener(surfaceRenderer::onZoomIn).build(); + final Action zoomOut = new Action.Builder().setIcon(iconMinus).setOnClickListener(surfaceRenderer::onZoomOut).build(); + final ActionStrip mapActionStrip = new ActionStrip.Builder() + .addAction(location) + .addAction(zoomIn) + .addAction(zoomOut) + .addAction(panAction) + .build(); + return new MapController.Builder().setMapActionStrip(mapActionStrip).build(); + } +} diff --git a/android/src/app/organicmaps/car/screens/BookmarksScreen.java b/android/src/app/organicmaps/car/screens/BookmarksScreen.java new file mode 100644 index 0000000000..42cb9dee3b --- /dev/null +++ b/android/src/app/organicmaps/car/screens/BookmarksScreen.java @@ -0,0 +1,135 @@ +package app.organicmaps.car.screens; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.car.app.CarContext; +import androidx.car.app.constraints.ConstraintManager; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.Header; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.Template; +import androidx.car.app.navigation.model.MapTemplate; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.bookmarks.data.BookmarkCategory; +import app.organicmaps.bookmarks.data.BookmarkInfo; +import app.organicmaps.bookmarks.data.BookmarkManager; +import app.organicmaps.car.SurfaceRenderer; +import app.organicmaps.car.UiHelpers; +import app.organicmaps.util.Graphics; + +import java.util.ArrayList; +import java.util.List; + +public class BookmarksScreen extends MapScreen +{ + private final int MAX_CATEGORIES_SIZE; + + @Nullable + private BookmarkCategory mBookmarkCategory; + + public BookmarksScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext, surfaceRenderer); + final ConstraintManager constraintManager = getCarContext().getCarService(ConstraintManager.class); + MAX_CATEGORIES_SIZE = constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST); + } + + private BookmarksScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer, @NonNull BookmarkCategory bookmarkCategory) + { + this(carContext, surfaceRenderer); + mBookmarkCategory = bookmarkCategory; + } + + @NonNull + @Override + public Template onGetTemplate() + { + MapTemplate.Builder builder = new MapTemplate.Builder(); + builder.setHeader(createHeader()); + builder.setMapController(UiHelpers.createMapController(getCarContext(), getSurfaceRenderer())); + builder.setActionStrip(UiHelpers.createSettingsActionStrip(getCarContext(), getSurfaceRenderer())); + builder.setItemList(mBookmarkCategory == null ? createBookmarkCategoriesList() : createBookmarksList()); + return builder.build(); + } + + @NonNull + private Header createHeader() + { + Header.Builder builder = new Header.Builder(); + builder.setStartHeaderAction(Action.BACK); + builder.setTitle(mBookmarkCategory == null ? getCarContext().getString(R.string.bookmarks) : mBookmarkCategory.getName()); + return builder.build(); + } + + @NonNull + private ItemList createBookmarkCategoriesList() + { + final List<BookmarkCategory> bookmarkCategories = getBookmarks(); + final int categoriesSize = Math.min(bookmarkCategories.size(), MAX_CATEGORIES_SIZE); + + ItemList.Builder builder = new ItemList.Builder(); + for (int i = 0; i < categoriesSize; ++i) + { + final BookmarkCategory bookmarkCategory = bookmarkCategories.get(i); + + Row.Builder itemBuilder = new Row.Builder(); + itemBuilder.setTitle(bookmarkCategory.getName()); + itemBuilder.addText(bookmarkCategory.getDescription()); + itemBuilder.setOnClickListener(() -> getScreenManager().push(new BookmarksScreen(getCarContext(), getSurfaceRenderer(), bookmarkCategory))); + itemBuilder.setBrowsable(true); + builder.addItem(itemBuilder.build()); + } + return builder.build(); + } + + @NonNull + private ItemList createBookmarksList() + { + final long bookmarkCategoryId = mBookmarkCategory.getId(); + final int bookmarkCategoriesSize = Math.min(mBookmarkCategory.getBookmarksCount(), MAX_CATEGORIES_SIZE); + + ItemList.Builder builder = new ItemList.Builder(); + for (int i = 0; i < bookmarkCategoriesSize; ++i) + { + final long bookmarkId = BookmarkManager.INSTANCE.getBookmarkIdByPosition(bookmarkCategoryId, i); + final BookmarkInfo bookmarkInfo = new BookmarkInfo(bookmarkCategoryId, bookmarkId); + + final Row.Builder itemBuilder = new Row.Builder(); + itemBuilder.setTitle(bookmarkInfo.getName()); + if (!bookmarkInfo.getAddress().isEmpty()) + itemBuilder.addText(bookmarkInfo.getAddress()); + if (!bookmarkInfo.getFeatureType().isEmpty()) + itemBuilder.addText(bookmarkInfo.getFeatureType()); + final Drawable icon = Graphics.drawCircleAndImage(bookmarkInfo.getIcon().argb(), + R.dimen.track_circle_size, + bookmarkInfo.getIcon().getResId(), + R.dimen.bookmark_icon_size, + getCarContext()); + itemBuilder.setImage(new CarIcon.Builder(IconCompat.createWithBitmap(Graphics.drawableToBitmap(icon))).build()); + builder.addItem(itemBuilder.build()); + } + return builder.build(); + } + + @NonNull + private static List<BookmarkCategory> getBookmarks() + { + final List<BookmarkCategory> bookmarkCategories = new ArrayList<>(BookmarkManager.INSTANCE.getCategories()); + + final List<BookmarkCategory> toRemove = new ArrayList<>(); + for (BookmarkCategory bookmarkCategory : bookmarkCategories) + { + if (bookmarkCategory.getBookmarksCount() == 0) + toRemove.add(bookmarkCategory); + } + bookmarkCategories.removeAll(toRemove); + + return bookmarkCategories; + } +} diff --git a/android/src/app/organicmaps/car/screens/CategoriesScreen.java b/android/src/app/organicmaps/car/screens/CategoriesScreen.java new file mode 100644 index 0000000000..4595273821 --- /dev/null +++ b/android/src/app/organicmaps/car/screens/CategoriesScreen.java @@ -0,0 +1,95 @@ +package app.organicmaps.car.screens; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.car.app.CarContext; +import androidx.car.app.constraints.ConstraintManager; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.Header; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.Template; +import androidx.car.app.navigation.model.MapTemplate; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.car.SurfaceRenderer; +import app.organicmaps.car.UiHelpers; + +import java.util.Arrays; +import java.util.List; + +public class CategoriesScreen extends MapScreen +{ + private static class CategoryData + { + @StringRes + public final int nameResId; + + @DrawableRes + public final int iconResId; + + public CategoryData(int nameResId, int iconResId) + { + this.nameResId = nameResId; + this.iconResId = iconResId; + } + } + + private static final List<CategoryData> CATEGORIES = Arrays.asList( + new CategoryData(R.string.fuel, R.drawable.ic_category_fuel), + new CategoryData(R.string.parking, R.drawable.ic_category_parking), + new CategoryData(R.string.eat, R.drawable.ic_category_eat), + new CategoryData(R.string.food, R.drawable.ic_category_food), + new CategoryData(R.string.hotel, R.drawable.ic_category_hotel), + new CategoryData(R.string.toilet, R.drawable.ic_category_toilet), + new CategoryData(R.string.rv, R.drawable.ic_category_rv) + ); + + private final int MAX_CATEGORIES_SIZE; + + public CategoriesScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext, surfaceRenderer); + final ConstraintManager constraintManager = getCarContext().getCarService(ConstraintManager.class); + MAX_CATEGORIES_SIZE = constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST); + } + + @NonNull + @Override + public Template onGetTemplate() + { + MapTemplate.Builder builder = new MapTemplate.Builder(); + builder.setHeader(createHeader()); + builder.setMapController(UiHelpers.createMapController(getCarContext(), getSurfaceRenderer())); + builder.setActionStrip(UiHelpers.createSettingsActionStrip(getCarContext(), getSurfaceRenderer())); + builder.setItemList(createCategoriesList()); + return builder.build(); + } + + @NonNull + private Header createHeader() + { + Header.Builder builder = new Header.Builder(); + builder.setStartHeaderAction(Action.BACK); + builder.setTitle(getCarContext().getString(R.string.categories)); + return builder.build(); + } + + @NonNull + private ItemList createCategoriesList() + { + ItemList.Builder builder = new ItemList.Builder(); + int categoriesSize = Math.min(CATEGORIES.size(), MAX_CATEGORIES_SIZE); + for (int i = 0; i < categoriesSize; ++i) + { + Row.Builder itemBuilder = new Row.Builder(); + itemBuilder.setTitle(getCarContext().getString(CATEGORIES.get(i).nameResId)); + itemBuilder.setImage(new CarIcon.Builder(IconCompat.createWithResource(getCarContext(), CATEGORIES.get(i).iconResId)).build()); + builder.addItem(itemBuilder.build()); + } + return builder.build(); + } +} diff --git a/android/src/app/organicmaps/car/screens/ErrorScreen.java b/android/src/app/organicmaps/car/screens/ErrorScreen.java new file mode 100644 index 0000000000..bcdaf8525f --- /dev/null +++ b/android/src/app/organicmaps/car/screens/ErrorScreen.java @@ -0,0 +1,32 @@ +package app.organicmaps.car.screens; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.Screen; +import androidx.car.app.model.LongMessageTemplate; +import androidx.car.app.model.Template; + +import app.organicmaps.R; + +public class ErrorScreen extends Screen +{ + private static final String TAG = ErrorScreen.class.getSimpleName(); + + public ErrorScreen(@NonNull CarContext carContext) + { + super(carContext); + } + + @NonNull + @Override + public Template onGetTemplate() + { + Log.d(TAG, "onGetTemplate"); + LongMessageTemplate.Builder builder = new LongMessageTemplate.Builder(getCarContext().getString(R.string.dialog_error_storage_message)); + builder.setTitle(getCarContext().getString(R.string.dialog_error_storage_title)); + + return builder.build(); + } +} diff --git a/android/src/app/organicmaps/car/screens/MapScreen.java b/android/src/app/organicmaps/car/screens/MapScreen.java new file mode 100644 index 0000000000..38ab3de45d --- /dev/null +++ b/android/src/app/organicmaps/car/screens/MapScreen.java @@ -0,0 +1,25 @@ +package app.organicmaps.car.screens; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.Screen; + +import app.organicmaps.car.SurfaceRenderer; + +public abstract class MapScreen extends Screen +{ + @NonNull + private final SurfaceRenderer mSurfaceRenderer; + + public MapScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext); + mSurfaceRenderer = surfaceRenderer; + } + + @NonNull + public SurfaceRenderer getSurfaceRenderer() + { + return mSurfaceRenderer; + } +} diff --git a/android/src/app/organicmaps/car/screens/NavigationScreen.java b/android/src/app/organicmaps/car/screens/NavigationScreen.java new file mode 100644 index 0000000000..477113f8d0 --- /dev/null +++ b/android/src/app/organicmaps/car/screens/NavigationScreen.java @@ -0,0 +1,104 @@ +package app.organicmaps.car.screens; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.Header; +import androidx.car.app.model.Item; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.Template; +import androidx.car.app.navigation.model.MapTemplate; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.car.SurfaceRenderer; +import app.organicmaps.car.UiHelpers; + +public class NavigationScreen extends MapScreen +{ + public NavigationScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext, surfaceRenderer); + } + + @NonNull + @Override + public Template onGetTemplate() + { + MapTemplate.Builder builder = new MapTemplate.Builder(); + builder.setHeader(createHeader()); + builder.setMapController(UiHelpers.createMapController(getCarContext(), getSurfaceRenderer())); + builder.setActionStrip(UiHelpers.createSettingsActionStrip(getCarContext(), getSurfaceRenderer())); + builder.setItemList(createList()); + return builder.build(); + } + + @NonNull + private Header createHeader() + { + Header.Builder builder = new Header.Builder(); + builder.setStartHeaderAction(new Action.Builder(Action.APP_ICON).build()); + builder.setTitle(getCarContext().getString(R.string.app_name)); + return builder.build(); + } + + @NonNull + private ItemList createList() + { + ItemList.Builder builder = new ItemList.Builder(); + builder.addItem(createSearchItem()); + builder.addItem(createCategoriesItem()); + builder.addItem(createBookmarksItem()); + return builder.build(); + } + + @NonNull + private Item createSearchItem() + { + final CarIcon iconSearch = new CarIcon.Builder(IconCompat.createWithResource(getCarContext(), R.drawable.ic_search)).build(); + + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(R.string.search)); + builder.setImage(iconSearch); + builder.setBrowsable(true); + builder.setOnClickListener(this::openSearch); + return builder.build(); + } + + @NonNull + private Item createCategoriesItem() + { + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(R.string.categories)); + builder.setBrowsable(true); + builder.setOnClickListener(this::openCategories); + return builder.build(); + } + + @NonNull + private Item createBookmarksItem() + { + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(R.string.bookmarks)); + builder.setBrowsable(true); + builder.setOnClickListener(this::openBookmarks); + return builder.build(); + } + + private void openSearch() + { + getScreenManager().push(new SearchScreen(getCarContext())); + } + + private void openCategories() + { + getScreenManager().push(new CategoriesScreen(getCarContext(), getSurfaceRenderer())); + } + + private void openBookmarks() + { + getScreenManager().push(new BookmarksScreen(getCarContext(), getSurfaceRenderer())); + } +} diff --git a/android/src/app/organicmaps/car/screens/SearchScreen.java b/android/src/app/organicmaps/car/screens/SearchScreen.java new file mode 100644 index 0000000000..f6464aa869 --- /dev/null +++ b/android/src/app/organicmaps/car/screens/SearchScreen.java @@ -0,0 +1,62 @@ +package app.organicmaps.car.screens; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.Screen; +import androidx.car.app.constraints.ConstraintManager; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.SearchTemplate; +import androidx.car.app.model.Template; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.search.SearchRecents; + +public class SearchScreen extends Screen implements SearchTemplate.SearchCallback +{ + private final int MAX_RESULTS_SIZE; + private ItemList mResults; + private String mSearchText = ""; + + public SearchScreen(@NonNull CarContext carContext) + { + super(carContext); + final ConstraintManager constraintManager = getCarContext().getCarService(ConstraintManager.class); + MAX_RESULTS_SIZE = constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST); + } + + @NonNull + @Override + public Template onGetTemplate() + { + SearchTemplate.Builder builder = new SearchTemplate.Builder(this); + builder.setHeaderAction(Action.BACK); + builder.setShowKeyboardByDefault(false); + if (mSearchText.isEmpty() || mResults == null) + loadRecents(); + builder.setItemList(mResults); + builder.setInitialSearchText(mSearchText); + return builder.build(); + } + + private void loadRecents() + { + final CarIcon iconRecent = new CarIcon.Builder(IconCompat.createWithResource(getCarContext(), R.drawable.ic_search_recent)).build(); + + ItemList.Builder builder = new ItemList.Builder(); + builder.setNoItemsMessage(getCarContext().getString(R.string.search_history_text)); + SearchRecents.refresh(); + int recentsSize = Math.min(SearchRecents.getSize(), MAX_RESULTS_SIZE); + for (int i = 0; i < recentsSize; ++i) + { + Row.Builder itemBuilder = new Row.Builder(); + itemBuilder.setTitle(SearchRecents.get(i)); + itemBuilder.setImage(iconRecent); + builder.addItem(itemBuilder.build()); + } + mResults = builder.build(); + } +} diff --git a/android/src/app/organicmaps/car/screens/settings/DrivingOptionsScreen.java b/android/src/app/organicmaps/car/screens/settings/DrivingOptionsScreen.java new file mode 100644 index 0000000000..3142500653 --- /dev/null +++ b/android/src/app/organicmaps/car/screens/settings/DrivingOptionsScreen.java @@ -0,0 +1,82 @@ +package app.organicmaps.car.screens.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.car.app.CarContext; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.Header; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.Template; +import androidx.car.app.navigation.model.MapTemplate; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.car.SurfaceRenderer; +import app.organicmaps.car.UiHelpers; +import app.organicmaps.car.screens.MapScreen; +import app.organicmaps.routing.RoutingOptions; +import app.organicmaps.settings.RoadType; + +public class DrivingOptionsScreen extends MapScreen +{ + @NonNull + private final CarIcon mCheckboxIcon; + @NonNull + private final CarIcon mCheckboxSelectedIcon; + + public DrivingOptionsScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext, surfaceRenderer); + mCheckboxIcon = new CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check_box)).build(); + mCheckboxSelectedIcon = new CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check_box_checked)).build(); + } + + @NonNull + @Override + public Template onGetTemplate() + { + MapTemplate.Builder builder = new MapTemplate.Builder(); + builder.setHeader(createHeader()); + builder.setMapController(UiHelpers.createMapController(getCarContext(), getSurfaceRenderer())); + builder.setItemList(createDrivingOptionsList()); + return builder.build(); + } + + @NonNull + private Header createHeader() + { + Header.Builder builder = new Header.Builder(); + builder.setStartHeaderAction(Action.BACK); + builder.setTitle(getCarContext().getString(R.string.driving_options_subheader)); + return builder.build(); + } + + @NonNull + private ItemList createDrivingOptionsList() + { + ItemList.Builder builder = new ItemList.Builder(); + builder.addItem(createDrivingOptionCheckbox(RoadType.Toll, R.string.avoid_tolls)); + builder.addItem(createDrivingOptionCheckbox(RoadType.Dirty, R.string.avoid_unpaved)); + builder.addItem(createDrivingOptionCheckbox(RoadType.Ferry, R.string.avoid_ferry)); + builder.addItem(createDrivingOptionCheckbox(RoadType.Motorway, R.string.avoid_motorways)); + return builder.build(); + } + + @NonNull + private Row createDrivingOptionCheckbox(RoadType roadType, @StringRes int titleRes) + { + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(titleRes)); + builder.setOnClickListener(() -> { + if (RoutingOptions.hasOption(roadType)) + RoutingOptions.removeOption(roadType); + else + RoutingOptions.addOption(roadType); + DrivingOptionsScreen.this.invalidate(); + }); + builder.setImage(RoutingOptions.hasOption(roadType) ? mCheckboxSelectedIcon : mCheckboxIcon); + return builder.build(); + } +} diff --git a/android/src/app/organicmaps/car/screens/settings/HelpScreen.java b/android/src/app/organicmaps/car/screens/settings/HelpScreen.java new file mode 100644 index 0000000000..aa1ffab2f9 --- /dev/null +++ b/android/src/app/organicmaps/car/screens/settings/HelpScreen.java @@ -0,0 +1,74 @@ +package app.organicmaps.car.screens.settings; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.model.Action; +import androidx.car.app.model.Header; +import androidx.car.app.model.Item; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.Template; +import androidx.car.app.navigation.model.MapTemplate; + +import app.organicmaps.BuildConfig; +import app.organicmaps.Framework; +import app.organicmaps.R; +import app.organicmaps.car.SurfaceRenderer; +import app.organicmaps.car.UiHelpers; +import app.organicmaps.car.screens.MapScreen; +import app.organicmaps.util.DateUtils; + +public class HelpScreen extends MapScreen +{ + public HelpScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext, surfaceRenderer); + } + + @NonNull + @Override + public Template onGetTemplate() + { + MapTemplate.Builder builder = new MapTemplate.Builder(); + builder.setHeader(createHeader()); + builder.setMapController(UiHelpers.createMapController(getCarContext(), getSurfaceRenderer())); + builder.setItemList(createSettingsList()); + return builder.build(); + } + + @NonNull + private Header createHeader() + { + Header.Builder builder = new Header.Builder(); + builder.setStartHeaderAction(Action.BACK); + builder.setTitle(getCarContext().getString(R.string.help)); + return builder.build(); + } + + @NonNull + private ItemList createSettingsList() + { + ItemList.Builder builder = new ItemList.Builder(); + builder.addItem(createVersionInfo()); + builder.addItem(createDataVersionInfo()); + return builder.build(); + } + + @NonNull + private Item createVersionInfo() + { + return new Row.Builder() + .setTitle(getCarContext().getString(R.string.app_name)) + .addText(BuildConfig.VERSION_NAME) + .build(); + } + + @NonNull + private Item createDataVersionInfo() + { + return new Row.Builder() + .setTitle(getCarContext().getString(R.string.data_version, "")) + .addText(DateUtils.getLocalDate(Framework.nativeGetDataVersion())) + .build(); + } +} diff --git a/android/src/app/organicmaps/car/screens/settings/SettingsScreen.java b/android/src/app/organicmaps/car/screens/settings/SettingsScreen.java new file mode 100644 index 0000000000..58acd4942f --- /dev/null +++ b/android/src/app/organicmaps/car/screens/settings/SettingsScreen.java @@ -0,0 +1,111 @@ +package app.organicmaps.car.screens.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.car.app.CarContext; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.Header; +import androidx.car.app.model.Item; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.Template; +import androidx.car.app.navigation.model.MapTemplate; +import androidx.core.graphics.drawable.IconCompat; + +import app.organicmaps.R; +import app.organicmaps.car.SurfaceRenderer; +import app.organicmaps.car.UiHelpers; +import app.organicmaps.car.screens.MapScreen; +import app.organicmaps.util.Config; + +public class SettingsScreen extends MapScreen +{ + private interface PrefsGetter + { + boolean get(); + } + + private interface PrefsSetter + { + void set(boolean newValue); + } + + @NonNull + private final CarIcon mCheckboxIcon; + @NonNull + private final CarIcon mCheckboxSelectedIcon; + + public SettingsScreen(@NonNull CarContext carContext, @NonNull SurfaceRenderer surfaceRenderer) + { + super(carContext, surfaceRenderer); + mCheckboxIcon = new CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check_box)).build(); + mCheckboxSelectedIcon = new CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_check_box_checked)).build(); + } + + @NonNull + @Override + public Template onGetTemplate() + { + MapTemplate.Builder builder = new MapTemplate.Builder(); + builder.setHeader(createHeader()); + builder.setMapController(UiHelpers.createMapController(getCarContext(), getSurfaceRenderer())); + builder.setItemList(createSettingsList()); + return builder.build(); + } + + @NonNull + private Header createHeader() + { + Header.Builder builder = new Header.Builder(); + builder.setStartHeaderAction(Action.BACK); + builder.setTitle(getCarContext().getString(R.string.settings)); + return builder.build(); + } + + @NonNull + private ItemList createSettingsList() + { + ItemList.Builder builder = new ItemList.Builder(); + builder.addItem(createRoutingOptionsItem()); + builder.addItem(createSharedPrefsCheckbox(R.string.big_font, Config::isLargeFontsSize, Config::setLargeFontsSize)); + builder.addItem(createSharedPrefsCheckbox(R.string.transliteration_title, Config::isTransliteration, Config::setTransliteration)); + builder.addItem(createHelpItem()); + return builder.build(); + } + + @NonNull + private Item createRoutingOptionsItem() + { + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(R.string.driving_options_title)); + builder.setOnClickListener(() -> getScreenManager().push(new DrivingOptionsScreen(getCarContext(), getSurfaceRenderer()))); + builder.setBrowsable(true); + return builder.build(); + } + + @NonNull + private Item createHelpItem() + { + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(R.string.help)); + builder.setOnClickListener(() -> getScreenManager().push(new HelpScreen(getCarContext(), getSurfaceRenderer()))); + builder.setBrowsable(true); + return builder.build(); + } + + @NonNull + private Row createSharedPrefsCheckbox(@StringRes int titleRes, PrefsGetter getter, PrefsSetter setter) + { + final boolean getterValue = getter.get(); + + Row.Builder builder = new Row.Builder(); + builder.setTitle(getCarContext().getString(titleRes)); + builder.setOnClickListener(() -> { + setter.set(!getterValue); + SettingsScreen.this.invalidate(); + }); + builder.setImage(getterValue ? mCheckboxSelectedIcon : mCheckboxIcon); + return builder.build(); + } +}