From 0ba52633cef4f2ea4ce5f20e2027a4caf449207d Mon Sep 17 00:00:00 2001 From: aqoleg Date: Tue, 5 Nov 2019 19:13:54 +0300 Subject: [PATCH] 4.0.0 --- README.md | 12 + src/app/build.gradle | 29 + src/app/src/main/AndroidManifest.xml | 26 + .../java/space/aqoleg/cat/BitmapCache.java | 199 +++++++ .../java/space/aqoleg/cat/CurrentTrack.java | 187 +++++++ .../src/main/java/space/aqoleg/cat/Data.java | 79 +++ .../java/space/aqoleg/cat/DialogMaps.java | 74 +++ .../space/aqoleg/cat/DialogSatellites.java | 179 ++++++ .../java/space/aqoleg/cat/DialogTracks.java | 76 +++ .../java/space/aqoleg/cat/Downloader.java | 94 ++++ .../java/space/aqoleg/cat/MainActivity.java | 337 +++++++++++ .../java/space/aqoleg/cat/MainService.java | 106 ++++ .../main/java/space/aqoleg/cat/MapView.java | 521 ++++++++++++++++++ .../src/main/java/space/aqoleg/cat/Maps.java | 217 ++++++++ .../java/space/aqoleg/cat/Projection.java | 86 +++ .../java/space/aqoleg/cat/SavedTracks.java | 189 +++++++ src/app/src/main/res/drawable-hdpi/exit.png | Bin 0 -> 710 bytes src/app/src/main/res/drawable-hdpi/maps.png | Bin 0 -> 2123 bytes src/app/src/main/res/drawable-hdpi/minus.png | Bin 0 -> 213 bytes .../main/res/drawable-hdpi/notification.png | Bin 0 -> 768 bytes src/app/src/main/res/drawable-hdpi/plus.png | Bin 0 -> 413 bytes .../src/main/res/drawable-hdpi/pointer.png | Bin 0 -> 2166 bytes .../src/main/res/drawable-hdpi/satellites.png | Bin 0 -> 1169 bytes src/app/src/main/res/drawable-hdpi/tracks.png | Bin 0 -> 690 bytes src/app/src/main/res/drawable-mdpi/exit.png | Bin 0 -> 536 bytes src/app/src/main/res/drawable-mdpi/maps.png | Bin 0 -> 1340 bytes src/app/src/main/res/drawable-mdpi/minus.png | Bin 0 -> 175 bytes .../main/res/drawable-mdpi/notification.png | Bin 0 -> 483 bytes src/app/src/main/res/drawable-mdpi/plus.png | Bin 0 -> 311 bytes .../src/main/res/drawable-mdpi/pointer.png | Bin 0 -> 1477 bytes .../src/main/res/drawable-mdpi/satellites.png | Bin 0 -> 780 bytes src/app/src/main/res/drawable-mdpi/tracks.png | Bin 0 -> 482 bytes src/app/src/main/res/drawable-xhdpi/exit.png | Bin 0 -> 812 bytes src/app/src/main/res/drawable-xhdpi/maps.png | Bin 0 -> 2903 bytes src/app/src/main/res/drawable-xhdpi/minus.png | Bin 0 -> 235 bytes .../main/res/drawable-xhdpi/notification.png | Bin 0 -> 1029 bytes src/app/src/main/res/drawable-xhdpi/plus.png | Bin 0 -> 436 bytes .../src/main/res/drawable-xhdpi/pointer.png | Bin 0 -> 3026 bytes .../main/res/drawable-xhdpi/satellites.png | Bin 0 -> 1456 bytes .../src/main/res/drawable-xhdpi/tracks.png | Bin 0 -> 907 bytes src/app/src/main/res/layout/activity_main.xml | 50 ++ src/app/src/main/res/layout/dialog_list.xml | 10 + .../src/main/res/layout/dialog_satellites.xml | 16 + src/app/src/main/res/layout/list_item.xml | 8 + src/app/src/main/res/mipmap-hdpi/icon.png | Bin 0 -> 3706 bytes src/app/src/main/res/mipmap-mdpi/icon.png | Bin 0 -> 2194 bytes src/app/src/main/res/mipmap-xhdpi/icon.png | Bin 0 -> 4652 bytes src/app/src/main/res/values/colors.xml | 4 + src/app/src/main/res/values/strings.xml | 24 + src/svg/cat.svg | 269 +++++++++ src/svg/exit.svg | 23 + src/svg/icon.svg | 26 + src/svg/maps.svg | 27 + src/svg/minus.svg | 22 + src/svg/notification.svg | 26 + src/svg/plus.svg | 22 + src/svg/pointer.svg | 29 + src/svg/satellites.svg | 24 + src/svg/tracks.svg | 22 + 59 files changed, 3013 insertions(+) create mode 100644 README.md create mode 100644 src/app/build.gradle create mode 100644 src/app/src/main/AndroidManifest.xml create mode 100644 src/app/src/main/java/space/aqoleg/cat/BitmapCache.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/CurrentTrack.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/Data.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/DialogMaps.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/DialogSatellites.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/DialogTracks.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/Downloader.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/MainActivity.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/MainService.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/MapView.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/Maps.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/Projection.java create mode 100644 src/app/src/main/java/space/aqoleg/cat/SavedTracks.java create mode 100644 src/app/src/main/res/drawable-hdpi/exit.png create mode 100644 src/app/src/main/res/drawable-hdpi/maps.png create mode 100644 src/app/src/main/res/drawable-hdpi/minus.png create mode 100644 src/app/src/main/res/drawable-hdpi/notification.png create mode 100644 src/app/src/main/res/drawable-hdpi/plus.png create mode 100644 src/app/src/main/res/drawable-hdpi/pointer.png create mode 100644 src/app/src/main/res/drawable-hdpi/satellites.png create mode 100644 src/app/src/main/res/drawable-hdpi/tracks.png create mode 100644 src/app/src/main/res/drawable-mdpi/exit.png create mode 100644 src/app/src/main/res/drawable-mdpi/maps.png create mode 100644 src/app/src/main/res/drawable-mdpi/minus.png create mode 100644 src/app/src/main/res/drawable-mdpi/notification.png create mode 100644 src/app/src/main/res/drawable-mdpi/plus.png create mode 100644 src/app/src/main/res/drawable-mdpi/pointer.png create mode 100644 src/app/src/main/res/drawable-mdpi/satellites.png create mode 100644 src/app/src/main/res/drawable-mdpi/tracks.png create mode 100644 src/app/src/main/res/drawable-xhdpi/exit.png create mode 100644 src/app/src/main/res/drawable-xhdpi/maps.png create mode 100644 src/app/src/main/res/drawable-xhdpi/minus.png create mode 100644 src/app/src/main/res/drawable-xhdpi/notification.png create mode 100644 src/app/src/main/res/drawable-xhdpi/plus.png create mode 100644 src/app/src/main/res/drawable-xhdpi/pointer.png create mode 100644 src/app/src/main/res/drawable-xhdpi/satellites.png create mode 100644 src/app/src/main/res/drawable-xhdpi/tracks.png create mode 100644 src/app/src/main/res/layout/activity_main.xml create mode 100644 src/app/src/main/res/layout/dialog_list.xml create mode 100644 src/app/src/main/res/layout/dialog_satellites.xml create mode 100644 src/app/src/main/res/layout/list_item.xml create mode 100644 src/app/src/main/res/mipmap-hdpi/icon.png create mode 100644 src/app/src/main/res/mipmap-mdpi/icon.png create mode 100644 src/app/src/main/res/mipmap-xhdpi/icon.png create mode 100644 src/app/src/main/res/values/colors.xml create mode 100644 src/app/src/main/res/values/strings.xml create mode 100644 src/svg/cat.svg create mode 100644 src/svg/exit.svg create mode 100644 src/svg/icon.svg create mode 100644 src/svg/maps.svg create mode 100644 src/svg/minus.svg create mode 100644 src/svg/notification.svg create mode 100644 src/svg/plus.svg create mode 100644 src/svg/pointer.svg create mode 100644 src/svg/satellites.svg create mode 100644 src/svg/tracks.svg diff --git a/README.md b/README.md new file mode 100644 index 0000000..d84ca74 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# cat + +All maps offline in one app and track writer. + +[download](https://github.com/aqoleg/cat/releases/download/4.0.0/cat.apk) + +Put tiles in the /cat/maps/mapName/z/y/x.png or /x.jpeg +and/or specifiy parameters in /cat/maps/mapName/properties.txt as json file: +- "name" - optional, name of the map +- "url" - optional, url to download as java formatted string "https://example/x=%1$d/y=%2$d/z=%3$d" +- "size" - optional, size of the tile +- "projection": "ellipsoid" - optional, for ellipsoid projection diff --git a/src/app/build.gradle b/src/app/build.gradle new file mode 100644 index 0000000..3c3bdd3 --- /dev/null +++ b/src/app/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 26 + buildToolsVersion "27.0.1" + defaultConfig { + applicationId "space.aqoleg.cat" + minSdkVersion 11 + targetSdkVersion 26 + versionCode 46 + versionName "4.0.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(include: ['*.jar'], dir: 'libs') + androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { + exclude group: 'com.android.support', module: 'support-annotations' + }) + testCompile 'junit:junit:4.12' +} \ No newline at end of file diff --git a/src/app/src/main/AndroidManifest.xml b/src/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..43518ff --- /dev/null +++ b/src/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/BitmapCache.java b/src/app/src/main/java/space/aqoleg/cat/BitmapCache.java new file mode 100644 index 0000000..a525d49 --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/BitmapCache.java @@ -0,0 +1,199 @@ +/* +cache with bitmaps +when get the tile, returns bitmap from cache and puts on the top of the stack, +or, if there is no such tile, asynchronously loads it from memory and puts it on the top of the stack +instead of the bottom item, if there is no such tile in the memory, try to get it from the lower zooms +and calls downloader in the one instance + */ +package space.aqoleg.cat; + +import android.graphics.Bitmap; +import android.os.AsyncTask; + +import java.io.RandomAccessFile; +import java.util.Arrays; + +class BitmapCache implements Downloader.Callback { + private final Maps maps; + private final Callback callback; + + private final int cacheSize; + private final int[] cacheMapN; + private final int[] cacheZ; + private final int[] cacheY; + private final int[] cacheX; + private final Bitmap[] cacheBitmap; + private final boolean[] cacheToDownload; + private final int[] cacheStackLevel; + private int nextStackLevel = 0; // possibly can not overflow + + private boolean hasLoader = false; + private boolean hasDownloader = false; + + BitmapCache(Callback callback) { + maps = Maps.getInstance(); + this.callback = callback; + cacheSize = getCacheSize(); + cacheMapN = new int[cacheSize]; + Arrays.fill(cacheMapN, -1); // tile with mapN = 0, z = 0, y = 0, x = 0 did not loaded yet + cacheZ = new int[cacheSize]; + cacheY = new int[cacheSize]; + cacheX = new int[cacheSize]; + cacheBitmap = new Bitmap[cacheSize]; + cacheToDownload = new boolean[cacheSize]; + cacheStackLevel = new int[cacheSize]; + } + + @Override + public void onDownloadFinish(int mapN, int z, int y, int x, Bitmap bitmap) { + for (int i = 0; i < cacheSize; i++) { + if (x == cacheX[i] && y == cacheY[i] && z == cacheZ[i] && mapN == cacheMapN[i]) { + if (bitmap != null) { + cacheBitmap[i] = bitmap; + } + cacheToDownload[i] = false; // even if this tile did not loaded, do not try one more time + break; + } + } + hasDownloader = false; + callback.onBitmapCacheLoad(); + } + + // returns bitmap from cache or null if it does not exist in the cache + Bitmap getBitmap(int mapN, int z, int y, int x) { + for (int i = 0; i < cacheSize; i++) { + if (x == cacheX[i] && y == cacheY[i] && z == cacheZ[i] && mapN == cacheMapN[i]) { + // put on the top of the stack + nextStackLevel++; + cacheStackLevel[i] = nextStackLevel; + // start download + if (cacheToDownload[i] && !hasDownloader && maps.canDownload(mapN)) { + hasDownloader = true; + new Downloader(mapN, z, y, x, this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + return cacheBitmap[i]; + } + } + // if tile did not find in the cache, start loader + if (!hasLoader) { + hasLoader = true; + new Loader(mapN, z, y, x).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + return null; + } + + private int getCacheSize() { + try { + RandomAccessFile reader = new RandomAccessFile("/proc/meminfo", "r"); + String line = reader.readLine(); + reader.close(); + // MemTotal: 1030428 kB + line = line.substring(line.indexOf("MemTotal:") + 9, line.indexOf("kB")).trim(); + int value = Integer.valueOf(line); + // 30 + ~50 tiles per gb + value = value / 20000; + if (value < 10) { + value = 10; + } else if (value > 200) { + value = 200; + } + return value + 30; + } catch (Exception exception) { + Data.getInstance().writeLog("can not determine memory size: " + exception.toString()); + } + return 50; + } + + interface Callback { + void onBitmapCacheLoad(); + } + + class Loader extends AsyncTask { + private final int loaderMapN; + private final int loaderZ; + private final int loaderY; + private final int loaderX; + private Bitmap bitmap; + private boolean isBitmapLoaded; + + Loader(int mapN, int z, int y, int x) { + loaderMapN = mapN; + loaderZ = z; + loaderY = y; + loaderX = x; + } + + @Override + protected Void doInBackground(Void... voids) { + bitmap = maps.getTile(loaderMapN, loaderZ, loaderY, loaderX); + isBitmapLoaded = bitmap != null; + if (!isBitmapLoaded) { + // try to fill with tile from previous zoom + int fullTileSize = maps.getSize(loaderMapN); + int xLeftPx = 0; + int yTopPx = 0; + int size = fullTileSize; + int z = loaderZ; + int y = loaderY; + int x = loaderX; + for (int i = 0; i < 5; i++) { + z--; + if (z == 0) { + break; + } + size = size >> 1; + + xLeftPx = xLeftPx >> 1; + if ((x & 0b1) == 1) { + // right half of the tile + xLeftPx += fullTileSize >> 1; + } + x = x >> 1; + + yTopPx = yTopPx >> 1; + if ((y & 0b1) == 1) { + // bottom half of the tile + yTopPx += fullTileSize >> 1; + } + y = y >> 1; + + bitmap = maps.getTile(loaderMapN, z, y, x); + if (bitmap != null) { + bitmap = Bitmap.createBitmap(bitmap, xLeftPx, yTopPx, size, size); + bitmap = Bitmap.createScaledBitmap(bitmap, fullTileSize, fullTileSize, true); + return null; + } + } + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + // do operation in the stack in the main thread + // search for the bottom item of the stack + int cacheN = 0; + int lowestStackLevel = Integer.MAX_VALUE; + for (int i = 0; i < cacheSize; i++) { + if (cacheStackLevel[i] < lowestStackLevel) { + cacheN = i; + lowestStackLevel = cacheStackLevel[i]; + } + } + // put tile instead the bottom item + cacheMapN[cacheN] = loaderMapN; + cacheZ[cacheN] = loaderZ; + cacheY[cacheN] = loaderY; + cacheX[cacheN] = loaderX; + cacheBitmap[cacheN] = bitmap; + cacheToDownload[cacheN] = !isBitmapLoaded; + // put on the top of the stack + nextStackLevel++; + cacheStackLevel[cacheN] = nextStackLevel; + // refresh + hasLoader = false; + callback.onBitmapCacheLoad(); + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/CurrentTrack.java b/src/app/src/main/java/space/aqoleg/cat/CurrentTrack.java new file mode 100644 index 0000000..f06979f --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/CurrentTrack.java @@ -0,0 +1,187 @@ +/* +current track singleton +filters and keeps points of the current track, writes it in .gpx file, calculates total distance + +gpx file: + + + + // at least one + // decimal degrees, latitude from -90 till 90, longitude from -180 till 180 + 0 // elevation (in meters), optional, default is 0 + // utc date YYYY-MM-DD, T if one line, time hh:mm:ss, Z zero meridian + + + + + */ +package space.aqoleg.cat; + +import android.location.Location; + +import java.io.File; +import java.io.FileWriter; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +class CurrentTrack { + private static final SimpleDateFormat fileName; // 2019-09-06T11-28-00.gpx in local time zone + private static final SimpleDateFormat gpxTime; // 2019-09-06T11:28:00Z in utc + private static final String gpxOpen; // \n + private static final String gpxPoint; // \n + private static final String gpxClose; // ... + + private static final CurrentTrack track = new CurrentTrack(); + + private final ArrayList xList = new ArrayList<>(); + private final ArrayList ySphericalList = new ArrayList<>(); + private final ArrayList yEllipsoidList = new ArrayList<>(); + private File file; + private Location previousLocation; + private float totalDistance; + private float localDistance; // can be reset + + static { + fileName = new SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss'.gpx'", Locale.getDefault()); + gpxTime = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()); + gpxTime.setTimeZone(TimeZone.getTimeZone("UTC")); + gpxOpen = "" + + System.getProperty("line.separator") + + "" + + System.getProperty("line.separator") + + " " + System.getProperty("line.separator") + + " " + System.getProperty("line.separator"); + gpxPoint = " %n" + + " %3$d%n" + + " %n" + + " %n"; + gpxClose = " " + System.getProperty("line.separator") + + " " + System.getProperty("line.separator") + + ""; + } + + static CurrentTrack getInstance() { + return track; + } + + // clears all data + void open() { + xList.clear(); + ySphericalList.clear(); + yEllipsoidList.clear(); + file = null; + previousLocation = null; + totalDistance = 0; + localDistance = 0; + } + + // deletes track if it is too short or closes it, and clears data + void close() { + if (file != null) { + if (totalDistance > 200 && file.length() > 480) { // more then 3 points and 200 m + try { + FileWriter writer = new FileWriter(file, true); + writer.append(gpxClose); + writer.flush(); + writer.close(); + } catch (Exception exception) { + Data.getInstance().writeLog("can not close track: " + exception.toString()); + } + } else if (!file.delete()) { + Data.getInstance().writeLog("can not delete short track"); + } + } + open(); + } + + // this track will not have add to saved track list + String getName() { + if (file == null) { + return null; + } + return file.getName(); + } + + // returns last written location or null + Location getLastLocation() { + return previousLocation; + } + + int getPointsNumber() { + return xList.size(); + } + + double getX(int pointN) { + return xList.get(pointN); + } + + double getY(int pointN, boolean isEllipsoid) { + if (isEllipsoid) { + return yEllipsoidList.get(pointN); + } else { + return ySphericalList.get(pointN); + } + } + + float getTotalDistance() { + return totalDistance; + } + + float getLocalDistance() { + return localDistance; + } + + void resetLocalDistance() { + localDistance = 0; + } + + void setNewLocation(Location location) { + if (previousLocation == null) { + // first point + previousLocation = location; + } else { + float distance = previousLocation.distanceTo(location); + if (distance > 50 && distance > location.getAccuracy() * 4) { + totalDistance += distance; + localDistance += distance; + previousLocation = location; + } else { + return; + } + } + double longitude = location.getLongitude(); + double latitude = location.getLatitude(); + + xList.add(Projection.getX(longitude)); + ySphericalList.add(Projection.getY(latitude, false)); + yEllipsoidList.add(Projection.getY(latitude, true)); + // decimal separator is a dot, float is rounded by 6 digits after dot + String point = String.format( + Locale.ENGLISH, + gpxPoint, + longitude, + latitude, + Math.round(location.getAltitude()), + gpxTime.format(new Date()) + ); + + boolean firstPoint = file == null; + if (firstPoint) { + file = new File(Data.getInstance().getTracks(), fileName.format(new Date())); + } + try { + FileWriter writer = new FileWriter(file, true); + if (firstPoint) { + writer.append(gpxOpen); + } + writer.append(point); + writer.flush(); + writer.close(); + } catch (Exception exception) { + Data.getInstance().writeLog("can not write track: " + exception.toString()); + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/Data.java b/src/app/src/main/java/space/aqoleg/cat/Data.java new file mode 100644 index 0000000..01a8a7a --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/Data.java @@ -0,0 +1,79 @@ +/* +root singleton +creates root directory and files on the first launch, writes log + +files: +sd/cat/.nomedia +sd/cat/log.txt +sd/cat/maps +sd/cat/tracks + */ +package space.aqoleg.cat; + +import android.os.Environment; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +class Data { + private static Data data; + + private final File log; + private final File maps; + private final File tracks; + + private Data() { + File root = new File(Environment.getExternalStorageDirectory(), "cat"); + log = new File(root, "log.txt"); + if (!root.isDirectory()) { + if (root.mkdirs()) { + try { + if (!new File(root, ".nomedia").createNewFile()) { + throw new IOException(); + } + } catch (IOException e) { + writeLog("can not create .nomedia " + e.toString()); + } + } + } + maps = new File(root, "maps"); + if (!maps.isDirectory()) { + if (!maps.mkdir()) { + writeLog("can not create " + maps.getAbsolutePath()); + } + } + tracks = new File(root, "tracks"); + if (!tracks.isDirectory()) { + if (!tracks.mkdir()) { + writeLog("can not create " + tracks.getAbsolutePath()); + } + } + } + + static Data getInstance() { + if (data == null) { + data = new Data(); + } + return data; + } + + void writeLog(String string) { + try { + FileWriter writer = new FileWriter(log, true); + writer.append(string); + writer.append(System.getProperty("line.separator")); + writer.flush(); + writer.close(); + } catch (IOException ignored) { + } + } + + File getMaps() { + return maps; + } + + File getTracks() { + return tracks; + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/DialogMaps.java b/src/app/src/main/java/space/aqoleg/cat/DialogMaps.java new file mode 100644 index 0000000..2db607e --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/DialogMaps.java @@ -0,0 +1,74 @@ +/* +list with all maps to select + */ +package space.aqoleg.cat; + +import android.app.DialogFragment; +import android.content.Context; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +public class DialogMaps extends DialogFragment implements AdapterView.OnItemClickListener { + private static final String argumentCurrentMapN = "mapN"; + + private int currentMapN; + + static DialogMaps newInstance(int currentMapN) { + Bundle args = new Bundle(); + args.putInt(argumentCurrentMapN, currentMapN); + DialogMaps dialog = new DialogMaps(); + dialog.setArguments(args); + dialog.setStyle(DialogFragment.STYLE_NO_TITLE, 0); + return dialog; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.dialog_list, container, false); + ((TextView) view.findViewById(R.id.title)).setText(getString(R.string.maps)); + currentMapN = getArguments().getInt(argumentCurrentMapN); + + ListView listView = view.findViewById(R.id.list); + listView.setAdapter(new Adapter(getActivity().getApplicationContext())); + listView.setSelection(currentMapN); + listView.setOnItemClickListener(this); + return view; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (position != currentMapN) { + ((MainActivity) getActivity()).selectMap(position); + } + dismiss(); + } + + private class Adapter extends ArrayAdapter { + Adapter(Context context) { + super( + context, + R.layout.list_item, + R.id.text, + Maps.getInstance().getList() + ); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = super.getView(position, convertView, parent); + if (position == currentMapN) { + view.setBackgroundColor(Color.GRAY); + } else { + view.setBackgroundColor(Color.TRANSPARENT); + } + return view; + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/DialogSatellites.java b/src/app/src/main/java/space/aqoleg/cat/DialogSatellites.java new file mode 100644 index 0000000..0eab48e --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/DialogSatellites.java @@ -0,0 +1,179 @@ +/* +fragment with detailed information about satellites and location + */ +package space.aqoleg.cat; + +import android.app.DialogFragment; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.location.*; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import static android.content.Context.LOCATION_SERVICE; + +@SuppressWarnings("deprecation") +public class DialogSatellites extends DialogFragment implements LocationListener, GpsStatus.Listener { + private LocationManager locationManager; // null if listeners has removed + private GpsStatus gpsStatus; + + private SatellitesView satellitesView; + + static DialogSatellites newInstance() { + DialogSatellites dialog = new DialogSatellites(); + dialog.setStyle(DialogFragment.STYLE_NO_TITLE, 0); + return dialog; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.dialog_satellites, container, false); + satellitesView = new SatellitesView(getActivity().getApplicationContext()); + ((FrameLayout) view.findViewById(R.id.frame)).addView(satellitesView); + return view; + } + + @Override + public void onStart() { + super.onStart(); + locationManager = ((LocationManager) getActivity().getSystemService(LOCATION_SERVICE)); + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this); + locationManager.addGpsStatusListener(this); + } + + @Override + public void onStop() { + locationManager.removeUpdates(this); + locationManager.removeGpsStatusListener(this); + locationManager = null; + super.onStop(); + } + + @Override + public void onLocationChanged(Location location) { + if (locationManager == null) { + return; + } + String text = String.format( + getString(R.string.locationText), + location.getLongitude(), + location.getLatitude(), + Math.round(location.getAltitude()), + Math.round(location.getAccuracy()), + Math.round(location.getSpeed() * 3.6f) + ); + ((TextView) getView().findViewById(R.id.coordinates)).setText(text); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + } + + @Override + public void onProviderEnabled(String provider) { + } + + @Override + public void onProviderDisabled(String provider) { + } + + @Override + public void onGpsStatusChanged(int event) { + if (locationManager == null) { + return; + } + gpsStatus = locationManager.getGpsStatus(gpsStatus); + satellitesView.invalidate(); + } + + class SatellitesView extends View { + private final Paint grid; + private final Paint usedSatellite; + private final Paint notUsedSatellite; + + private int xCenterPx; + private int yCenterPx; + private float outerRadiusPx; + private float middleRadiusPx; + private float innerRadiusPx; + private float positionScalePx; + private float usedSatelliteRadiusScalePx; + private float notUsedSatelliteRadiusPx; + + SatellitesView(Context context) { + super(context); + + grid = new Paint(); + grid.setStyle(Paint.Style.STROKE); + grid.setStrokeWidth(1); + grid.setColor(Color.BLACK); + grid.setAntiAlias(true); + + usedSatellite = new Paint(); + usedSatellite.setStyle(Paint.Style.FILL_AND_STROKE); + usedSatellite.setColor(Color.GREEN); + usedSatellite.setAntiAlias(true); + + notUsedSatellite = new Paint(); + notUsedSatellite.setStyle(Paint.Style.FILL_AND_STROKE); + notUsedSatellite.setColor(Color.RED); + notUsedSatellite.setAntiAlias(true); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (!changed) { + return; + } + xCenterPx = getWidth() >> 1; + yCenterPx = getHeight() >> 1; + outerRadiusPx = (getHeight() / 2) - 10; + middleRadiusPx = outerRadiusPx * 2 / 3; + innerRadiusPx = outerRadiusPx / 3; + positionScalePx = outerRadiusPx / 90; + usedSatelliteRadiusScalePx = outerRadiusPx / 280; + notUsedSatelliteRadiusPx = outerRadiusPx / 60; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + canvas.drawCircle(xCenterPx, yCenterPx, outerRadiusPx, grid); + canvas.drawCircle(xCenterPx, yCenterPx, middleRadiusPx, grid); + canvas.drawCircle(xCenterPx, yCenterPx, innerRadiusPx, grid); + canvas.drawLine(xCenterPx, yCenterPx - innerRadiusPx, xCenterPx, yCenterPx - outerRadiusPx, grid); + canvas.drawLine(xCenterPx, yCenterPx + innerRadiusPx, xCenterPx, yCenterPx + outerRadiusPx, grid); + canvas.drawLine(xCenterPx - innerRadiusPx, yCenterPx, xCenterPx - outerRadiusPx, yCenterPx, grid); + canvas.drawLine(xCenterPx + innerRadiusPx, yCenterPx, xCenterPx + outerRadiusPx, yCenterPx, grid); + + if (gpsStatus != null) { + int satellitesN = 0; + int satellitesUsed = 0; + Iterable satellites = gpsStatus.getSatellites(); + for (GpsSatellite satellite : satellites) { + satellitesN++; + float radian = (-satellite.getAzimuth() + 90) * (float) Math.PI / 180; + float fromCenterPx = (-satellite.getElevation() + 90) * positionScalePx; + float xPx = xCenterPx + (float) Math.cos(radian) * fromCenterPx; + float yPx = yCenterPx - (float) Math.sin(radian) * fromCenterPx; + if (satellite.usedInFix()) { + satellitesUsed++; + canvas.drawCircle(xPx, yPx, satellite.getSnr() * usedSatelliteRadiusScalePx, usedSatellite); + } else { + canvas.drawCircle(xPx, yPx, notUsedSatelliteRadiusPx, notUsedSatellite); + } + } + String text = String.format(getString(R.string.satellitesText), satellitesN, satellitesUsed); + ((TextView) getView().findViewById(R.id.satellites)).setText(text); + } + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/DialogTracks.java b/src/app/src/main/java/space/aqoleg/cat/DialogTracks.java new file mode 100644 index 0000000..bfaf04f --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/DialogTracks.java @@ -0,0 +1,76 @@ +/* +list with all tracks to select + */ +package space.aqoleg.cat; + +import android.app.DialogFragment; +import android.content.Context; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +public class DialogTracks extends DialogFragment implements AdapterView.OnItemClickListener { + private static final String argumentCurrentTrackN = "trackN"; + + private int currentTrackN; + + static DialogTracks newInstance(int currentTrackN) { + Bundle args = new Bundle(); + args.putInt(argumentCurrentTrackN, currentTrackN); + DialogTracks dialog = new DialogTracks(); + dialog.setArguments(args); + dialog.setStyle(DialogFragment.STYLE_NO_TITLE, 0); + return dialog; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.dialog_list, container, false); + ((TextView) view.findViewById(R.id.title)).setText(getString(R.string.tracks)); + currentTrackN = getArguments().getInt(argumentCurrentTrackN); + + ListView listView = view.findViewById(R.id.list); + listView.setAdapter(new Adapter(getActivity().getApplicationContext())); + listView.setSelection(currentTrackN); + listView.setOnItemClickListener(this); + return view; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + SavedTracks.getInstance().loadTrack( + position == currentTrackN ? -1 : position, + true, + (MainActivity) getActivity() + ); + dismiss(); + } + + private class Adapter extends ArrayAdapter { + Adapter(Context context) { + super( + context, + R.layout.list_item, + R.id.text, + SavedTracks.getInstance().getList() + ); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = super.getView(position, convertView, parent); + if (position == currentTrackN) { + view.setBackgroundColor(Color.GRAY); + } else { + view.setBackgroundColor(Color.TRANSPARENT); + } + return view; + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/Downloader.java b/src/app/src/main/java/space/aqoleg/cat/Downloader.java new file mode 100644 index 0000000..5aad253 --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/Downloader.java @@ -0,0 +1,94 @@ +/* +asynchronous tile downloader +downloads tile, saves it into memory and invokes callback with bitmap + */ +package space.aqoleg.cat; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.AsyncTask; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; + +class Downloader extends AsyncTask { + private final int mapN; + private final int z; + private final int y; + private final int x; + private final Callback callback; + private Bitmap bitmap; + + Downloader(int mapN, int z, int y, int x, Callback callback) { + this.callback = callback; + this.mapN = mapN; + this.z = z; + this.y = y; + this.x = x; + } + + @Override + protected Boolean doInBackground(Void... voids) { + Maps maps = Maps.getInstance(); + HttpURLConnection connection = null; + try { + URL url = new URL(maps.getUrl(mapN, z, y, x)); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(7000); + connection.setReadTimeout(15000); + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + return false; + } + String contentType = connection.getContentType(); // image/png, image/jpeg + DataInputStream inputStream = new DataInputStream(connection.getInputStream()); + + ByteArrayOutputStream byteArray = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + do { + length = inputStream.read(buffer); + if (length > 0) { + byteArray.write(buffer, 0, length); + } else { + break; + } + } while (true); + inputStream.close(); + byte[] content = byteArray.toByteArray(); + + bitmap = BitmapFactory.decodeByteArray(content, 0, content.length); + if (bitmap == null) { + return false; + } + String extension = '.' + contentType.substring(contentType.lastIndexOf('/') + 1); + FileOutputStream outputStream = new FileOutputStream(maps.createTile(mapN, z, y, x, extension)); + outputStream.write(content); + outputStream.close(); + return true; + } catch (Exception exception) { + if (exception instanceof IOException) { + return false; + } + Data.getInstance().writeLog("can not download tile: " + exception.toString()); + return false; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Override + protected void onPostExecute(Boolean result) { + super.onPostExecute(result); + callback.onDownloadFinish(mapN, z, y, x, result ? bitmap : null); + } + + interface Callback { + void onDownloadFinish(int mapN, int z, int y, int x, Bitmap bitmap); + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/MainActivity.java b/src/app/src/main/java/space/aqoleg/cat/MainActivity.java new file mode 100644 index 0000000..ef83bd2 --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/MainActivity.java @@ -0,0 +1,337 @@ +/* +one single activity, handles state of the app, permissions, buttons and starts service + */ +package space.aqoleg.cat; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +public class MainActivity extends Activity implements View.OnClickListener, LocationListener, SavedTracks.Callback { + private static final String preferences = "preferences"; + private static final String preferencesMapDirectoryName = "currentMapDirectoryName"; + private static final String preferencesZ = "zoomStartsFromZero"; + private static final String preferencesLocationLongitude = "lastLocationLongitude"; + private static final String preferencesLocationLatitude = "lastLocationLatitude"; + + private static final String stateSavedTrackName = "trackName"; + private static final String stateCenterLongitude = "centerLongitude"; + private static final String stateCenterLatitude = "centerLatitude"; + private static final String stateHasSelection = "hasSelection"; + private static final String stateSelectionLongitude = "selectionLongitude"; + private static final String stateSelectionLatitude = "selectionLatitude"; + + private boolean hasPermissions; + private LocationManager locationManager; // null if listener has removed + private MapView mapView; // null if listener has removed + private int mapN; + private int z; + private int trackN; + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + finish(); + return; + } + } + recreate(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + checkPermissions(); + if (!hasPermissions) { + return; + } + // map and zoom from preferences + SharedPreferences sharedPreferences = getSharedPreferences(preferences, MODE_PRIVATE); + String mapDirectoryName = sharedPreferences.getString(preferencesMapDirectoryName, ""); + z = sharedPreferences.getInt(preferencesZ, 5); + // selected track and point and screen position + String trackName = ""; + float centerLongitude, centerLatitude; + boolean hasSelection = false; + float selectedLongitude = 0; + float selectedLatitude = 0; + if (savedInstanceState != null) { + trackName = savedInstanceState.getString(stateSavedTrackName); + centerLongitude = savedInstanceState.getFloat(stateCenterLongitude); + centerLatitude = savedInstanceState.getFloat(stateCenterLatitude); + hasSelection = savedInstanceState.getBoolean(stateHasSelection); + selectedLongitude = savedInstanceState.getFloat(stateSelectionLongitude); + selectedLatitude = savedInstanceState.getFloat(stateSelectionLatitude); + } else { + float[] intentData = getDataFromIntent(); + if (intentData != null) { + centerLongitude = intentData[0]; + centerLatitude = intentData[1]; + hasSelection = true; + selectedLongitude = centerLongitude; + selectedLatitude = centerLatitude; + } else { + Location location = CurrentTrack.getInstance().getLastLocation(); + if (location != null) { + centerLongitude = (float) location.getLongitude(); + centerLatitude = (float) location.getLatitude(); + } else { + centerLongitude = sharedPreferences.getFloat(preferencesLocationLongitude, 0); + centerLatitude = sharedPreferences.getFloat(preferencesLocationLatitude, 0); + } + } + } + // load data + mapN = Maps.getInstance().loadMaps(mapDirectoryName); + trackN = SavedTracks.getInstance().loadTracks(trackName); + if (trackN != -1) { + SavedTracks.getInstance().loadTrack(trackN, false, this); + } + // set views + setContentView(R.layout.activity_main); + findViewById(R.id.localDistance).setOnClickListener(this); + findViewById(R.id.zPlus).setOnClickListener(this); + findViewById(R.id.zMinus).setOnClickListener(this); + findViewById(R.id.exit).setOnClickListener(this); + findViewById(R.id.satellites).setOnClickListener(this); + findViewById(R.id.selectTrack).setOnClickListener(this); + findViewById(R.id.selectMap).setOnClickListener(this); + findViewById(R.id.center).setOnClickListener(this); + if (CurrentTrack.getInstance().getLocalDistance() != CurrentTrack.getInstance().getTotalDistance()) { + findViewById(R.id.totalDistance).setVisibility(View.VISIBLE); + } + printZoom(); + // set mapView + mapView = new MapView(this); + mapView.set(mapN, z, centerLongitude, centerLatitude, hasSelection, selectedLongitude, selectedLatitude); + ((FrameLayout) findViewById(R.id.mapView)).addView(mapView); + + startService(new Intent(getApplicationContext(), MainService.class)); + } + + @Override + public void onStart() { + super.onStart(); + if (hasPermissions) { + printDistance(); + float locationLongitude, locationLatitude; + Location location = CurrentTrack.getInstance().getLastLocation(); + if (location != null) { + locationLongitude = (float) location.getLongitude(); + locationLatitude = (float) location.getLatitude(); + } else { + SharedPreferences sharedPreferences = getSharedPreferences(preferences, MODE_PRIVATE); + locationLongitude = sharedPreferences.getFloat(preferencesLocationLongitude, 0); + locationLatitude = sharedPreferences.getFloat(preferencesLocationLatitude, 0); + } + mapView.setLocation(locationLongitude, locationLatitude); + locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this); + } + } + + @Override + public void onStop() { + super.onStop(); + if (hasPermissions) { + locationManager.removeUpdates(this); + locationManager = null; + getSharedPreferences(preferences, MODE_PRIVATE) + .edit() + .putString(preferencesMapDirectoryName, Maps.getInstance().getDirectoryName(mapN)) + .putInt(preferencesZ, z) + .putFloat(preferencesLocationLongitude, mapView.getLocationLongitude()) + .putFloat(preferencesLocationLatitude, mapView.getLocationLatitude()) + .apply(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (hasPermissions) { + outState.putString(stateSavedTrackName, SavedTracks.getInstance().getTrackName(trackN)); + outState.putFloat(stateCenterLongitude, mapView.getCenterLongitude()); + outState.putFloat(stateCenterLatitude, mapView.getCenterLatitude()); + outState.putBoolean(stateHasSelection, mapView.hasSelection()); + outState.putFloat(stateSelectionLongitude, mapView.getSelectedLongitude()); + outState.putFloat(stateSelectionLatitude, mapView.getSelectedLatitude()); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (hasPermissions) { + SavedTracks.getInstance().clear(); + mapView = null; + } + } + + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.localDistance: + if (CurrentTrack.getInstance().getLocalDistance() != 0) { + findViewById(R.id.totalDistance).setVisibility(View.VISIBLE); + } + CurrentTrack.getInstance().resetLocalDistance(); + printDistance(); + break; + case R.id.zPlus: + if (z == 18) { + return; + } + z++; + printZoom(); + mapView.changeZoom(z); + break; + case R.id.zMinus: + if (z == 0) { + return; + } + z--; + printZoom(); + mapView.changeZoom(z); + break; + case R.id.exit: + stopService(new Intent(getApplicationContext(), MainService.class)); + finish(); + break; + case R.id.satellites: + DialogSatellites.newInstance().show(getFragmentManager(), null); + break; + case R.id.selectTrack: + DialogTracks.newInstance(trackN).show(getFragmentManager(), null); + break; + case R.id.selectMap: + DialogMaps.newInstance(mapN).show(getFragmentManager(), null); + break; + case R.id.center: + mapView.center(); + break; + } + } + + @Override + public void onLocationChanged(Location location) { + if (locationManager != null) { + printDistance(); + mapView.refreshLocation(location); + } + } + + @Override + public void onStatusChanged(String s, int i, Bundle bundle) { + } + + @Override + public void onProviderEnabled(String s) { + } + + @Override + public void onProviderDisabled(String s) { + } + + @Override + public void onTrackLoad(int trackN, boolean centerMap) { + if (mapView != null) { + this.trackN = trackN; + mapView.refresh(centerMap); + } + } + + void selectMap(int mapN) { + this.mapN = mapN; + mapView.changeMap(mapN); + } + + private float[] getDataFromIntent() { + if (getIntent() == null || getIntent().getData() == null || getIntent().getData().toString() == null) { + return null; + } + try { + // geo:lat,lon or geo:lat,lon?z=zoom or geo:0,0?q=lat,lon(label) + String path = getIntent().getData().toString(); + float latitude; + float longitude; + if (path.contains("0,0?q=")) { + int index = path.indexOf(",", 6); + latitude = Float.parseFloat(path.substring(path.indexOf("=") + 1, index)); + longitude = Float.parseFloat(path.substring(index + 1, path.indexOf("(", index))); + } else { + int index = path.indexOf(","); + latitude = Float.parseFloat(path.substring(4, index)); + if (path.contains("?")) { + longitude = Float.parseFloat(path.substring(index + 1, path.indexOf("?"))); + z = Integer.parseInt(path.substring(path.indexOf("?z=") + 3)) - 1; + if (z < 0) { + z = 0; + } else if (z > 18) { + z = 18; + } + } else { + longitude = Float.parseFloat(path.substring(index + 1)); + } + } + if (latitude < -90) { + latitude = -90; + } else if (latitude > 90) { + latitude = 90; + } + if (longitude < -180) { + longitude = -180; + } else if (longitude > 180) { + longitude = 180; + } + return new float[]{longitude, latitude}; + } catch (Exception exception) { + Data.getInstance().writeLog("can not parse geo: " + exception.toString()); + return null; + } + } + + private void printDistance() { + String format = getString(R.string.distanceKm); + float distance = CurrentTrack.getInstance().getTotalDistance() / 1000; + ((TextView) findViewById(R.id.totalDistance)).setText(String.format(format, distance)); + distance = CurrentTrack.getInstance().getLocalDistance() / 1000; + ((TextView) findViewById(R.id.localDistance)).setText(String.format(format, distance)); + } + + private void printZoom() { + ((TextView) findViewById(R.id.zoom)).setText("z".concat(String.valueOf(z + 1))); + } + + private void checkPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int permissions = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + + checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE); + if (permissions != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + }, + 0 + ); + return; + } + } + hasPermissions = true; + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/MainService.java b/src/app/src/main/java/space/aqoleg/cat/MainService.java new file mode 100644 index 0000000..8eb728b --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/MainService.java @@ -0,0 +1,106 @@ +/* +single foreground service +opens new track and writes filtered track at starts and closes it at destroy +*/ +package space.aqoleg.cat; + +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.graphics.BitmapFactory; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.os.IBinder; + +@SuppressWarnings("deprecation") +public class MainService extends Service implements LocationListener { + private LocationManager locationManager; // null if listeners has removed + private CurrentTrack track; + private long previousLocationTime; + private Location bestLocation; + private float bestLocationAccuracy; + + @Override + public void onCreate() { + super.onCreate(); + locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); + // delay 0 for not to loose the satellites + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this); + + Intent startActivityIntent = new Intent(getApplicationContext(), MainActivity.class); + Notification.Builder builder = new Notification.Builder(getApplicationContext()) + .setSmallIcon(R.drawable.notification) + .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.icon)) + .setContentTitle(getString(R.string.serviceDescription)) + .setContentIntent(PendingIntent.getActivity(getApplicationContext(), 0, startActivityIntent, 0)); + startForeground(1, builder.getNotification()); + + track = CurrentTrack.getInstance(); + track.open(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + locationManager.removeUpdates(this); + locationManager = null; + track.close(); + } + + @Override + public void onLocationChanged(Location location) { + if (locationManager == null) { + return; + } + if (previousLocationTime == 0) { + // first location + previousLocationTime = location.getTime() - 4000; // no delay + bestLocation = location; + bestLocationAccuracy = location.getAccuracy(); + } else { + long timeSincePreviousLocation = location.getTime() - previousLocationTime; + if (timeSincePreviousLocation > 10000) { + // time is out, use the best location + if (bestLocation == null) { + bestLocation = location; + } + track.setNewLocation(bestLocation); + // start searching the next best location + previousLocationTime = bestLocation.getTime(); + bestLocation = null; + } else if (timeSincePreviousLocation > 4000) { + // searching the new best location after delay + if (bestLocation == null || location.getAccuracy() < bestLocationAccuracy) { + // this location is the best + bestLocation = location; + bestLocationAccuracy = location.getAccuracy(); + } + } + } + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + } + + @Override + public void onProviderEnabled(String provider) { + } + + @Override + public void onProviderDisabled(String provider) { + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/MapView.java b/src/app/src/main/java/space/aqoleg/cat/MapView.java new file mode 100644 index 0000000..f57e6a3 --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/MapView.java @@ -0,0 +1,521 @@ +/* +view with the map, handles touch events + */ +package space.aqoleg.cat; + +import android.content.Context; +import android.graphics.*; +import android.location.Location; +import android.view.MotionEvent; +import android.view.View; + +public class MapView extends View implements View.OnTouchListener, BitmapCache.Callback { + private final Path path = new Path(); + private final Paint mainPaint = new Paint(); + private final Paint secondaryPaint = new Paint(); + private final Paint textPaint = new Paint(); + + private final String selectionText; + private BitmapCache bitmapCache; + private CurrentTrack currentTrack; + private SavedTracks savedTrack; + // layout constants + private float xCenterPx; + private float yCenterPx; + private int xRightPx; + private int yBottomPx; + // map parameters + private int mapN; + private int z; + private boolean isEllipsoid; + private int tileSizePx; + private int maxTileN; + private double wholeSizePx; // tileSizePx * tilesN + // center and location coordinates, from 0 to 1, in the current projection + private Location location = new Location(""); + private double xLocation1; + private double yLocation1; + private double xCenter1; + private double yCenter1; + // touch events + private long onTouchTimestampMs; + private float xTouchStartPx; + private float yTouchStartPx; + private double xCenter1TouchStart; + private double yCenter1TouchStart; + // selection + private final Location selectionLocation = new Location(""); + private boolean hasSelection; + private double xSelection1; + private double ySelection1; + + MapView(Context context) { + super(context); + selectionText = context.getString(R.string.selection); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed) { + xCenterPx = getWidth() / 2; + yCenterPx = getHeight() / 2; + xRightPx = getWidth(); + yBottomPx = getHeight(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + drawTiles(canvas); + drawPointers(canvas); + if (z > 4) { + drawCurrentPath(canvas, currentTrack.getPointsNumber()); + } + if (z > 4) { + drawSavedPath(canvas, savedTrack.getPointsNumber()); + } + if (hasSelection) { + drawSelections(canvas); + } + } + + @Override + public boolean onTouch(View view, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + onTouchTimestampMs = System.currentTimeMillis(); + xTouchStartPx = event.getX(); + yTouchStartPx = event.getY(); + xCenter1TouchStart = xCenter1; + yCenter1TouchStart = yCenter1; + break; + case MotionEvent.ACTION_MOVE: + xCenter1 = xCenter1TouchStart + ((xTouchStartPx - event.getX()) / wholeSizePx); + yCenter1 = yCenter1TouchStart + ((yTouchStartPx - event.getY()) / wholeSizePx); + // normalize + if (xCenter1 < 0) { + xCenter1 += 1 + (int) -xCenter1; + } else if (xCenter1 > 1) { + xCenter1 -= (int) xCenter1; + } + if (yCenter1 < 0) { + yCenter1 = 0; + } else if (yCenter1 >= 1) { + yCenter1 = 1; + } + invalidate(); + break; + case MotionEvent.ACTION_UP: + if (System.currentTimeMillis() - onTouchTimestampMs < 300 && + Math.abs(xTouchStartPx - event.getX()) < 10 && Math.abs(yTouchStartPx - event.getY()) < 10) { + onShortTouch(); + } + break; + } + return true; + } + + @Override + public void onBitmapCacheLoad() { + invalidate(); + } + + void set( + int mapN, + int z, + float centerLongitude, + float centerLatitude, + boolean hasSelection, + float selectedLongitude, + float selectedLatitude + ) { + bitmapCache = new BitmapCache(this); + currentTrack = CurrentTrack.getInstance(); + savedTrack = SavedTracks.getInstance(); + + mainPaint.setColor(Color.rgb(0xFA, 0x05, 0x05)); + mainPaint.setStyle(Paint.Style.STROKE); + mainPaint.setStrokeWidth(2); + mainPaint.setAntiAlias(true); + secondaryPaint.setColor(Color.rgb(0xEA, 0x04, 0xBC)); + secondaryPaint.setStyle(Paint.Style.STROKE); + secondaryPaint.setStrokeWidth(2); + secondaryPaint.setAntiAlias(true); + textPaint.setColor(Color.rgb(0xFA, 0x05, 0x05)); + textPaint.setAntiAlias(true); + textPaint.setTextSize(16); + + this.mapN = mapN; + this.z = z; + isEllipsoid = Maps.getInstance().isEllipsoid(mapN); + tileSizePx = Maps.getInstance().getSize(mapN); + maxTileN = (1 << z) - 1; + wholeSizePx = tileSizePx * (1 << z); + + xCenter1 = Projection.getX(centerLongitude); + yCenter1 = Projection.getY(centerLatitude, isEllipsoid); + + this.hasSelection = hasSelection; + if (hasSelection) { + selectionLocation.setLongitude(selectedLongitude); + selectionLocation.setLatitude(selectedLatitude); + xSelection1 = Projection.getX(selectedLongitude); + ySelection1 = Projection.getY(selectedLatitude, isEllipsoid); + } + + setOnTouchListener(this); + } + + void setLocation(float locationLongitude, float locationLatitude) { + location.setLongitude(locationLongitude); + location.setLatitude(locationLatitude); + xLocation1 = Projection.getX(locationLongitude); + yLocation1 = Projection.getY(locationLatitude, isEllipsoid); + invalidate(); + } + + void refreshLocation(Location location) { + if (location.distanceTo(this.location) < 20) { + return; + } + this.location = new Location(location); + xLocation1 = Projection.getX(location.getLongitude()); + yLocation1 = Projection.getY(location.getLatitude(), isEllipsoid); + invalidate(); + } + + void refresh(boolean centerMap) { + if (centerMap && savedTrack.getPointsNumber() > 0) { + xCenter1 = savedTrack.getX(0); + yCenter1 = savedTrack.getY(0, isEllipsoid); + } + invalidate(); + } + + void changeMap(int mapN) { + this.mapN = mapN; + boolean isEllipsoid = Maps.getInstance().isEllipsoid(mapN); + if (this.isEllipsoid != isEllipsoid) { + yLocation1 = Projection.getY(location.getLatitude(), isEllipsoid); + yCenter1 = Projection.getY(Projection.getLatitude(yCenter1, this.isEllipsoid), isEllipsoid); + if (hasSelection) { + ySelection1 = Projection.getY(selectionLocation.getLatitude(), isEllipsoid); + } + this.isEllipsoid = isEllipsoid; + } + tileSizePx = Maps.getInstance().getSize(mapN); + wholeSizePx = tileSizePx * (1 << z); + invalidate(); + } + + void changeZoom(int z) { + this.z = z; + maxTileN = (1 << z) - 1; + wholeSizePx = (1 << z) * tileSizePx; + invalidate(); + } + + void center() { + xCenter1 = xLocation1; + yCenter1 = yLocation1; + invalidate(); + } + + float getLocationLongitude() { + return (float) location.getLongitude(); + } + + float getLocationLatitude() { + return (float) location.getLatitude(); + } + + float getCenterLongitude() { + return (float) Projection.getLongitude(xCenter1); + } + + float getCenterLatitude() { + return (float) Projection.getLatitude(yCenter1, isEllipsoid); + } + + boolean hasSelection() { + return hasSelection; + } + + float getSelectedLongitude() { + return (float) selectionLocation.getLongitude(); + } + + float getSelectedLatitude() { + return (float) selectionLocation.getLatitude(); + } + + private void drawTiles(Canvas canvas) { + // center tile + int xTileN = (int) (xCenter1 * (1 << z)); + int yTileN = (int) (yCenter1 * (1 << z)); + int xLeftPx = (int) (xCenterPx - ((xCenter1 * (1 << z) - xTileN) * tileSizePx)); + int yTopPx = (int) (yCenterPx - ((yCenter1 * (1 << z) - yTileN) * tileSizePx)); + drawTile(canvas, xTileN, yTileN, xLeftPx, yTopPx); + // clockwise from the top + int rounds = (int) (Math.max(xCenterPx, yCenterPx) / tileSizePx) + 1; + int step = 0; + for (int round = 0; round < rounds; round++) { + step += 2; + xTileN--; + yTileN--; + xLeftPx -= tileSizePx; + yTopPx -= tileSizePx; + for (int i = 0; i < step; i++) { + xTileN++; + xLeftPx += tileSizePx; + drawTile(canvas, xTileN, yTileN, xLeftPx, yTopPx); + } + for (int i = 0; i < step; i++) { + yTileN++; + yTopPx += tileSizePx; + drawTile(canvas, xTileN, yTileN, xLeftPx, yTopPx); + } + for (int i = 0; i < step; i++) { + xTileN--; + xLeftPx -= tileSizePx; + drawTile(canvas, xTileN, yTileN, xLeftPx, yTopPx); + } + for (int i = 0; i < step; i++) { + yTileN--; + yTopPx -= tileSizePx; + drawTile(canvas, xTileN, yTileN, xLeftPx, yTopPx); + } + } + } + + private void drawTile(Canvas canvas, int tileX, int tileY, int leftPx, int topPx) { + if (leftPx < -tileSizePx || leftPx > xRightPx || topPx < -tileSizePx || topPx > yBottomPx) { + return; + } + if (tileX < 0) { + tileX += maxTileN + 1; + if (tileX < 0) { + return; + } + } else if (tileX > maxTileN) { + tileX -= maxTileN + 1; + if (tileX > maxTileN) { + return; + } + } + if (tileY < 0 || tileY > maxTileN) { + return; + } + Bitmap bitmap = bitmapCache.getBitmap(mapN, z, tileY, tileX); + if (bitmap != null) { + canvas.drawBitmap(bitmap, leftPx, topPx, null); + } + } + + private void drawPointers(Canvas canvas) { + float yPx = (float) (yCenterPx + ((yLocation1 - yCenter1) * wholeSizePx)); + if (yPx <= 0 || yPx >= yBottomPx) { + return; + } + float xPx = (float) (xCenterPx + ((xLocation1 - xCenter1) * wholeSizePx)); + drawPointer(canvas, xPx, yPx); + drawPointer(canvas, xPx + (float) wholeSizePx, yPx); + drawPointer(canvas, xPx - (float) wholeSizePx, yPx); + } + + private void drawPointer(Canvas canvas, float xPx, float yPx) { + if (xPx < 0 || xPx > xRightPx) { + return; + } + canvas.drawCircle(xPx, yPx, 8, mainPaint); + canvas.drawLine(xPx, yPx + 8, xPx, yPx + 4, mainPaint); + canvas.drawLine(xPx, yPx - 8, xPx, yPx - 4, mainPaint); + canvas.drawLine(xPx + 8, yPx, xPx + 4, yPx, mainPaint); + canvas.drawLine(xPx - 8, yPx, xPx - 4, yPx, mainPaint); + } + + private void drawCurrentPath(Canvas canvas, int pointsN) { + if (pointsN <= 0) { + return; + } + double xDeltaFromCenter1 = currentTrack.getX(0) - xCenter1; + if (xDeltaFromCenter1 > 0.5) { + xDeltaFromCenter1 -= 1; + } else if (xDeltaFromCenter1 < -0.5) { + xDeltaFromCenter1 += 1; + } + float xPx = (float) (xCenterPx + (xDeltaFromCenter1 * wholeSizePx)); + float yPx = (float) (yCenterPx + ((currentTrack.getY(0, isEllipsoid) - yCenter1) * wholeSizePx)); + float previousXPx = xPx; + float previousYPx = yPx; + boolean hasStarted = false; + if (xPx > 0 && xPx < xRightPx && yPx > 0 && yPx < yBottomPx) { + canvas.drawCircle(xPx, yPx, 2, mainPaint); + hasStarted = true; + path.moveTo(xPx, yPx); + } + for (int i = 1; i < pointsN; i++) { + xDeltaFromCenter1 = currentTrack.getX(i) - xCenter1; + if (xDeltaFromCenter1 > 0.5) { + xDeltaFromCenter1 -= 1; + } else if (xDeltaFromCenter1 < -0.5) { + xDeltaFromCenter1 += 1; + } + xPx = (float) (xCenterPx + (xDeltaFromCenter1 * wholeSizePx)); + yPx = (float) (yCenterPx + ((currentTrack.getY(i, isEllipsoid) - yCenter1) * wholeSizePx)); + if (xPx > 0 && xPx < xRightPx && yPx > 0 && yPx < yBottomPx) { + if (!hasStarted) { + hasStarted = true; + path.moveTo(previousXPx, previousYPx); + } + path.lineTo(xPx, yPx); + } else { + if (hasStarted) { + hasStarted = false; + path.lineTo(xPx, yPx); + } + previousXPx = xPx; + previousYPx = yPx; + } + } + canvas.drawPath(path, mainPaint); + path.reset(); + } + + private void drawSavedPath(Canvas canvas, int pointsN) { + if (pointsN <= 0) { + return; + } + double xDeltaFromCenter1 = savedTrack.getX(0) - xCenter1; + if (xDeltaFromCenter1 > 0.5) { + xDeltaFromCenter1 -= 1; + } else if (xDeltaFromCenter1 < -0.5) { + xDeltaFromCenter1 += 1; + } + float xPx = (float) (xCenterPx + (xDeltaFromCenter1 * wholeSizePx)); + float yPx = (float) (yCenterPx + ((savedTrack.getY(0, isEllipsoid) - yCenter1) * wholeSizePx)); + float previousXPx = xPx; + float previousYPx = yPx; + boolean hasStarted = false; + if (xPx > 0 && xPx < xRightPx && yPx > 0 && yPx < yBottomPx) { + canvas.drawCircle(xPx, yPx, 2, secondaryPaint); + hasStarted = true; + path.moveTo(xPx, yPx); + } + for (int i = 1; i < pointsN; i++) { + xDeltaFromCenter1 = savedTrack.getX(i) - xCenter1; + if (xDeltaFromCenter1 > 0.5) { + xDeltaFromCenter1 -= 1; + } else if (xDeltaFromCenter1 < -0.5) { + xDeltaFromCenter1 += 1; + } + xPx = (float) (xCenterPx + (xDeltaFromCenter1 * wholeSizePx)); + yPx = (float) (yCenterPx + ((savedTrack.getY(i, isEllipsoid) - yCenter1) * wholeSizePx)); + if (xPx > 0 && xPx < xRightPx && yPx > 0 && yPx < yBottomPx) { + if (!hasStarted) { + hasStarted = true; + path.moveTo(previousXPx, previousYPx); + } + path.lineTo(xPx, yPx); + } else { + if (hasStarted) { + hasStarted = false; + path.lineTo(xPx, yPx); + } + previousXPx = xPx; + previousYPx = yPx; + } + } + canvas.drawPath(path, secondaryPaint); + path.reset(); + } + + private void drawSelections(Canvas canvas) { + float yPx = (float) (yCenterPx + ((ySelection1 - yCenter1) * wholeSizePx)); + if (yPx <= 0 || yPx >= yBottomPx) { + return; + } + float xPx = (float) (xCenterPx + ((xSelection1 - xCenter1) * wholeSizePx)); + boolean hasText = drawSelection(canvas, xPx, yPx, true); + hasText |= drawSelection(canvas, xPx - (float) wholeSizePx, yPx, !hasText); + drawSelection(canvas, xPx + (float) wholeSizePx, yPx, !hasText); + } + + private boolean drawSelection(Canvas canvas, float xPx, float yPx, boolean withText) { + if (xPx < 0 || xPx > xRightPx) { + return false; + } + canvas.drawCircle(xPx, yPx, 8, mainPaint); + double rad = Math.toRadians(90 - selectionLocation.bearingTo(location)); + canvas.drawLine( + (float) (xPx + Math.cos(rad) * 8), + (float) (yPx - Math.sin(rad) * 8), + (float) (xPx + Math.cos(rad) * 16), + (float) (yPx - Math.sin(rad) * 16), + mainPaint + ); + if (!withText) { + return false; + } + + float distance = location.distanceTo(selectionLocation); + float bearing = location.bearingTo(selectionLocation); + if (bearing < 0) { + bearing += 360; + } + String text = String.format(selectionText, Math.round(bearing), distance / 1000); + float halfTextWidth = textPaint.measureText(text) / 2; + if (xPx < halfTextWidth + 10) { + xPx = 10; + } else if (xPx > xRightPx - halfTextWidth - 10) { + xPx = xRightPx - 2 * halfTextWidth - 10; + } else { + xPx -= halfTextWidth; + } + if (yPx < yCenterPx) { + yPx += 28; + } else { + yPx -= 18; + } + canvas.drawText(text, xPx, yPx, textPaint); + return true; + } + + private void onShortTouch() { + if (hasSelection) { + // if touch current selection, deselect and return + float xSelectionPx = (float) (xCenterPx + ((xSelection1 - xCenter1) * wholeSizePx)); + float ySelectionPx = (float) (yCenterPx + ((ySelection1 - yCenter1) * wholeSizePx)); + if (Math.abs(yTouchStartPx - ySelectionPx) < 16) { + if (Math.abs(xTouchStartPx - xSelectionPx) < 16 || + Math.abs(xTouchStartPx - xSelectionPx - wholeSizePx) < 16 || + Math.abs(xTouchStartPx - xSelectionPx + wholeSizePx) < 16) { + hasSelection = false; + invalidate(); + return; + } + } + } + ySelection1 = yCenter1 + ((yTouchStartPx - yCenterPx) / wholeSizePx); + if (ySelection1 < 0 || ySelection1 > 1) { + // out of map, deselect and return + hasSelection = false; + invalidate(); + return; + } + xSelection1 = xCenter1 + ((xTouchStartPx - xCenterPx) / wholeSizePx); + // normalize + if (xSelection1 < 0) { + xSelection1 += 1 + (int) -xSelection1; + } else if (xSelection1 > 1) { + xSelection1 -= (int) xSelection1; + } + // set new selection + hasSelection = true; + selectionLocation.setLongitude(Projection.getLongitude(xSelection1)); + selectionLocation.setLatitude(Projection.getLatitude(ySelection1, isEllipsoid)); + invalidate(); + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/Maps.java b/src/app/src/main/java/space/aqoleg/cat/Maps.java new file mode 100644 index 0000000..389765c --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/Maps.java @@ -0,0 +1,217 @@ +/* +map properties singleton +creates default maps on the first app launch, parses list of maps properties from json files, +searches current map by its name on the app start, handles tile's parameters: url, size, projection, name and files + +tile's parameters: +z - zoom from 0 to 18, displayed zoom = (z + 1) +x, y - tiles number, tile(0, 0) is the top left tile, from 0 to (2^z - 1) + +files: +/maps/map1/, /maps/map2/, ... , /maps/mapN/ +/maps/map1/properties.txt // optional +/maps/map1/z/y/x.extension // for example /maps/map1/10/4/4.png or /maps/map1/10/4/4.jpeg + +properties.txt json file: +{ + "name": "map name", // optional, if this is not specified, use directory name + "url": "https://example/x=%1$d/y=%2$d/z=%3$d", // optional, if this is not specified, can not download + "size": 256, // optional, if this is not specified, use 256 + "projection": "ellipsoid" // optional, if this is not specified, use spherical +} + */ +package space.aqoleg.cat; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; + +class Maps { + private static final String propertiesFileName = "properties.txt"; + private static final String jsonName = "name"; + private static final String jsonUrl = "url"; + private static final String jsonSize = "size"; + private static final String jsonProjection = "projection"; + private static final String jsonProjectionEllipsoid = "ellipsoid"; + private static final int sizeDefault = 256; + + private static final Maps maps = new Maps(); + + private final ArrayList nameList = new ArrayList<>(); + private String[] pathArray; // absolute path of the map's directory + private String[] urlArray; // can be empty + private int[] sizeArray; + private boolean[] isEllipsoidArray; // if true ellipsoid, else spherical + + private Maps() { + } + + static Maps getInstance() { + return maps; + } + + // returns number of the map with this searchingDirectoryName or 0 + int loadMaps(String searchingDirectoryName) { + String[] list = Data.getInstance().getMaps().list(); + Arrays.sort(list); + int mapsCount = list.length; + if (mapsCount == 0) { + addDefaultMaps(); + return loadMaps(""); + } else { + nameList.clear(); + pathArray = new String[mapsCount]; + urlArray = new String[mapsCount]; + sizeArray = new int[mapsCount]; + isEllipsoidArray = new boolean[mapsCount]; + int mapN = 0; + int i = 0; + for (String string : list) { + if (string.equals(searchingDirectoryName)) { + mapN = i; + } + loadMap(string, i); + i++; + } + return mapN; + } + } + + // for save state and then load it with loadMaps() + String getDirectoryName(int mapN) { + return new File(pathArray[mapN]).getName(); + } + + // for fragment's adapter + ArrayList getList() { + return nameList; + } + + boolean canDownload(int mapN) { + return !urlArray[mapN].isEmpty(); + } + + String getUrl(int mapN, int z, int y, int x) { + return String.format(urlArray[mapN], x, y, z); + } + + int getSize(int mapN) { + return sizeArray[mapN]; + } + + boolean isEllipsoid(int mapN) { + return isEllipsoidArray[mapN]; + } + + // returns .png file, or .jpeg file, or null + Bitmap getTile(int mapN, int z, int y, int x) { + String path = pathArray[mapN] + File.separator + z + File.separator + y + File.separator + x; + Bitmap bitmap = BitmapFactory.decodeFile(path + ".png"); + if (bitmap != null) { + return bitmap; + } + return BitmapFactory.decodeFile(path + ".jpeg"); + } + + // creates all directories if they are not exist + File createTile(int mapN, int z, int y, int x, String extension) { + File yDirectory = new File(pathArray[mapN] + File.separator + z + File.separator + y); + if (!yDirectory.isDirectory() && !yDirectory.mkdirs()) { + Data.getInstance().writeLog("can not create " + yDirectory.getAbsolutePath()); + } + return new File(yDirectory, x + extension); + } + + private void addDefaultMaps() { + addMap( + "osm", + "open street map", + "http://a.tile.openstreetmap.org/%3$d/%1$d/%2$d.png", + false + ); + addMap( + "topo", + "marshruty", + "http://maps.marshruty.ru/ml.ashx?al=1&i=1&x=%1$d&y=%2$d&z=%3$d", + false + ); + addMap( + "yasat", + "yandex satellites", + "http://sat01.maps.yandex.net/tiles?l=sat&x=%1$d&y=%2$d&z=%3$d&g=Gagarin", + true + ); + addMap( + "arctopo", + "arcgis topo map", + "http://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/%3$d/%2$d/%1$d", + false + ); + addMap( + "arcsat", + "arcgis satellite", + "http://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/%3$d/%2$d/%1$d", + false + ); + } + + private void addMap(String directoryName, String mapName, String url, boolean projectionsIsEllipsoid) { + File directory = new File(Data.getInstance().getMaps(), directoryName); + if (!directory.mkdir()) { + Data.getInstance().writeLog("can not create " + directory.getAbsolutePath()); + return; + } + try { + JSONObject json = new JSONObject(); + json.put(jsonName, mapName); + json.put(jsonUrl, url); + if (projectionsIsEllipsoid) { + json.put(jsonProjection, jsonProjectionEllipsoid); + } + byte[] data = json.toString(3).getBytes(); + + FileOutputStream outputStream = new FileOutputStream(new File(directory, propertiesFileName)); + outputStream.write(data); + outputStream.close(); + } catch (Exception e) { + Data.getInstance().writeLog("can not write properties " + directory.getAbsolutePath() + " " + e.toString()); + } + } + + private void loadMap(String directoryName, int mapN) { + pathArray[mapN] = new File(Data.getInstance().getMaps(), directoryName).getAbsolutePath(); + urlArray[mapN] = ""; + String name = ""; + File file = new File(pathArray[mapN], propertiesFileName); + if (file.isFile()) { + try { + FileInputStream inputStream = new FileInputStream(file); + byte[] data = new byte[inputStream.available()]; + int bytesRead = inputStream.read(data); + inputStream.close(); + if (bytesRead != data.length) { + throw new IOException(); + } + + JSONObject json = new JSONObject(new String(data, "UTF-8")); + name = json.optString(jsonName); + urlArray[mapN] = json.optString(jsonUrl); + sizeArray[mapN] = json.optInt(jsonSize); + isEllipsoidArray[mapN] = json.optString(jsonProjection).equals(jsonProjectionEllipsoid); + } catch (Exception e) { + Data.getInstance().writeLog("can not read properties " + e.toString()); + } + } + nameList.add(name.isEmpty() ? directoryName : name); + if (sizeArray[mapN] == 0) { + sizeArray[mapN] = sizeDefault; + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/Projection.java b/src/app/src/main/java/space/aqoleg/cat/Projection.java new file mode 100644 index 0000000..4bd1dbb --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/Projection.java @@ -0,0 +1,86 @@ +/* +math for projections +converts tiles coordinates to longitude-latitude coordinates and back +spherical (epsg3857, epsg4326, web mercator, default) and ellipsoid (epsg3395) +https://pubs.usgs.gov/pp/1395/report.pdf + */ +package space.aqoleg.cat; + +class Projection { + private static final double pi2; // 2*pi + private static final double pi1_2; // pi/2 + private static final double pi1_4; // pi/4 + private static final double e; // ellipsoid eccentricity + private static final double e1_2; // e/2 + + static { + pi2 = Math.PI * 2; + pi1_2 = Math.PI / 2; + pi1_4 = Math.PI / 4; + // [1 - (polarRadius**2 / equatorialRadius**2)]**0.5 + e = Math.sqrt(1 - (Math.pow(6356752.3142, 2) / Math.pow(6378137, 2))); + e1_2 = e / 2; + } + + /* + longitude in decimal degrees from -180 (west) to 180 (east) + x from 0 (longitude -180) to 1 (longitude 180) + use double values, float has not enough precious + this is the same for both projections + xRadian = longitudeRadian + x = (xRadian - x0Radian) / 2pi = (longitude + 180) / 360 + */ + static double getX(double longitude) { + return (longitude + 180) / 360; + } + + static double getLongitude(double x) { + return x * 360 - 180; + } + + /* + latitude in decimal degrees from -85 (south) to 85 (north) + y from 0 (north) to 1 (south), reversed direction + use double values, float has not enough precious + y = (y0Radian - yRadian) / 2pi = (pi - yRadian) / 2pi = 0.5 - yRadian / 2pi + yRadian = y0Radian - y * 2pi = pi - y * 2pi + */ + static double getY(double latitude, boolean ellipsoid) { + // yRadian = ln[ tan(pi/4 + latitudeRadian/2) * k ] + if (!ellipsoid) { + // k = 1 + return 0.5 - Math.log(Math.tan(pi1_4 + Math.toRadians(latitude) / 2)) / pi2; + } else { + // k = [ (1 - e*sin(latitudeRadian)) / (1 + e*sin(latitudeRadian)) ]**(e/2) + double latitudeRadian = Math.toRadians(latitude); + double k = e * Math.sin(latitudeRadian); + k = Math.pow((1 - k) / (1 + k), e1_2); + return 0.5 - Math.log(Math.tan(pi1_4 + latitudeRadian / 2) * k) / pi2; + } + } + + static double getLatitude(double y, boolean ellipsoid) { + if (!ellipsoid) { + // latitudeRadian = arctan[ sinh(yRadian) ] + return Math.toDegrees(Math.atan(Math.sinh(Math.PI - (pi2 * y)))); + } else { + // latitudeRadian = + // pi/2 - 2*arctan[ t * ( (1 - e * sin(latitudeRadian) / (1 + e * sin(latitudeRadian)) )**(e/2) ] + // t = e ** (-yRadian) + double t = Math.exp((pi2 * y) - Math.PI); + // first trial latitude = pi/2 - 2*arctan(t) + double trialLatitudeRadian = pi1_2 - 2 * Math.atan(t); + double latitudeRadian = 0; + // usually 1 - 2 iterations + for (int i = 0; i < 5; i++) { + latitudeRadian = e * Math.sin(trialLatitudeRadian); + latitudeRadian = pi1_2 - 2 * Math.atan(t * Math.pow((1 - latitudeRadian) / (1 + latitudeRadian), e1_2)); + if (Math.abs(trialLatitudeRadian - latitudeRadian) < 0.000000001) { // 1 cm max + break; + } + trialLatitudeRadian = latitudeRadian; + } + return Math.toDegrees(latitudeRadian); + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/space/aqoleg/cat/SavedTracks.java b/src/app/src/main/java/space/aqoleg/cat/SavedTracks.java new file mode 100644 index 0000000..6707407 --- /dev/null +++ b/src/app/src/main/java/space/aqoleg/cat/SavedTracks.java @@ -0,0 +1,189 @@ +/* +saved tracks singleton +creates list of tracks, searches current track by its name on the app start, +asynchronous parses .gpx file and keeps points of the track + */ +package space.aqoleg.cat; + +import android.os.AsyncTask; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; + +class SavedTracks { + private static final SavedTracks savedTracks = new SavedTracks(); + + private final ArrayList nameList = new ArrayList<>(); + private ArrayList xList = new ArrayList<>(); + private ArrayList ySphericalList = new ArrayList<>(); + private ArrayList yEllipsoidList = new ArrayList<>(); + private Loader loader; + + private SavedTracks() { + } + + static SavedTracks getInstance() { + return savedTracks; + } + + // returns number of the track with this searchingTrackName or -1 + int loadTracks(String searchingTrackName) { + clear(); + String currentTrackName = CurrentTrack.getInstance().getName(); + String[] list = Data.getInstance().getTracks().list(); + Arrays.sort(list); + int trackN = -1; + int i = 0; + for (String string : list) { + if (!string.equals(currentTrackName)) { // can be null + if (string.equals(searchingTrackName)) { + trackN = i; + } + nameList.add(string); + i++; + } + } + return trackN; + } + + void clear() { + nameList.clear(); + xList.clear(); + ySphericalList.clear(); + yEllipsoidList.clear(); + if (loader != null) { + loader.cancel(true); + loader = null; + } + } + + String getTrackName(int trackN) { + return trackN == -1 ? "" : nameList.get(trackN); + } + + // for fragment's adapter + ArrayList getList() { + return nameList; + } + + int getPointsNumber() { + return xList.size(); + } + + double getX(int pointN) { + return xList.get(pointN); + } + + double getY(int pointN, boolean isEllipsoid) { + if (isEllipsoid) { + return yEllipsoidList.get(pointN); + } else { + return ySphericalList.get(pointN); + } + } + + void loadTrack(int trackN, boolean centerMap, Callback callback) { + if (loader != null) { + loader.cancel(true); + loader = null; + } + if (trackN == -1) { + xList.clear(); + ySphericalList.clear(); + yEllipsoidList.clear(); + callback.onTrackLoad(-1, false); + } else { + loader = new Loader(trackN, centerMap, callback); + loader.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + interface Callback { + void onTrackLoad(int trackN, boolean centerMap); + } + + class Loader extends AsyncTask { + private final int trackN; + private final boolean centerMap; + private final Callback callback; + private final ArrayList loaderXList = new ArrayList<>(); + private final ArrayList loaderYSphericalList = new ArrayList<>(); + private final ArrayList loaderYEllipsoidList = new ArrayList<>(); + + Loader(int trackN, boolean centerMap, Callback callback) { + this.trackN = trackN; + this.centerMap = centerMap; + this.callback = callback; + } + + @Override + protected Void doInBackground(Void... voids) { + try { + File file = new File(Data.getInstance().getTracks(), nameList.get(trackN)); + BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file))); + String trackPoint = null; // the whole string + String line; + int startIndex, stopIndex; + double longitude, latitude; + do { + line = reader.readLine(); + if (line == null) { + break; + } + if (trackPoint == null) { + if (line.contains("")) { + continue; + } + + startIndex = trackPoint.indexOf("lat"); + startIndex = trackPoint.indexOf('"', startIndex) + 1; + stopIndex = trackPoint.indexOf('"', startIndex); + latitude = Double.valueOf(trackPoint.substring(startIndex, stopIndex)); + + startIndex = trackPoint.indexOf("lon"); + startIndex = trackPoint.indexOf('"', startIndex) + 1; + stopIndex = trackPoint.indexOf('"', startIndex); + longitude = Double.valueOf(trackPoint.substring(startIndex, stopIndex)); + + trackPoint = null; + loaderYSphericalList.add(Projection.getY(latitude, false)); + loaderYEllipsoidList.add(Projection.getY(latitude, true)); + loaderXList.add(Projection.getX(longitude)); + } while (true); + reader.close(); + } catch (Exception exception) { + Data.getInstance().writeLog("can not read track " + exception.toString()); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + loader = null; + if (loaderXList.size() > 0) { + xList = loaderXList; + ySphericalList = loaderYSphericalList; + yEllipsoidList = loaderYEllipsoidList; + callback.onTrackLoad(trackN, centerMap); + } else { + xList.clear(); + ySphericalList.clear(); + yEllipsoidList.clear(); + callback.onTrackLoad(trackN, false); + } + } + } +} \ No newline at end of file diff --git a/src/app/src/main/res/drawable-hdpi/exit.png b/src/app/src/main/res/drawable-hdpi/exit.png new file mode 100644 index 0000000000000000000000000000000000000000..59fc1a7c2c491ac6b3ed0b79feb76a49e28b657b GIT binary patch literal 710 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 Hr-UW|Z(>ev literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-hdpi/notification.png b/src/app/src/main/res/drawable-hdpi/notification.png new file mode 100644 index 0000000000000000000000000000000000000000..047f1d5d7d38d217a133eb630ece7a1b3b3bd03a GIT binary patch literal 768 zcmV+b1ONPqP)re5r0r5f9fj;0pGxP2?XNapss@Y5=m49A3*dCTqPJpvQrl#qAqm& z&SV;9daAnnj?r8T4);#isj72px_f%=)YOF8XsDj5sxA?G9b5*}O`flO?twR>(dhlq z+{JN-xCS18J&?1v5blA+;sT{ak}iN};8#wC;aAKPFkf2F<&oqVSOY(dLgNm7Cty`M z-GV6xfz#l{xK#`I&y_n>yXR}fye>#Sl=7-r6hz&oTxY()*-WU}!Y5A3)T#XKgQPj* z*-soBU`B2ANR4j>|3jb{&sKZ4r#(QFR+Be@Kj6C=@y$XJXBpB*lUP-n=yR?poLbtJ zNlb4)v4|Rr3ox__&q-zl(R{(=F6 zlINkAPwrd5d$=yd{Q8;zy&YJlqr*}hWDzxH@N|;+uLF3>)J1RkDo#GH(;@FuzLY(vA)W$yRry=?QZ=&OX%h(0i(;-*Lj)q{RN&tMcY(d> yIYkDyk3tv78UC<3-Q@qmXZXWv->Lb34gUrv68SwBWxE~#00004%P)1pw6o&s484qp97-)|mf=wq~AV+D@T*%%%kXf`x$pt(WiQpr&kTEnFL=5RvD8))G zp&}ICH~K06;~Q+fNB}_)Su={5H36_3xOM?x7o%xRfm;CYLWoUSmdCyY*4m0uo3K38aPuQbPi%$zH%UN=yJ;duC1l zY3(-$^bCM5fhB;4fF?tE3;4||fXCktox_|EVxyE=`*z`+@6Xk=)+^skDdoIrn%Z^5KH)`AV#y8Vi zTexbjQ;8P@Ze&{P)gOQApF(osxsfdZJbN~nV)zbw4`Az=f9QMY64)Wq!IOh6BAp9m z{}PixYDge8B#;^sNKN(v(Qjy^l=E{}OtJfAf5}G>MCN<}w~AHfCU2-&00000NkvXX Hu0mjfkyNcz literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-hdpi/pointer.png b/src/app/src/main/res/drawable-hdpi/pointer.png new file mode 100644 index 0000000000000000000000000000000000000000..e247d15ad6192889eba8e68ee938f6bee369a3a8 GIT binary patch literal 2166 zcmV-+2#NQJP)8u*@PlwjFqrbHD z7npHcx06A>0@A=JXn57g)tAR`EX#>J{6I0+0?s2jETre*l;R@FxI|0Ne$@ys8Ob1pzgH zgAoxC6)ekcC4_imV`HZ^8qJ(isq_H=!!Y#p^z>^pGc)lV$3@aKeb49fwF0mJc=n$n zpq9yGKL+q&R8$m`mzVcxK|#Ue{rmU-H8L_%kW%n^z0~E)m)BXXR&8Hj-&=EYb4mc` zd7eLu6%tt~fl@+!*Sd(0GGmb zBwPZKgpgkXI9O9t)8E+GcrVy$hYGLPOBs#EbhFvK3qUK!aYq1nL)8z5fRd)^UZqm` zc6)ofb>F^y(_wqN1bTaWH5C;VMQ*qIAsBqobnw@?U_0w`^7Z?^_I&oB(h%gf6xFE6heA0NLBAW0B} z_F#qtL1+V@8y_FPQ&v`1wP(+s-3-H!KoTu2EmlDgDghL~ECDqk#8gvL(;v9!r>3UX zq@|^m^!N9#7X%@b||VAoV&5H@t#=DFbpvm48@a^lZ?;jO9yZ#1jhq$o8!3j$;rv*27{r{ z?RGEn#bh$wjgF4?%4D)1u9Scpz=tPJoVXPjDhmq>zdbQAL9;CT9RM#AjT|h?=1xpZ ztSKrg+9e*4%jJA+ZS63CqgdiCEER>0tX;eI)YGR=e<4m%ZEbBEDl03$CkTQ8!0#mV zE@(8Gg5>1nNwLsww>~guB6AL0E zBZZilm}!T@p$~_^hWPmS*+5}nd9Z-&?Ckgb1VTn~a`NPj8#k_o@@#y3{H(*_cvEUD zSQw2`sZ?xO9({#SsZ>7v>+5b2!Yktc3lWUK9K$dX|5i_yfZvxnsj*;Z;Lp?3)2{^z zjYi{EsY=ssw;xk)HZwC5@AY~qaU}A1 zJoMn;V6wle6yp=T2&185$l-7x@ZeJvRla4*mcNN*9*;-nbUHNv#=@}wB9UFXbm^lY zi7Y7`V)sFpL~av|E-U@C?BD zhK7a=uh+Y%YPDLe{TnxK{DP+GzF;ImL;_9IeVaFLp6}}Fx*;C$csw$b$+QE22}?A+ zmP}+k&!2TVookFnW4c(bQmMF`H*a<)CMJH35ONnlSf#EXnS_x0n>KBd4-5=+D-;S| zJb3iz(RW=g7tQnh&z3T>Tqb_TaojPp*}SW_w|CJ>uV26Zi; zBP#KLLZRTVUAtzpSS(Fit@d32V-!V|2Q%a!vum~5bc@B(boJ`h-v+My!-o%}s;a7r zD2i$V(7l{<;WW0csnzOao6Xjdnwl!v*cu)lRyQ{{C&}e5K2f+8L2mmiyi&qp1#XBu6E!O<}{P61MWzf^plTck;%a9FL@_1)dwTAR)G4TfRlJkK`*_&Jt|HeUsS1-uTR z#DB$8qMs&R~^GJw8P=#`I2u8z&(Vq$5*k6Rs5ghAD{i2#jCPPwEzGB07*qoM6N<$f*`^tQUCw| literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-hdpi/satellites.png b/src/app/src/main/res/drawable-hdpi/satellites.png new file mode 100644 index 0000000000000000000000000000000000000000..c62fe11a254a6d701db30770ea51687c47383529 GIT binary patch literal 1169 zcmV;C1aA9@P)9rFjIiuQF(#YzL#S10F^FC8;}3{tqQkb>f>xIchi=}lp4H1&GS6wwtw2H=>%;|-hB~_D5#uzu$2XZs>&YR!E zoO32816*;%75`iM(x0-VkBE+l=m=#f(aUk%(a_M)*Q2AOUvM0E1fc)2Ln-BPc*fWm zV{ELPBu<5Y5+rzqb-f(N%?%C?e(P{Jem!~8WHN1~QmNR=%8HFK_6LAdH5Y{vft=xG zE=wYxfTHj+mm!f$prG(FrIw&9$NPwA#%8lEOiWCyscGEU*!V^u5YRKm>Huu3kC>Z^ zi)bo}q7V!QpJj}7-~z(~*@w^?fOW6eJG{TY|F<9r8%i5?cXzkD-R_%`Bnbe1EK158 z&+`6ilVT%xY)uNTO*?PwOVakQ&ZE^)z#It2L}hY5{blzJkJ{#WA_0hi)x{OJ|a3A z9v=ScL+z|bq8lP2!%q|TU%S-+S}XvJef@1Id^&}|0w~SF`VS^ zsZ^?;i0({HO?}zk-u_p%23oB)X)qWz=jZ1K060;EIZ?I8sei8>sI2LKQeE8zOf+BPBzL*>p+tpM=fC+vDS95yJuFcOIv00aTF@jQRe zXf#HNXa*`I&Lfb?LM#^3yIij4^A%1{PuIlb@f!d(d7i%~2*Lxu-~XA-W?N7rQOGKk zi(Q`Q^~=l4uUoBFzgDX~^Fb8~g|1tzR_o#6;X|J1-xUPmf!pm~0synwypc#GcD!Ei z5P%4dSG|%6fH8JInM}SiJ3HH<*Xy^Mo11qs&pVyYnx38>>-P5cQ$$p4u~>eQ!^^0V zP_vBY;u^tE#Gs*4Ni>Fvchx4jba}xDLmYzNNFXbM<8SOh!gVUUoPfuQJAN z1DGqNfXs6MZUJaWe@6hoCdYAiEf&iIx7+=Fz6MGqiZ+B!unXX40N(*v1OT4rzYzq% z@9}u@h6j#^Q+j7NoNH%yQ39E?aUA!9#bSA=G`x(-WZFv4i4TxvP84ey&4z}E78@EG z9w`|%YPje-kO1&|G#Y)o(48Fs*zNYl$;ruX#@GOWPtU3;#SroXSoisSwn8NAc6;Oa z`1os*Bn<$#o2{-C0ziQhO2Y%C695!S6cYZj=p~|mp0q!S3+0qVIfa*bEK!c(Wy<F<;8;PTc!;)63hlCmp5{-7b*aoTO{YnEAMoOP{NC>?zH#OWP!vT`|1*oh^{)en zEveQnfG6W`GD|A1C+2iIukk#;w@8t8yS>rt^#BtBfam$WTCG-Iq=p~}o1*}~ur5;g zUqAzx&G0=iNjjarAd2Gptn=IL_67iH(wHd$L{VI?*Xt*6bZMN0q&L@;03*8q?k-wn z<@W#$U^W`SY&3w`XaKX(0A^eH0Q20;`2k!3a1Ou_z#9P10UR38*UV-KaGGJ5f}$u_ z-EQ{>j^hpng8|d+c5e=c!-uM>W&qp=pc&ljM86AgmJlLx95*Nw3J>D(`2O*Ls;bCl zv*)+AwsNv8`xQmGV{oq%O$%VL*=$d}UhiSE+1z1S_NPzSYPBMl%e@PS!~JA3c?-Zv z0Q+M#VmSJ`8D?65%bKQLEEbE~_r7Z*sRy<5{!HzBZT+^fk1!J1&lWBcfgdZ%Di*~$gPl=lRhlp2@Uxo_S3>Ubip|^UCY>o{}VK$8NW)lO~nR z<+W5Qbz7F@5`cp_)}DA=zHDa*A)h=RPq$L3Y-^g9J$@;Ql1-=6MV4j15<(ss-N+)G z48xQx7K`d~x!y;kQ7IS%>4mMtl%CfAdiXyf6Eh1V?r_-zXe14IL?EMt~gDj;ymr|aW z6sg6xiD>2f{^gpy{(RrRGze#D{#I5&N-0&!j^n%x27^1xvL-nuY$CcD4u|)SGLvo$3{(L4H$(zr_T$=wyGwK-vb6i zW*NuvNnTCpw6oY{j6J5GR6>XgB3i+$x9jpZMXHd$fDa&f-zDVA&+;1lcab{eFW~>< a`|$?nV8ce*e4twZ0000sAJi!S=9+ zA{ivoGC|zK3IO1!(#tlQWg`h^R* zzw^E4eC|2-_d6%RbN$DXJmYiK0JH!q0HguT0SEx_|C<0Bnx@|e(4|afq?<-<;$0kvn+cuA4EO?8AVYKdV71PvV%`gPg4T}1N!mt z@$%W(Srti=7#$rQHW&=-P7qeB^$5ptjSyK5Q2?5z?QLys`o6wC4*)O>Q(RM1)5fxF zUm}sX4`7lY2opt7U$(cmdkKO_1Ay6Vo;8_FQDJ`_9UVtqF4xZ-$9-4`fM);x{bTd< z^MlznZ*Fcraqr%}QI6w!1m0QzCo{kI>h=1znVFe8!VYD%0m#<;7g60R%>2$H&Kx1R$GJqol zLA+WB0mCpIH*elt((Cmt!nUfaswg(g_)`%A&q0kwvn5reQt8IT#KhN4O-+tiEasC) zBpd)FlgSZ|;|4_-*mOD_tE;P96gE^;RKx+Ow*wH>;lqbHpU-#8?RKj+Ha4)nzAm|V z@!}@{To8Pid-m+HR99D@7#9z{33eyj096l}a~ST3Vh~GrQfs^ytwe6M!Fb zY$mx}{@%lf4{sF}{rUTzo12qDkcL8+-|zSDErh(awe?q;rq2q(r40=Y!P3%F(Twr? z{c-@mYzy$@$rD*564`NSd_G@kJRYAGguPy`S0UyPhr_#;mX>y7^W2aZ008bfolgBu z0HIKbOeT|&+!#&MUoeqAn?`qHIK@>nd^ znvIo|luWZMJ0$eRFn$@<{KOU&Ye3KOsCUF0qn|z zO#Alj6J2|Y#bQh(65j|12SoM)0N{E4fzRi&9yo9yVKf?JVjhV^0=L_(SXfwiv81Hr zHIgK|Z{NQCU1eqEmO9ek->s=>2zxV;P?Awfk2=v6bi`zxLKC{ y9NQd3pA{fiRhg?Ib3cd8KP7>D4?NfZR(}B8#P{#+26NW{0000 zDQ@x=6?b|QS*03c%qe$wa>9e|e^1mqc$+HowkNseFbWD*?)EqLXL$DDjOc+n-sJBV z&*z96XA7QG30^DupX)d8OAZ4@>^&;%n-@x0U7@p^YFKDRi_E0 z%(;$F278&%8Y#X*PP96DQw_+}fL+ksT*R{)q&aD#Ll%B5Z^Gcv2>Q+^1_qGHI)VJf@R;E7yjoI$&tI0!guKp$qm^H6QB9N Za6bhEl!!FFsC+V(6hkkIl8gI(!9jo6=nyI;NYKVX$v&QF8#(=9{G~K z{t7@MGOK`|9e}-Ktp#vd^0G~Uz`iJoBV%m%ED}+swZ=aLz!)2DPRp56Cp(JsO10-u0C5>je^J)GIQXZUpePj-!rzb ziarBLU^D|nlu0SQO+tv?-0!Aowo0ko*GELzlt8Psp1Rda0J$lkl*;S6z5(dG_K#!e z6u4mCeeODH9e~q}{x{KQAPFRa=mJ4s#kR1rh0GkH|Cl7Aa|c}vR8l0*_iF$E002ov JPDHLkV1iLah6n%v literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-mdpi/pointer.png b/src/app/src/main/res/drawable-mdpi/pointer.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfdbb287002acec87e5feaab8d18b50e8a1be49 GIT binary patch literal 1477 zcmV;$1v>hPP)iP?GNf*Lh#{x}%ARcftZMHIyTk*zFjb|1vRpc!=T0tt0=8;r1aY#;ov zktkKlqzF~q3N18cwM?AUx{{?DR|;$Qr(GHoT}XpHFK3d|Hv3Rdt}!~>Zfqa=Kt8;` z_x-)k^S-&?bH4+g<3EP%Df3DJ7yxVokOmL}5CjnTHwA2^DC&IxuND;*@dkr|H5d#@ z00;(yxxrvi9f?Fp02euq`vSo1KP5m#QPk%Oh2ntSZl88I9KWm8>g8+;=jZ2hy1Kev zI&tDebt;uQzp$|IF@WT=2+)xv>9JTWv)$d@(+Y(my$Qh(1VLuA*{X(yhDvyzHv^bk zZ!NJAIYm+5cXf4Lb2^>B5CpNhd4GSuF%$~rmX($90O0X>iY6u|3Mwip76^g>K@c#T z&2z=Y#i_BeF{dC1X92{sk!)FqTt$+k$L)6iaOlvX88LYD=+O$TR%>AxX2Zk<1JHDQy1&&>-USno}QlR+}zwZ0q8Ol);t3gMIAqKxOD4y*`R z7d16CLATpIm2FP1*Qb#2AZAuU`r*Te8xlMVR`!0(O*3y`E*uW88ivQ?DUuqF&*wAz z9f{B9GdLWM)$yJ^dqUzZhQnbcGHf|hKp+qZ(BdE(jpp|D_Ue|FmZZjWWdr^zUaxoc zZ&D}}@9o^VGb-6!1KNMN$0#=qx!RG!#XhE-x=XI6gkUYPk%XZQh^78T?Oixb_GYsz zICJJq5zq6VWaHYXBPyXNYRu(wUA0=RtIj0|f^1-5KwnT$kP^$wsgb@lmLK0AfO5cgX(BBssivW$8ld`gGTge1xR6B`H40i!lRFp f;Cczq@xR4iyr)~@J;l?V00000NkvXXu0mjfqxQos literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-mdpi/satellites.png b/src/app/src/main/res/drawable-mdpi/satellites.png new file mode 100644 index 0000000000000000000000000000000000000000..4af001968e8a91c6832bea7bd787d50387951e1e GIT binary patch literal 780 zcmV+n1M~ceP)TA(P;EBrE~@W zJ*j}Lw1F|kstwXKt#xW@>bl)-zXu?5QaDh434A=yzm7yAvBAMX%PeS`*6R2B@94Td zoy+C60Wbh-0MvR30Q*a^Aj|U7K?zOMTKoF?q;xvH+tJa{B8s9404pmiUc)dpc6N5| z0?3|3q>@A`7K=S+jD=KHed>0*lXkoPy(CF7O6j5M2nK_1d7gh(FZdyexw*NQZnrzh zaol~6$Fo%mXN-j?r6G^U^8tXj7Jy|FmLzGK=E&sa)?=ZM4M_=01c9mf#>{+8O>>gsCQDbDBf zP!y$$V{PKhFgA(K8;{n4kZk9@%MgYM6h7$xKTnS!HqSifN;WNfw zgu~%?N5P9Jcg~cahP8%LN*5D}#G~Hc-tRp9o~ZdN*I{IdXYYls~iBtSZJFf4uwV}b)C1MxE$2$O@O1BnZ5YDkzgMpEKX z7)%-#*a_8`&{&c>2w1R4u|<|XTkn1E-Y1v$t_S$|tQf-Od>TMv!8s2AUPd45E(H?N zX!M+A*^ZNBS(byfS`A(bfMwZEKA(SflAFn7FccWG=2pNhu6MvnrBd0JBq=m2F3WOo zsREKDg$jj&hOb7O#YD_g0ZV}^r^zet0bjru@CAOm2M7Q$0AT=a05X7&{}NzGlDvsT zB0ZX>Ut6sfsVK^>VHjt+uHP?PV4tF>TTv9Hcs$-44K^ALlH<5*UDq!G7{lR3-vaXm z0)fCqHk++mivs||Fz=;O=}r)YW9xV-m1=t`V47wuolc8($vn?@d7kgODLBoKQ$P@e z>1Ba=*!93uu~;Ba^hHJYa1R8`$*Hk&*Be*bhZ7(6;%+>O8}c7*SXsE#FEL_RC$ Y6Y*|_eiHUo8UO$Q07*qoM6N<$f=n#QhyVZp literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-xhdpi/exit.png b/src/app/src/main/res/drawable-xhdpi/exit.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a77dec32319144bf328af09b3d99b4add04956 GIT binary patch literal 812 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 Bi-`aL literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-xhdpi/minus.png b/src/app/src/main/res/drawable-xhdpi/minus.png new file mode 100644 index 0000000000000000000000000000000000000000..561a1d97e18ee19d50bab8735ce18abaae93bcf2 GIT binary patch literal 235 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=n><|{Ln`LHy}pt2uz^79!|4LK z3IR(QV^S7hk<%(I`@~>2gE@bJn(Hf%-bKbw{@=1b_&8LZ6Q~gk9{9&deV)D~(`ABu zl#1;>-u&LR$Zu~PTXx&GXk1!iS~ XdE04)(U<^C}4-18Y+G}HoE`Z(O7We_O+II*y!7h-?Kt_)Q1^dDC ztcnBi&+zS!7Eo;IY+hD>j4 z|I&(6dz+BynNK}7Nl_uPe7+8-9d|ddeiMD(9FVm!3)z5|oRfp%`eBD`$XNinok^>ofc35p zEUO=VrjoMOz;JYz{l))YQ`vQP0oNWo&FVj z<(h#PhWbve`ITmHOm}6z;(KMYLDcQuj3sB_zCk)}`15q1Fp`a?{JanwfUf&|rZeo{ z*vu`ZPG~dt4S#ww$JA*Rx|8bJp9YCP74YZfQjH~nWVg{e<@D1tU9^Twhm9^Lx!4#Y z%V%)65n>%^dc8Ry>qz<75b+*=t1pSc^>zT>DqtNcpGu>z_Dnkg`z;apbpUssi!H9J zIGk4S(&FOZ zh|_9w30Tj(QfgIN>lN&BKsaKFN%&Pj9f`SG>?H1DQB-V+ewRkGjqGP*E6icsX;Y{# z08v_%bj4jOyX@1JsU7tvYtVyy@1W>_me(v-;??$9i^t(iBODt{;x;f+t+EK-FfnUl z^;8{4k-b1~#k}kZiQ-;TRjaNbQcyqeJ}?Ay-IvwGxdD1XczX~KT{SV869L-Z>bkE7 z=+a4dOe;X;zZ&QyrNhW5c;wwx;{0#_$_V@hd)lqCi!Sh=00000NkvXXu0mjf2ubNI literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-xhdpi/plus.png b/src/app/src/main/res/drawable-xhdpi/plus.png new file mode 100644 index 0000000000000000000000000000000000000000..4d490be5988077a0eee2f47a9fd4a9704f9fbeb1 GIT binary patch literal 436 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 UJ*aH51^@s607*qoM6N<$f-2dKs{jB1 literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-xhdpi/satellites.png b/src/app/src/main/res/drawable-xhdpi/satellites.png new file mode 100644 index 0000000000000000000000000000000000000000..1a47ec9b3c8f5c396236d6070870c1d2f08871d7 GIT binary patch literal 1456 zcmV;h1yA~kP)Yuf<&5MFE$@*|nJKW~L=n$OSgLXn!Bnx}#2EGW}d=o|u zbVUbgHk9E;Mdnm26VoV-58Aqc6SmNH!(fS1NV-Pp$i4abaC2r%x8(k7d$;5Rc}ed* z_k6$ko!|MLb5DT=4H`6P(7-7?rB7Yr0dNw)Cjg}7?aI>Q0dN8Ump&&@N0D$#yrL)< z6h*m!Ix<0oJu8ZGp-?Cs(KPMw=FOYOp__0|4~GTfnfWj?AHJJRAdB~T0K@?7Q55B3 zB9S;J%QBZ=P%IX)Wy_Y}=;-Lv08RsV9e`%9yUt_9fSTe3cY_IL4KOTT;C2(t7+_kw zz|AHYHNdiXfm=;5Vt{S&0ymma%K#66ApmdM6)#X#mAbmRUQQ;HPeGqXdjJ&ev}uQG zm73$?0YH{z-nnz+rBTLC<1w@o`Nd8s-uFtFj^!GpVqXoi`)0L(0PAIhmU1PH(f z0Nx4&0++_d#y)FmYO)*!0|Nsa`uh5IYnpbwB!1FrTTWpl2|8ke8^r_Wra{a3phEmE z0FMKB7QiE=OVR+o2XGd^b>rQ)kJ&=VvPR`~B15aQKJ4d-q;lwQ5xn0P^{~6pO`L z2L}h0R4Ua*L_aa}O91|`D$FQW7eh5osQBfHh*pO}p^?GC!SQO1heDyi@#Du|05HMK z&tZ`x!OA+F0ek^qeL9`q9*@Vp-QC?OkLSM`wPMVV0ob^2-@dPFi3fmSFgTORWah`m z$Gf1L9kN)|ua_G>ZFFQLsvt}CCH0|$^k&%r6QUHFo(a);bi2%W^NPu46 z&Xmpbr({`vb#`|4J(2K?lF#QQpU?NUs;XxIyx~AUZ;KP9a1r`~=46F<0C;H4nl(4f ziU)w^=4Q?7_1*yRaK)cm;9v>wR6|7oJInsA07ehrX zT-eOya=F#{eBN}f$>;M@E|>E{uMz=We1e%ZrdTZ2YWBuxG}@-B z>Iwkgp(I|Cq_vSq1&rR*+uPeYH8s`B%x5db1Nx>)B9S=f zL?kT58=E8%ZJU^wcqWs{%m;(P->Y5R+uPeYG&Hmgz+Xi4m?TN}mx~v8JRWp+ccY7Zf@29J>f;8(Y9m9jww@9 zQ?1Y|Kd&sybUX3o*c}c_WdH!I1n>?K^)d5(rRDG}Gb5YLdNoa3K}7#B^XEjgRgxrc zE%5?7CM=}^f(HQXfW9H}Xz9D(plADEiRjbHF>eH`CM<^mccQg4)wM(-k<;OD*syqk zmG=&AF~GKXfmsvWaDtJvkYVO;0MPL8@DruILtB_lhHl6Kj=g*-iQkb-CZB}CnZOMO01lX7MZ9>h zTd-_`Rq^7XZo;$)cEpPZx(&l7*cC4x=0<9o;DmU$LjXPmz=1&Emzy_l-a3d`C=`yA z3Y~lYNbUj?>Pftyt`B1i>iRgoKn#bv6fdajf5~akph1HMPV+BiCSbVofm|>E0000< KMNUMnLSTZYkGzop literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/drawable-xhdpi/tracks.png b/src/app/src/main/res/drawable-xhdpi/tracks.png new file mode 100644 index 0000000000000000000000000000000000000000..712d36dfb3d8f2cb7165db244c1cf10f5bb711af GIT binary patch literal 907 zcmV;619bd}P)^T|KrUl$NH`JWGhU3fi@hw)*vudRvnxS)Tnm_Iq9P%kTL-pFDYf z;mHpy%d#xXvMlSFB!-WF3m6Fb7;C^g$Gzx=eDFpFB9X`$&+{IHF?pp@Iel<&P! z0`NTVL9tlOhB0@4fB(C_@7EpUgmK`fi7mi%0Ml#%rURH}d=;o?XlUroQmGVc_5GDf zVp+`b6kO;0ka7c!gS#zXCr2p8=(IgnJU2 zEg%AXAR_Op>hq4{9CmeeZHoxo+uN`1@9)1YBA=*g7MK7Ywm))vHB-PT;A`M~JRUEm z)9D}6>2#r^qvIcd!^1;aTwLs%ot=Gsb#?VU5lN_O0w{(Nyj_Cc16<%M;N0lwXl`?J z^V9M1@s0ma9>8^7O{G%Bm6es-nM~$$5qU{O^1usW1aFt1SHL)MF_}!}=jZ3|HX1xR zIr$5~)YQ}!Uy7E@ z>G@-MdHKsny>|5Xc9{?#iwC&^E~{#1DwX=SnUe>|<#OM&nCpoNau2v5BL9w!jTJ&q zV?dAsdZN+jZg+QgHS{zF1SvpOo4WF!DnSY;?d|PF%jNQOp{Fq*NC9_L)yZbFFNdDS zfFR3YR7CFg_V(^>ZEf9XW+fON9=^1(u~By!tmkN(+s_zY26t8Un(zD9Mn*=?FD)(I z2{LgelNrqC^REH-IR1xFBMtxGe_TO#in>4!cq5riez&l&@Pp$V|LvKWm^d>tGjmy0 z@2Top;O{nMHLer*9?*C^{@e8Q^wQ4G&c~{HRaLK6tJRO@=H})H2M5;y6_GWd<;(x1 ziU1#q$U{IK$2o|_V!wNyS8-jpO6|n=f$KojXwraM-7I|ycndg3?Ng3YdmwTb_<{x- hlPt@!EXz6({sT&C`dgKO*qs0X002ovPDHLkV1ny)x;p>> literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/layout/activity_main.xml b/src/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..7e521e0 --- /dev/null +++ b/src/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/src/main/res/layout/dialog_list.xml b/src/app/src/main/res/layout/dialog_list.xml new file mode 100644 index 0000000..1549697 --- /dev/null +++ b/src/app/src/main/res/layout/dialog_list.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/src/app/src/main/res/layout/dialog_satellites.xml b/src/app/src/main/res/layout/dialog_satellites.xml new file mode 100644 index 0000000..5fd73d3 --- /dev/null +++ b/src/app/src/main/res/layout/dialog_satellites.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/app/src/main/res/layout/list_item.xml b/src/app/src/main/res/layout/list_item.xml new file mode 100644 index 0000000..58dbe60 --- /dev/null +++ b/src/app/src/main/res/layout/list_item.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/src/app/src/main/res/mipmap-hdpi/icon.png b/src/app/src/main/res/mipmap-hdpi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3cc76facfdd38c5b5c1a23e606a06d07a61ee20f GIT binary patch literal 3706 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 Y2Q(7G|8LX~AOHXW07*qoM6N<$f}ue_fdBvi literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/mipmap-mdpi/icon.png b/src/app/src/main/res/mipmap-mdpi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3fb791d554cec921d97ea1eec5d0ddb3de44a71e GIT binary patch literal 2194 zcmV;D2yOR?P)sHDu{@PDEG95^#}_9jE+O^si5T7 zuV2UCxN$?=y?a-bmX?Z|ni|pANV|n--@d)*)vK3COiUCbM~)QZ#*Gv0+O;EqT+mMd zQxg`7V1;Qo=+K7`AKsojcTUcjF+;X$)k;E7K-9W*YdLe~OnLtNc}X46br94mSUZB| zM<wVytHYB+M_h>VYq56EDDz!DM?S{by&rE;2JSQ&v`1vU96!-n`l3ouUlaL}V}nuXrJ}mJ8POc^d!b%^SalgwsgfCJ3&H zvNBGBTpbhu2*fc|n$d=C^Cd>!4wy4xy3>RS6Lg3Te4l{QLyYsx+FC z78ZIf?A+X3_LURREP(NA?vDNX^|JslOgfcYw{C?sE1Xg4a4PH9uebW=h6$UR!1wuC z0Mn055A^6qM@PGc$|XydsEsSs!i{a|(xr~#1ZVCOOgm6AKRXLxDrat^0l1C++qZ93 z2Q6B(khgE&b~M6YC1of}9n|GixX)-iX3w5YmQud}z>x6j)ho;Y7|QHBtnTR2rAtbA zn>KCa@ZrPd`}gmiY;de2FJ&l89n|Gi7=d&MCoeKyvh{TZfyVEzT)ASQ9yV;4lU`Lv z_vzHBljJ@@BX;fD<;rVs*tv73l407wRK(4VC^u-(Aj_U>*RHYZJAVMOw{G2P3Ap+9 zyLazY$IF&2Q?}9Nkl)Lysw%s!KHrZXJyLtiv}w~+8;$2cxVce!db(xLwr$%el4Sz0 z?{En`jOa3W@L)%Ju53qL`Fu66)RRa@|%@dxB`2O*tc(=s-_Oa zevzM_?Djcqk?wcVqD7WHQ>RX)sv>^?{unc6j3q!!nzRvi_tEu;({jX!5wb^*9+JDMy)g5P zi;Gid2I?@2lN*_R>-Vu^$5O1y1Q6wl#--uv)vL;V=FOYu#etA0_V3>>FaQFF6Va_( zH?Q_5Po9X!j~@%{F`{M5mR@aiqr-;}D>s@xeY#7}&ee7RjXZ$s0v8f}`+7&M)2B}h z#BMQp@?;;4qehJqTefTw1qB5@Z3MJn!2&@Zd!a7sq;3rWE~MBhDk{XWW5?7xk~==? z)rV8T^yDvIyx4M^g9mxm{11x(2*~nBX!SoqO)i%xpIE&qVK7RZt)6&uuAN*S~dd0@Zs_%sh7fQD45-6|T zjd)8zeWbQ++v>wqA8SgSUeIaTtXZ=xK_*lz7cX8^b$W!EI`DhbrcG8J#2iQOb_htMeYvA1xFHYYJwFS83}8IS>pqP>Dk@67Yxl7m#gV7W z=o#<_z#lIGXgAV*vR`JrDA@rS?*Y&q30EDj`!on1K5A=gsYtm7U5I&#Q<*boj&h-< zrlz2HsS7){L}F<5%UcTA!h4*+Mqi_Zz-mST8lpi^nZ8!x;$=ObI=A6dpQ85e-D_bB zl$W|EPMm1<*Rf+q9R0T4*=c5bO#n<29dF%qQ<{c=f@;%_TK8RWc3d^ZNDc zs@|!%@!q?4&*!S1={ZUXC_3oTHwB>J8&C~Fm@|QlQeDitU3M{_*P!j^2@Q@jxo+J$ zNnPg1dyM}Re7(amUJFwZsFdzxt_gO^TLt>P=L%5UrVc@F0JyfeNttn;y!3gG+f+c2 z`{6wlR8UBFx=ce&U)4tP$pbi!l`xk;Tkae|?e$GCL%;nx3uR*z14f^x7sHgVJ9rIWtn3B-6N zW=uD@lT!zDWrMnhVqjQk(JAjLQ$Wdx;R!cy-W1rS1#b_ktE;_!v*J9gezWpKhO?~z z;1bC3sP!{~!(X8oPnoa)n4JO)|7j(G%pcwA42aSIss))px+^_x&5!00_&-PBAGN2+ UH{5WuW&i*H07*qoM6N<$f|}Js!2kdN literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/mipmap-xhdpi/icon.png b/src/app/src/main/res/mipmap-xhdpi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..36b98476970400070b3857dd0ef390ade0b19c1b GIT binary patch literal 4652 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 i;Xs806%Le;1OErJ_9q{V(RGmk0000pg literal 0 HcmV?d00001 diff --git a/src/app/src/main/res/values/colors.xml b/src/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..76a8d77 --- /dev/null +++ b/src/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #fa0505 + \ No newline at end of file diff --git a/src/app/src/main/res/values/strings.xml b/src/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..af24a30 --- /dev/null +++ b/src/app/src/main/res/values/strings.xml @@ -0,0 +1,24 @@ + + + cat + all maps offline and track writer + track writer + + %1$.1f km + increase zoom + decrease zoom + exit + satellites + select track + select map + center map + %1$d\u00B0 %2$.1f km + maps + + tracks + + longitude %1$f\u00B0\nlatitude %2$f\u00B0\naltitude %3$d m\naccuracy %4$d m\nspeed %5$d + km/h + + available %1$d, used %2$d + \ No newline at end of file diff --git a/src/svg/cat.svg b/src/svg/cat.svg new file mode 100644 index 0000000..fd83575 --- /dev/null +++ b/src/svg/cat.svg @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/exit.svg b/src/svg/exit.svg new file mode 100644 index 0000000..e56f7ee --- /dev/null +++ b/src/svg/exit.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/icon.svg b/src/svg/icon.svg new file mode 100644 index 0000000..726e466 --- /dev/null +++ b/src/svg/icon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/maps.svg b/src/svg/maps.svg new file mode 100644 index 0000000..a3b2d61 --- /dev/null +++ b/src/svg/maps.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/minus.svg b/src/svg/minus.svg new file mode 100644 index 0000000..28b0e44 --- /dev/null +++ b/src/svg/minus.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/notification.svg b/src/svg/notification.svg new file mode 100644 index 0000000..7b16ad2 --- /dev/null +++ b/src/svg/notification.svg @@ -0,0 +1,26 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/svg/plus.svg b/src/svg/plus.svg new file mode 100644 index 0000000..d9dc336 --- /dev/null +++ b/src/svg/plus.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/pointer.svg b/src/svg/pointer.svg new file mode 100644 index 0000000..6a496f7 --- /dev/null +++ b/src/svg/pointer.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/satellites.svg b/src/svg/satellites.svg new file mode 100644 index 0000000..c80cb61 --- /dev/null +++ b/src/svg/satellites.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/svg/tracks.svg b/src/svg/tracks.svg new file mode 100644 index 0000000..f7af109 --- /dev/null +++ b/src/svg/tracks.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + \ No newline at end of file