initial commit

This commit is contained in:
Morten Delenk 2022-08-12 14:26:53 +01:00
commit f3534a8431
No known key found for this signature in database
GPG key ID: 5130416C797067B6
160 changed files with 14200 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
*.xml
*.db
/key
/media
/.direnv
/doc

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

2696
Doxyfile Normal file

File diff suppressed because it is too large Load diff

4
README.md Normal file
View file

@ -0,0 +1,4 @@
# Modules
- `app`: The android app
- `auth`: Authentication code

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

79
app/build.gradle Normal file
View file

@ -0,0 +1,79 @@
buildscript {
repositories {
google()
}
dependencies {
def nav_version = "2.5.1"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
}
}
plugins {
id 'com.android.application'
id 'androidx.navigation.safeargs' version '2.5.1'
}
android {
compileSdk 33
defaultConfig {
applicationId "rs.chir.invtracker.client"
minSdk 21
targetSdk 33
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_15
targetCompatibility JavaVersion.VERSION_15
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'com.google.android.material:material:1.7.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.navigation:navigation-fragment:2.5.1"
implementation "androidx.navigation:navigation-ui:2.5.1"
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation 'androidx.datastore:datastore-rxjava3:1.0.0'
implementation 'androidx.datastore:datastore-preferences-rxjava3:1.0.0'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'com.google.android.gms:play-services-cronet:18.0.1'
implementation 'org.chromium.net:cronet-fallback:102.5005.125'
implementation 'com.google.net.cronet:cronet-okhttp:0.1.0'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation project(path: ':invtracker')
implementation 'androidx.annotation:annotation:1.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.preference:preference:1.2.0'
implementation 'com.google.android.gms:play-services-location:20.0.0'
implementation 'com.google.zxing:core:3.5.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'org.bouncycastle:bcprov-jdk15to18:1.71'
implementation "androidx.camera:camera-core:1.1.0"
implementation "androidx.camera:camera-camera2:1.1.0"
implementation "androidx.camera:camera-lifecycle:1.1.0"
implementation "androidx.camera:camera-view:1.1.0"
implementation 'org.osmdroid:osmdroid-android:6.1.13'
}

21
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,22 @@
package rs.chir.invtracker.client
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("rs.chir.invtracker.client", appContext.packageName)
}
}

View file

@ -0,0 +1,142 @@
package rs.chir.cv;
import static android.graphics.ImageFormat.FLEX_RGBA_8888;
import static android.graphics.ImageFormat.FLEX_RGB_888;
import static android.graphics.ImageFormat.YUV_420_888;
import static android.graphics.ImageFormat.YUV_422_888;
import static android.graphics.ImageFormat.YUV_444_888;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.camera.core.ImageProxy;
import com.google.zxing.LuminanceSource;
/**
* Converts an {@link ImageProxy} into a {@link LuminanceSource}.
* <br>
* This class is used internally by {@link QRAnalyzer} for converting a camera image into a Format {@link com.google.zxing} can use.
*
* @author Morten Delenk
* @version 1.0.0
* @see QRAnalyzer
*/
public class ImageProxyLuminanceSource extends LuminanceSource {
/**
* How much the red channel is weighted in the luminance calculation.
*/
private static final float LUMINANCE_RED = 0.299f;
/**
* How much the green channel is weighted in the luminance calculation.
*/
private static final float LUMINANCE_GREEN = 0.587f;
/**
* How much the blue channel is weighted in the luminance calculation.
*/
private static final float LUMINANCE_BLUE = 0.114f;
/**
* The image proxy to convert.
*/
private final ImageProxy imageProxy;
/**
* Creates a new Instance from the given {@link ImageProxy}.
*
* @param imageProxy The image proxy to convert.
*/
public ImageProxyLuminanceSource(@NonNull ImageProxy imageProxy) {
super(imageProxy.getWidth(), imageProxy.getHeight());
this.imageProxy = imageProxy;
}
/**
* Converts an RGB value into a luminance value.
* @param r The red channel in the range [0, 1].
* @param g The green channel in the range [0, 1].
* @param b The blue channel in the range [0, 1].
* @return The luminance value in the range [0, 1].
*/
private static float getLuminance(float r, float g, float b) {
return LUMINANCE_RED * r + LUMINANCE_GREEN * g + LUMINANCE_BLUE * b;
}
/**
* Converts an RGB value into a luminance value.
* @param r The red channel in the range [0, 255].
* @param g The green channel in the range [0, 255].
* @param b The blue channel in the range [0, 255].
* @return The luminance value in the range [0, 255].
*/
private static byte getLuminance(byte r, byte g, byte b) {
float fr = r / 255.0f;
float fg = g / 255.0f;
float fb = b / 255.0f;
return (byte) (ImageProxyLuminanceSource.getLuminance(fr, fg, fb) * 255.0f);
}
/**
* Retrieves a plane proxy from the image proxy.
* @param planeIndex the plane index to retrieve.
* @return the plane proxy.
*/
private ImageProxy.PlaneProxy getPlaneProxy(int planeIndex) {
return imageProxy.getPlanes()[planeIndex];
}
/**
* Retrieves a single pixel on a plane from the image.
* @param planeIndex the plane index to retrieve the pixel from.
* @param x the x coordinate of the pixel.
* @param y the y coordinate of the pixel.
* @return the pixel.
*/
private byte getOnPlane(int planeIndex, int x, int y) {
var plane = this.getPlaneProxy(planeIndex);
var rowStride = plane.getRowStride();
var pixelStride = plane.getPixelStride();
var row = y * rowStride;
var pixel = x * pixelStride;
return plane.getBuffer().get(row + pixel);
}
/**
* Retrieves the luminance value of a single pixel from the image.
* @param x the x coordinate of the pixel.
* @param y the y coordinate of the pixel.
* @return the pixel.
*/
private byte getPixel(int x, int y) {
return switch (this.imageProxy.getFormat()) {
case YUV_420_888, YUV_422_888, YUV_444_888 -> this.getOnPlane(0, x, y);
case FLEX_RGB_888, FLEX_RGBA_8888 -> ImageProxyLuminanceSource.getLuminance(
this.getOnPlane(0, x, y),
this.getOnPlane(1, x, y),
this.getOnPlane(2, x, y));
default -> throw new IllegalArgumentException("Unsupported image format: " + this.imageProxy.getFormat());
};
}
@NonNull
@Override
public byte[] getRow(int y, @Nullable byte[] row) {
if (row == null || row.length < this.getWidth()) {
row = new byte[this.getWidth()];
}
for (int x = 0; x < this.getWidth(); x++) {
row[x] = this.getPixel(x, y);
}
return row;
}
@NonNull
@Override
public byte[] getMatrix() {
var buffer = new byte[this.getWidth() * this.getHeight()];
for (int x = 0; x < this.getWidth(); x++) {
for (int y = 0; y < this.getHeight(); y++) {
buffer[x + y * this.getWidth()] = this.getPixel(x, y);
}
}
return buffer;
}
}

View file

@ -0,0 +1,74 @@
package rs.chir.cv;
import androidx.annotation.NonNull;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageProxy;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import rs.chir.compat.java.util.Optional;
import rs.chir.compat.java.util.function.Function;
/**
* {@link ImageAnalysis.Analyzer} for QR codes.
*
* When a QR code is found, the analyzer will call the {@link QRAnalyzerListener} callback.
*
* @author Morten Delenk
* @version 1.0.0
*/
public class QRAnalyzer implements ImageAnalysis.Analyzer {
/**
* QR Code analyzer, maps an {@link ImageProxy} to an {@link Optional} {@link String}.
*/
private final Function<ImageProxy, Optional<String>> qrCodePipeline;
/**
* Listener for QR code events.
*/
private final QRAnalyzerListener listener;
/**
* Creates a new QR analyzer.
* @param listener The listener to call when a QR code is found.
*/
public QRAnalyzer(@NonNull QRAnalyzerListener listener) {
this.listener = listener;
this.qrCodePipeline = imageProxy -> {
var luminanceSource = new ImageProxyLuminanceSource(imageProxy);
var binarizer = new HybridBinarizer(luminanceSource);
var binaryBitmap = new BinaryBitmap(binarizer);
var reader = new QRCodeReader();
try {
var result = reader.decode(binaryBitmap);
return Optional.of(result.getText());
} catch (Exception e) {
e.printStackTrace();
return Optional.empty();
}
};
}
/**
* Analyzes the given {@link ImageProxy}.
* @param image The image to analyze.
*/
@Override
public void analyze(@NonNull ImageProxy image) {
this.qrCodePipeline.apply(image).ifPresent(this.listener::onQRFound);
image.close();
}
/**
* Listener for QR code events.
*/
@FunctionalInterface
public interface QRAnalyzerListener {
/**
* Called when a QR code is found.
* @param qrCode The contents of the QR code.
*/
void onQRFound(@NonNull String qrCode);
}
}

View file

@ -0,0 +1,13 @@
/**
* Module for computer vision on Android.
*
* Currently supports:
*
* <ul>
* <li>QR Code scanning: {@link rs.chir.cv.QRAnalyzer}</li>
* <li>Conversion of an {@link androidx.camera.core.ImageProxy} into a {@link com.google.zxing.LuminanceSource}: {@link rs.chir.cv.QRAnalyzer}</li>
* </ul>
*
*
*/
package rs.chir.cv;

View file

@ -0,0 +1,204 @@
package rs.chir.invtracker.api;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.model.Cursor;
import rs.chir.invtracker.model.GeoLocation;
import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.model.PasetoToken;
import rs.chir.invtracker.model.PublicKey;
import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.utils.ConnectionScorer;
/**
* An API client that caches results of API calls.
*/
public class CachedClientImpl extends Client {
/**
* The underlying client.
*/
private final Client client;
/**
* The context used to retrieve network connectivity information.
*/
private final Context context;
/**
* Cached objects.
* @see TrackedItem
*/
private final Map<Long, TrackedItem> objects = new java.util.HashMap<>(10);
/**
* Cached public key.
* @see PublicKey
*/
private Optional<PublicKey> publicKey = Optional.empty();
/**
* True if all items have been cached.
*/
private boolean hasAllItems = false;
/**
* Creates a new cached client.
* @param context the context used to retrieve network connectivity information
* @param client the underlying client
*/
public CachedClientImpl(@NonNull Context context, @NonNull Client client) {
this.client = client;
this.context = context;
}
/**
* Returns whether aggressive caching is desired.
* @return true if network connectivity is limited
*/
private boolean aggressivelyCache() {
return ConnectionScorer.score(context) != ConnectionScorer.ConnectionRating.CONNECTED;
}
/**
* @inheritDoc
*/
@NonNull
@Override
public Single<PublicKey> getPublicKey() {
return this.publicKey.map(Single::just).orElseGet(() -> this.client.getPublicKey().doOnSuccess(publicKey -> this.publicKey = Optional.of(publicKey)));
}
/**
* @inheritDoc
*/
@NonNull
@Override
public Single<PasetoToken> login(@NonNull String username, @NonNull String password) {
return this.client.login(username, password);
}
/**
* @inheritDoc
*/
@NonNull
@Override
public Single<TrackedItem> getObject(long id) {
return Optional.tryIndex(this.objects, id).map(Single::just).orElseGet(() -> this.client.getObject(id).doOnSuccess(this::cacheObject));
}
/**
* Caches the given object.
* @param trackedItem the object to cache
*/
private void cacheObject(TrackedItem trackedItem) {
this.objects.put(trackedItem.id(), trackedItem);
}
/**
* @inheritDoc
*/
@NonNull
@Override
public Single<Cursor<TrackedItem>> getObjects(@NonNull Optional<String> cont) {
if (this.aggressivelyCache() && cont.isEmpty()) {
// Return cached objects
Long[] keys = this.objects.keySet().toArray(new Long[0]);
if (keys.length != 0) {
// We need to sort the keys because the caller expects them to be in order
Arrays.sort(keys);
var items = new ArrayList<TrackedItem>(keys.length);
for (var key : keys) {
items.add(this.objects.get(key));
}
return Single.just(new Cursor<>(items, Optional.of(String.valueOf(keys[keys.length - 1]))));
}
}
return this.client.getObjects(cont).doOnSuccess(cursor -> {
for (var item : cursor.items()) {
this.cacheObject(item);
}
});
}
@NonNull
@Override
public Flowable<TrackedItem> streamObjects() {
return super.streamObjects().doOnComplete(() -> this.hasAllItems = true);
}
@NonNull
@Override
public Single<Cursor<TrackedItem>> getObjects(@NonNull GeoRect rect, @NonNull Optional<String> cont) {
if (this.aggressivelyCache() && this.hasAllItems) {
// We can only do this if we have all items cached
var items = new ArrayList<TrackedItem>();
for (var item : this.objects.values()) {
item.lastKnownLocation().ifPresent(location -> {
if (rect.contains(location)) {
items.add(item);
}
});
}
return Single.just(new Cursor<>(items, Optional.of(String.valueOf(items.get(items.size() - 1).id()))));
}
return this.client.getObjects(rect, cont).doOnSuccess(cursor -> {
for (var item : cursor.items()) {
this.cacheObject(item);
}
});
}
@NonNull
@Override
public Single<Boolean> updateLocation(long itemId, @NonNull GeoLocation location) {
return this.client.updateLocation(itemId, location).flatMap(resp -> {
this.objects.remove(itemId);
return this.getObject(itemId).map(__ -> resp);
});
}
@NonNull
@Override
public Single<InputStream> fetchURL(@NonNull String imageUrl) {
return this.client.fetchURL(imageUrl);
}
@NonNull
@Override
public Single<Cursor<GeoLocation>> getLocations(long itemId, @NonNull Optional<String> cont) {
// TODO: maybe cache this
return this.client.getLocations(itemId, cont);
}
@NonNull
@Override
public Single<String> upload(@NonNull Uri uri) {
return this.client.upload(uri);
}
@NonNull
@Override
public Single<TrackedItem> updateObject(@NonNull TrackedItem item) {
return this.client.updateObject(item).doOnSuccess(this::cacheObject);
}
@NonNull
@Override
public Single<TrackedItem> createObject(@NonNull TrackedItem item) {
return this.client.createObject(item).doOnSuccess(this::cacheObject);
}
@NonNull
@Override
public Single<Boolean> deleteObject(long id) {
return this.client.deleteObject(id).doOnSuccess(__ -> this.objects.remove(id));
}
}

View file

@ -0,0 +1,220 @@
package rs.chir.invtracker.api;
import android.net.Uri;
import androidx.annotation.NonNull;
import java.io.InputStream;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.model.Cursor;
import rs.chir.invtracker.model.GeoLocation;
import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.model.PasetoToken;
import rs.chir.invtracker.model.PublicKey;
import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.utils.CursorStreamable;
/**
* The API Client abstract base class.
*/
public abstract class Client {
/**
* Protected constructor.
*/
protected Client() {
}
/**
* Gets the public key.
* @return A single that emits the public key
* @see PublicKey
* @see Single
*/
@NonNull
public abstract Single<PublicKey> getPublicKey();
/**
* Attempts to log in with the given credentials.
* @param username the username
* @param password the password
* @return A single that emits the API token
* @see PasetoToken
* @see Single
*/
@NonNull
public abstract Single<PasetoToken> login(String username, String password);
/**
* Retrieves an item with the given ID.
* @param id the item ID
* @return A single that emits the item
* @see TrackedItem
* @see Single
*/
@NonNull
public abstract Single<TrackedItem> getObject(long id);
/**
* Retrieves a page of items.
* @param cont An optional continuation token
* @return A single that emits a page of items
* @see TrackedItem
* @see Cursor
* @see Single
*/
@NonNull
public abstract Single<Cursor<TrackedItem>> getObjects(@NonNull Optional<String> cont);
/**
* Retrieve all items
* @return A flowable that emits all items
* @see TrackedItem
* @see Flowable
*/
@NonNull
public Flowable<TrackedItem> streamObjects() {
return new CursorStreamable<>(this::getObjects);
}
/**
* Retieves a page of items that are within the given rectangle.
* @param rect The rectangle
* @param cont An optional continuation token
* @return A single that emits a page of items
* @see TrackedItem
* @see Cursor
* @see Single
*/
@NonNull
public abstract Single<Cursor<TrackedItem>> getObjects(@NonNull GeoRect rect, @NonNull Optional<String> cont);
/**
* Retrieves all items that are within the given rectangle.
* @param rect The rectangle
* @return A flowable that emits all items
* @see TrackedItem
* @see Flowable
*/
@NonNull
public Flowable<TrackedItem> streamObjects(@NonNull GeoRect rect) {
return new CursorStreamable<>(id -> this.getObjects(rect, id));
}
/**
* Retrieves all items that are in an optional rectangle.
* @param rect The rectangle
* @return A flowable that emits all matching items
* @see TrackedItem
* @see Flowable
*/
@NonNull
public Flowable<TrackedItem> streamObjects(@NonNull Optional<GeoRect> rect) {
return rect.map(this::streamObjects).orElseGet(this::streamObjects);
}
/**
* Retrieves a page of items that are within the given rectangle.
* @param rect The rectangle
* @param cont An optional continuation token
* @return A single that emits a page of items
*/
@NonNull
public Single<Cursor<TrackedItem>> getObjects(@NonNull Optional<GeoRect> rect, @NonNull Optional<String> cont) {
return rect.map(r -> this.getObjects(r, cont)).orElseGet(() -> this.getObjects(cont));
}
/**
* Updates the location of the given item.
* @param item The item
* @param location The location
* @return A single that emits true on success
* @see Single
*/
@NonNull
public Single<Boolean> updateLocation(@NonNull TrackedItem item, @NonNull GeoLocation location) {
return this.updateLocation(item.id(), location);
}
/**
* Updates the location of the given item.
* @param itemId The item ID
* @param location The location
* @return A single that emits true on success
* @see Single
*/
@NonNull
public abstract Single<Boolean> updateLocation(long itemId, @NonNull GeoLocation location);
/**
* Starts a download for an image.
* @param imageUrl The image URL
* @return A single that emits the image input stream
*/
@NonNull
public abstract Single<InputStream> fetchURL(@NonNull String imageUrl);
/**
* Retrieves a page of locations for the given item.
* @param itemId The item ID
* @param cont An optional continuation token
* @return A single that emits a page of locations
* @see GeoLocation
* @see Cursor
*/
@NonNull
public abstract Single<Cursor<GeoLocation>> getLocations(long itemId, @NonNull Optional<String> cont);
/**
* Retrieves all locations for the given item.
* @param itemId The item ID
* @return A flowable that emits all locations
* @see GeoLocation
* @see Flowable
*/
@NonNull
public Flowable<GeoLocation> streamLocations(long itemId) {
return new CursorStreamable<>(id -> this.getLocations(itemId, id));
}
/**
* Uploads an image to the server.
* @param uri The source URI
* @return The URL of the uploaded image
* @see Single
* @see String
*/
@NonNull
public abstract Single<String> upload(@NonNull Uri uri);
/**
* Updates an item.
* @param item The item
* @return A single that emits the updated item
* @see Single
* @see TrackedItem
*/
@NonNull
public abstract Single<TrackedItem> updateObject(@NonNull TrackedItem item);
/**
* Creates a new item.
* @param item The item
* @return A single that emits the created item
* @see Single
* @see TrackedItem
*/
@NonNull
public abstract Single<TrackedItem> createObject(@NonNull TrackedItem item);
/**
* Deletes an item.
* @param id The item id
* @return A single that emits true on success
* @see Single
*/
@NonNull
public abstract Single<Boolean> deleteObject(long id);
}

View file

@ -0,0 +1,21 @@
package rs.chir.invtracker.api;
import androidx.annotation.NonNull;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.function.Supplier;
import rs.chir.invtracker.utils.SingleBackoff;
/**
* An extended version of {@link SingleBackoff} that errors out on {@link UnauthorizedException}.
* @param <T> the type of the result
*/
class ClientBackoff<T> extends SingleBackoff<T> {
/**
* Creates a new client backoff.
* @param singleSupplier the supplier of the result
*/
ClientBackoff(@NonNull Supplier<Single<T>> singleSupplier) {
super(singleSupplier, t -> !(t instanceof UnauthorizedException));
}
}

View file

@ -0,0 +1,312 @@
package rs.chir.invtracker.api;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.net.cronet.okhttptransport.CronetInterceptor;
import org.chromium.net.CronetEngine;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.xml.transform.TransformerException;
import io.reactivex.rxjava3.core.Single;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSink;
import okio.Okio;
import rs.chir.compat.java.util.Base64;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.client.BuildConfig;
import rs.chir.invtracker.client.model.APIKey;
import rs.chir.invtracker.model.Cursor;
import rs.chir.invtracker.model.GeoLocation;
import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.model.PasetoToken;
import rs.chir.invtracker.model.PublicKey;
import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.utils.CallAdapter;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
/**
* The main API client.
*/
public class ClientImpl extends Client {
/**
* The development API URL.
*/
private static final String DEV_API_URL = "http://192.168.178.26:1234";
/**
* The production API URL.
*/
private static final String PROD_API_URL = "https://invtracker.chir.rs";
/**
* The API URL.
*/
private static final String API_URL = BuildConfig.DEBUG ? DEV_API_URL : PROD_API_URL;
/**
* An empty body.
*/
private static final byte[] EMPTY_BODY = new byte[0];
/**
* The {@link Context} used to retrieve the API token.
*/
private final Context context;
/**
* The HTTP client.
*/
private final OkHttpClient client;
/**
* Creates a new client.
* @param context the context used to retrieve the API token
* @param cronetEngine the Cronet engine
*/
private ClientImpl(Context context, CronetEngine cronetEngine) {
this.context = context;
this.client = new OkHttpClient.Builder()
.addInterceptor(CronetInterceptor.newBuilder(cronetEngine).build())
.build();
}
/**
* Creates a new client.
* @param context the context used
*/
@NonNull
public static Single<Client> create(@NonNull Context context) {
return CronetEngineProvider.getInstance(context)
.map(engine -> new ClientImpl(context, engine));
}
/**
* Returns the HTTP client.
* @return the HTTP client
*/
@NonNull
public OkHttpClient getClient() {
return this.client;
}
/**
* Parses a response
* @param response A single emitting the response
* @param clazz The expected class of the response
* @param <T> The expected class of the response
* @return A single emitting the parsed response
* @see SimpleXML#parseDocument(InputStream)
* @see SimpleXML#deserialize(Node, Class)
*/
private <T extends XMLSerializable> Single<T> parseResponse(@NonNull Single<Response> response, Class<T> clazz) {
return response.flatMap(response1 -> {
if (response1.isSuccessful()) {
var document = SimpleXML.parseDocument(response1.body().byteStream());
return Single.just(SimpleXML.deserialize(document.getDocumentElement(), clazz));
} else if (response1.code() == 401) {
return APIKey.setAPIToken(this.context, Optional.empty())
.flatMap(__ -> Single.error(new UnauthorizedException("Unauthorized")));
} else {
throw new RuntimeException("Unexpected response: " + response1.code());
}
});
}
/**
* Encodes a request body.
* @param body the body to encode
* @param <T> the type of the body
* @return the encoded body
* @throws IOException If the serializer could not write to a {@link ByteArrayOutputStream}
* @throws TransformerException If the serializer could not transform the body to XML
* @see SimpleXML#serialize(XMLSerializable)
* @see SimpleXML#writeTo(Document, OutputStream)
*/
@NonNull
private <T extends XMLSerializable> byte[] encodeBody(@NonNull T body) throws IOException, TransformerException {
var doc = SimpleXML.serialize(body);
try (var bos = new ByteArrayOutputStream()) {
SimpleXML.writeTo(doc, bos);
return bos.toByteArray();
}
}
/**
* @inheritDoc
*/
@Override
@NonNull
public Single<PublicKey> getPublicKey() {
return this.parseResponse(new ClientBackoff<>(() -> {
Request request = new Request.Builder()
.url(API_URL + "/public-key")
.build();
return new CallAdapter(this.client.newCall(request));
}), PublicKey.class);
}
/**
* Creates a new {@link Request.Builder} for the given API route.
* @param route the API route
* @return A single emitting the {@link Request.Builder}
*/
private Single<Request.Builder> createRequest(String route) {
return APIKey.get(this.context)
.map(apiToken -> {
var request = new Request.Builder()
.url(API_URL + route)
.header("Authorization", "Bearer " + apiToken);
return request;
});
}
@Override
@NonNull
public Single<PasetoToken> login(String username, String password) {
return this.parseResponse(new ClientBackoff<>(() -> {
if (username.contains(":")) {
throw new IllegalArgumentException("Username cannot contain ':'");
}
String authHeader = "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
Request request = new Request.Builder()
.url(API_URL + "/login")
.header("Authorization", authHeader)
.post(RequestBody.create(EMPTY_BODY))
.build();
return new CallAdapter(this.client.newCall(request));
}), PasetoToken.class);
}
@Override
@NonNull
public Single<TrackedItem> getObject(long id) {
return this.parseResponse(new ClientBackoff<>(() -> this.createRequest("/objects/" + id).map(Request.Builder::build)
.map(this.client::newCall)
.flatMap(CallAdapter::new)), TrackedItem.class);
}
@Override
@NonNull
public Single<Cursor<TrackedItem>> getObjects(@NonNull Optional<String> cont) {
return this.parseResponse(new ClientBackoff<>(() -> this.createRequest("/objects" + cont.map(c -> "?start=" + c).orElse(""))
.flatMap(request -> new CallAdapter(this.client.newCall(request.get().build())))),
Cursor.class)
.map(cursor -> (Cursor<TrackedItem>) cursor);
}
@Override
@NonNull
public Single<Cursor<TrackedItem>> getObjects(@NonNull GeoRect rect, @NonNull Optional<String> cont) {
return this.parseResponse(new ClientBackoff<>(() -> this.createRequest("/geo-rect" + cont.map(c -> "?start=" + c).orElse(""))
.flatMap(request -> {
var req = request.method("POST", RequestBody.create(this.encodeBody(rect)))
.build();
return new CallAdapter(this.client.newCall(req));
})),
Cursor.class)
.map(cursor -> (Cursor<TrackedItem>) cursor);
}
@Override
@NonNull
public Single<Boolean> updateLocation(long itemId, @NonNull GeoLocation location) {
return this.createRequest("/objects/" + itemId + "/locations")
.flatMap(builder -> {
var req = builder.method("POST", RequestBody.create(this.encodeBody(location)))
.build();
return new CallAdapter(this.client.newCall(req));
}).map(__ -> true);
}
@Override
@NonNull
public Single<InputStream> fetchURL(@NonNull String imageUrl) {
Single<Request.Builder> rb;
if (imageUrl.startsWith(API_URL)) {
rb = APIKey.get(this.context)
.map(apiToken -> new Request.Builder()
.url(imageUrl)
.header("Authorization", "Bearer " + apiToken));
} else {
rb = Single.just(new Request.Builder()
.url(imageUrl));
}
return rb.flatMap(request -> new ClientBackoff<>(() -> new CallAdapter(this.client.newCall(request.build()))))
.map(Response::body).map(ResponseBody::byteStream);
}
@Override
@NonNull
public Single<Cursor<GeoLocation>> getLocations(long itemId, @NonNull Optional<String> cont) {
return this.parseResponse(new ClientBackoff<>(() -> this.createRequest("/objects/" + itemId + "/locations" + cont.map(c -> "?start=" + c).orElse(""))
.flatMap(request -> new CallAdapter(this.client.newCall(request.get().build())))),
Cursor.class)
.map(cursor -> (Cursor<GeoLocation>) cursor);
}
@NonNull
@Override
public Single<String> upload(@NonNull Uri uri) {
return new ClientBackoff<>(() -> this.createRequest("/upload")
.flatMap(request -> {
var cr = this.context.getContentResolver();
var source = Okio.source(cr.openInputStream(uri));
var contentType = cr.getType(uri);
var rb = new RequestBody() {
@Override
public void writeTo(@NonNull BufferedSink bufferedSink) throws IOException {
bufferedSink.writeAll(source);
}
@Nullable
@Override
public MediaType contentType() {
return MediaType.parse(contentType);
}
};
return new CallAdapter(this.client.newCall(request.method("POST", rb).build()));
})).map(Response::headers).map(headers -> headers.get("Location")).map(header -> API_URL + header);
}
@NonNull
@Override
public Single<TrackedItem> updateObject(@NonNull TrackedItem item) {
return this.parseResponse(new ClientBackoff<>(() -> this.createRequest("/objects/" + item.id())
.flatMap(request -> new CallAdapter(this.client.newCall(request.method("PATCH", RequestBody.create(this.encodeBody(item)))
.build())))),
TrackedItem.class);
}
@NonNull
@Override
public Single<TrackedItem> createObject(@NonNull TrackedItem item) {
return this.parseResponse(new ClientBackoff<>(() -> this.createRequest("/objects")
.flatMap(request -> new CallAdapter(this.client.newCall(request.method("POST", RequestBody.create(this.encodeBody(item)))
.build())))),
TrackedItem.class);
}
@NonNull
@Override
public Single<Boolean> deleteObject(long id) {
return this.createRequest("/objects/" + id)
.flatMap(request -> new CallAdapter(this.client.newCall(request.method("DELETE", RequestBody.create(EMPTY_BODY)).build())))
.map(__ -> true);
}
}

View file

@ -0,0 +1,72 @@
package rs.chir.invtracker.api;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import com.google.android.gms.net.CronetProviderInstaller;
import org.chromium.net.CronetEngine;
import org.chromium.net.impl.JavaCronetProvider;
import java.io.File;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.utils.TaskAdapter;
/**
* Utility class for creating a {@link CronetEngine} instance.
*/
public enum CronetEngineProvider {
;
/**
* The HTTPS port number.
*/
public static final int HTTPS_PORT = 443;
/**
* Cache size, currently set to 128MiB.
*/
private static final long CACHE_SIZE = 128 * 1024 * 1024; // 128 MiB
/**
* The {@link CronetEngine} instance.
*/
private static Optional<CronetEngine> ENGINE = Optional.empty();
/**
* Creates a new {@link CronetEngine} instance.
* @param context The context used for installing cronet
* @return A single that emits the {@link CronetEngine} instance
*/
@NonNull
public static Single<CronetEngine> getInstance(@NonNull Context context) {
if (!ENGINE.isPresent()) {
var installTask = CronetProviderInstaller.installProvider(context);
return TaskAdapter.fromTask(installTask)
.map(_void -> new CronetEngine.Builder(context))
.onErrorResumeNext(throwable -> {
Log.e("CronetEngineProvider", "Failed to install Cronet provider " + throwable);
return Single.fromCallable(() -> new JavaCronetProvider(context).createBuilder());
})
.map(builder -> {
var cachedir = context.getCacheDir().getAbsolutePath() + "/cronet";
// We need to create the directory if it doesn't exist
new File(cachedir).mkdirs();
ENGINE = Optional.of(builder
// *.chir.rs supports HTTP/3, avoid connecting to HTTP/2
.addQuicHint("invtracker.chir.rs", HTTPS_PORT, HTTPS_PORT)
// Support HTTP/2 and HTTP/3
.enableHttp2(true)
.enableQuic(true)
// Set the cache storage directory
.setStoragePath(context.getCacheDir().getAbsolutePath() + "/cronet")
// Set the cache size
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE)
.build());
return ENGINE.get();
});
}
return Single.fromCallable(ENGINE::get);
}
}

View file

@ -0,0 +1,12 @@
package rs.chir.invtracker.api;
import androidx.annotation.NonNull;
/**
* An exception that is thrown when the API client is unauthorized.
*/
public class UnauthorizedException extends RuntimeException {
UnauthorizedException(@NonNull String message) {
super(message);
}
}

View file

@ -0,0 +1,6 @@
/**
* API client tools.
*
* You can retrieve an API client using {@link rs.chir.invtracker.client.Application#getClient()}.
*/
package rs.chir.invtracker.api;

View file

@ -0,0 +1,87 @@
package rs.chir.invtracker.client;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.datastore.preferences.core.Preferences;
import androidx.datastore.preferences.rxjava3.RxPreferenceDataStoreBuilder;
import androidx.datastore.rxjava3.RxDataStore;
import com.google.android.material.color.DynamicColors;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.Security;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.api.CachedClientImpl;
import rs.chir.invtracker.api.Client;
import rs.chir.invtracker.api.ClientImpl;
/**
* Android application class
*
* @see android.app.Application
*/
public class Application extends android.app.Application {
/**
* The instance of the API client.
*/
private Optional<Client> client = Optional.empty();
/**
* The instance of the preferences datastore.
*/
private RxDataStore<Preferences> dataStore;
/**
* Returns the Application instance for a context
* @param context the context to get the Application instance for
* @return the Application instance for the context
*/
@NonNull
public static Application getInstance(@NonNull Context context) {
return (Application) context.getApplicationContext();
}
/**
* Called when the application is starting, before any activity, service, or receiver objects (excluding content providers) have been created.
*/
@Override
public void onCreate() {
super.onCreate();
// Needed for deserializing the server pubkey
Security.insertProviderAt(new BouncyCastleProvider(), 1);
Security.setProperty("crypto.policy", "unlimited");
// Enable dynamic colors
DynamicColors.applyToActivitiesIfAvailable(this);
// Create the preferences datastore
this.dataStore = new RxPreferenceDataStoreBuilder(this, /*name=*/ "settings").build();
}
/**
* Returns the preferences datastore.
* @return the preferences datastore
*/
@NonNull
public RxDataStore<Preferences> getDataStore() {
return this.dataStore;
}
/**
* Returns the API client.
* @return A single that completes with the API client.
*/
@NonNull
public Single<Client> getClient() {
if (!this.client.isPresent()) {
return ClientImpl.create(this).map(client -> new CachedClientImpl(this, client)).map(v -> (Client) v).doOnSuccess(c -> {
this.client = Optional.of(c);
});
}
return Single.fromCallable(this.client::get);
}
}

View file

@ -0,0 +1,281 @@
package rs.chir.invtracker.client;
import static android.app.Activity.RESULT_OK;
import static android.provider.MediaStore.ACTION_PICK_IMAGES;
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.view.View;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContract;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.client.databinding.FragmentEditItemBinding;
import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.utils.SinglePermission;
/**
* The Item editing and creation interface.
*/
public class EditItemFragment extends FragmentBase<FragmentEditItemBinding> {
/**
* Argument name for the item ID.
*/
private static final String ARG_ID = "id";
/**
* Argument name for the edit mode.
*/
private static final String ARG_EDIT_MODE = "editMode";
/**
* The item ID.
*/
private long mId;
/**
* The edit mode.
*/
private boolean mEditMode;
/**
* The currently selected image URI.
*/
private Optional<URI> image = Optional.empty();
/**
* Android 13+ image picker activity results launcher.
*/
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
private final ActivityResultLauncher<String> pickImageLauncherTiramisu = this.registerForActivityResult(new ActivityResultContract<String, Uri>() {
/**
* Parses the result from the activity.
* @param i ?
* @param intent the intent that was returned.
* @return the image URI.
*/
@Override
public Uri parseResult(int i, @Nullable Intent intent) {
if (intent == null) {
return null;
}
return intent.getData();
}
/**
* Creates a request intent
* @param context the context to use for the request.
* @param mime the mime type to use for the request.
* @return the request intent.
*/
@NonNull
@Override
public Intent createIntent(@NonNull Context context, String mime) {
var intent = new Intent(ACTION_PICK_IMAGES);
intent.setType(mime);
return intent;
}
}, result -> {
if (result != null) {
// upload image if successful
this.uploadImage(result);
}
});
// For older versions of android, androidx supports the contract natively
/**
* Android pre-13 image picker activity results launcher.
*/
private final ActivityResultLauncher<String> pickImageLauncher = this.registerForActivityResult(new ActivityResultContracts.GetContent(), result -> {
if (result != null) {
this.uploadImage(result);
}
});
/**
* Android camera activity results launcher.
*/
private final ActivityResultLauncher<Uri> takePictureLauncher = this.registerForActivityResult(new ActivityResultContracts.TakePicture(), this::onPictureTaken);
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param itemId the item ID to edit.
* @param editMode <code>true</code> if you want to edit, create otherwise.
* @return A new instance of fragment EditItemFragment.
*/
@NonNull
public static EditItemFragment newInstance(long itemId, boolean editMode) {
EditItemFragment fragment = new EditItemFragment();
Bundle args = new Bundle();
args.putLong(ARG_ID, itemId);
args.putBoolean(ARG_EDIT_MODE, editMode);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (this.getArguments() != null) {
mId = this.getArguments().getLong(ARG_ID);
mEditMode = this.getArguments().getBoolean(ARG_EDIT_MODE);
}
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if (mEditMode) {
// Fetch the item we are editing
this.getBinding().progressBar.setVisibility(View.VISIBLE);
this.subscribe(this.getClient().flatMap(client -> client.getObject(mId)), this::prefillItem);
}
// On older versions of android, we need to request the camera permission to ask the camera for a picture
// Hide the camera button if we don't have the permission
if (!this.hasPermission(Manifest.permission.CAMERA) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
this.getBinding().buttonTakePicture.setVisibility(View.INVISIBLE);
this.subscribe(new SinglePermission(this, Manifest.permission.CAMERA),
__ -> this.getBinding().buttonTakePicture.setVisibility(View.VISIBLE));
}
this.getBinding().buttonTakePicture.setOnClickListener(v -> {
try {
this.takePicture();
} catch (IOException e) {
this.onActionError(e);
}
});
this.getBinding().buttonSelectPicture.setOnClickListener(__ -> this.selectPicture());
this.getBinding().fab.setOnClickListener(__ -> this.saveItem());
}
/**
* Saves the item to the server.
*/
private void saveItem() {
var name = this.getBinding().editTextName.getText().toString();
var description = this.getBinding().editTextDescription.getText().toString();
var item = new TrackedItem(mId, name, description, this.image, Optional.empty());
// Update or create the item
if(mEditMode) {
this.subscribe(this.getClient().flatMap(client -> client.updateObject(item)), this::onActionSuccess);
} else {
this.subscribe(this.getClient().flatMap(client -> client.createObject(item)), this::onActionSuccess);
}
}
/**
* Handler for when the saving of the item is successful.
* @param item the item that was saved.
*/
private void onActionSuccess(TrackedItem trackedItem) {
if(mEditMode) {
// return if we were editing
this.getNavController().navigateUp();
} else {
// navigate to the new item
this.navigate(EditItemFragmentDirections.actionEditItemFragmentToItemDetailFragment(trackedItem.id()));
}
}
/**
* Pick an @{link ActivityResultLauncher} to use for picking an image.
* @return the @{link ActivityResultLauncher} to use for picking an image.
*/
private ActivityResultLauncher<String> getImagePickerLauncher() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return this.pickImageLauncherTiramisu;
} else {
return this.pickImageLauncher;
}
}
/**
* Select an image from the gallery.
*/
private void selectPicture() {
this.getImagePickerLauncher().launch("image/*");
}
/**
* Callback for when an image was taken.
* @param success <code>true</code> if the image was taken successfully.
*/
private void onPictureTaken(@NonNull Boolean success) {
if (success) {
// Recalculate the file name
var filename = String.format("%d.jpg", mId);
var path = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), filename);
// upload
this.uploadImage(Uri.fromFile(path));
}
}
/**
* Upload an image to the server.
* @param uri the source image URI.
*/
private void uploadImage(Uri uri) {
// show the progress bar
this.getBinding().progressBar.setVisibility(View.VISIBLE);
this.subscribe(this.getClient().flatMap(client -> client.upload(uri)),
// successful upload
u -> {
this.image = Optional.of(URI.create(u));
this.getBinding().progressBar.setVisibility(View.INVISIBLE);
},
// error uploading
t -> {
this.onActionError(t);
this.getBinding().progressBar.setVisibility(View.INVISIBLE);
});
}
/**
* Attempts to take a picture with the camera.
* @throws IOException if the camera cannot be accessed.
*/
private void takePicture() throws IOException {
// Calculate the file name
var filename = String.format("%d.jpg", mId);
var path = new File(this.requireContext().getCacheDir(), filename);
// Delete the file if it already exists
if (path.exists()) {
path.delete();
}
// Create the file and mark it for deletion
path.createNewFile();
path.deleteOnExit();
// Create a content:// URI for the file
var file = FileProvider.getUriForFile(this.requireContext(), this.requireContext().getApplicationContext().getPackageName() + ".provider", path);
// take the picture
takePictureLauncher.launch(file);
}
/**
* Prefill the fragment with the given item.
* @param item the item to prefill with.
*/
private void prefillItem(@NonNull TrackedItem trackedItem) {
this.getBinding().progressBar.setVisibility(View.GONE);
this.getBinding().editTextName.setText(trackedItem.name());
this.getBinding().editTextDescription.setText(trackedItem.description());
this.image = trackedItem.picture();
}
}

View file

@ -0,0 +1,283 @@
package rs.chir.invtracker.client;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.view.MenuHost;
import androidx.core.view.MenuProvider;
import androidx.fragment.app.Fragment;
import androidx.navigation.NavController;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;
import androidx.viewbinding.ViewBinding;
import com.google.android.material.snackbar.Snackbar;
import java.lang.reflect.InvocationTargetException;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.schedulers.Schedulers;
import rs.chir.compat.java.util.Objects;
import rs.chir.invtracker.api.Client;
import rs.chir.utils.TypeParameterGetter;
/**
* Base class for all fragments.
* @param <FragmentBinding> the type of the view binding.
*/
public class FragmentBase<FragmentBinding extends ViewBinding> extends Fragment implements TypeParameterGetter<FragmentBinding> {
/**
* The disposable that is used to dispose of all subscriptions on exit
*/
@Nullable
private CompositeDisposable action;
/**
* The fragment binding.
*/
@Nullable
private FragmentBinding _binding;
/**
* The menu provider
*/
@Nullable
private MenuProvider menuProvider;
/**
* Retrieves the fragment binding
* @return the fragment binding
*/
@NonNull
protected FragmentBinding getBinding() {
if (_binding == null) {
throw new IllegalStateException("Fragment is not attached to a view");
}
return _binding;
}
/**
* Sets the fragment binding
* @param binding the fragment binding
*/
protected void setBinding(@NonNull FragmentBinding binding) {
_binding = binding;
}
/**
* Called when the view is created.
* @param inflater the layout inflater
* @param container the container view
* @param savedInstanceState the saved instance state
* @return the inflated view
*/
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return this.onCreateView(inflater, container, savedInstanceState, this.getTypeParameter());
}
/**
* Called when the view is created.
* @param inflater the layout inflater
* @param container the container view
* @param savedInstanceState the saved instance state
* @param clazz the class of the fragment binding
* @return the inflated view
*/
protected View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState, @NonNull Class<FragmentBinding> clazz) {
try {
// We need to find the static method inflate(LayoutInflater, ViewGroup, boolean)
var method = clazz.getMethod("inflate", LayoutInflater.class, ViewGroup.class, boolean.class);
// call the method with the arguments and a null instance
var result = method.invoke(null, inflater, container, false);
this.setBinding(Objects.requireNonNull(clazz.cast(result)));
return this.getBinding().getRoot();
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
throw new IllegalArgumentException("Class " + clazz.getName() + " does not have a public static inflate(LayoutInflater, ViewGroup, boolean) method", e);
}
}
/**
* Adds a disposable to the {@link CompositeDisposable}
* @param disposable the disposable to add
*/
protected void setAction(@NonNull Disposable action) {
if (this.action == null) {
// initialize the composite disposable if necessary
this.action = new CompositeDisposable();
}
this.action.add(action);
}
/**
* Subscribe to the given observable and add it to the {@link CompositeDisposable}
* @param value the observable to subscribe to
* @param consumer the consumer to call when the observable emits an item
*/
protected <T> void subscribe(@NonNull Observable<T> value, @NonNull Consumer<? super T> consumer) {
this.subscribe(value, consumer, this::onActionError);
}
/**
* Subscribe to the given single and add it to the {@link CompositeDisposable}
* @param value the single to subscribe to
* @param consumer the consumer to call when the single emits an item
*/
protected <T> void subscribe(@NonNull Single<T> value, @NonNull Consumer<T> consumer) {
this.subscribe(value.toObservable(), consumer);
}
/**
* Subscribe to the given single and add it to the {@link CompositeDisposable}
* @param value the single to subscribe to
* @param consumer the consumer to call when the observable emits an item
* @param error the consumer to call when an error occurs
*/
protected <T> void subscribe(@NonNull Single<? extends T> value, @NonNull Consumer<? super T> consumer, @NonNull Consumer<? super Throwable> error) {
this.subscribe(value.toObservable(), consumer, error);
}
/**
* Subscribe to the given observable and add it to the {@link CompositeDisposable}
* @param value the observable to subscribe to
* @param consumer the consumer to call when the observable emits an item
* @param error the consumer to call when an error occurs
*/
protected <T> void subscribe(@NonNull Observable<T> value, @NonNull Consumer<? super T> consumer, @NonNull Consumer<? super Throwable> error) {
this.setAction(value.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(consumer, error));
}
/**
* Called when the fragment is attached to the view.
* @param view the view
* @param savedInstanceState the saved instance state
*/
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
this.menuProvider = this.getMenuProvider();
if (this.menuProvider != null) {
this.getMenuHost().addMenuProvider(this.menuProvider);
}
}
/**
* Creates this fragments menu provider.
* @return the menu provider, or null if no menu provider is needed
*/
@Nullable
protected MenuProvider getMenuProvider() {
return null;
}
/**
* Called when the fragment is paused
*/
@Override
public void onPause() {
super.onPause();
if (this.action != null)
this.action.dispose();
this.action = null;
}
/**
* Called when the fragment is detached from the view.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
this._binding = null;
if (this.menuProvider != null) {
this.getMenuHost().removeMenuProvider(this.menuProvider);
}
}
/**
* Returns the menu host
* @return the menu host
*/
@NonNull
protected MenuHost getMenuHost() {
var activity = this.requireActivity();
return activity;
}
/**
* Returns the application
* @return the application
*/
@NonNull
protected Application getApplication() {
return Application.getInstance(this.requireContext());
}
/**
* Returns the API client
* @return the API client
*/
@NonNull
protected Single<Client> getClient() {
return this.getApplication().getClient();
}
/**
* Returns the navigation controller
* @return the navigation controller
*/
@NonNull
protected NavController getNavController() {
return Navigation.findNavController(this.getBinding().getRoot());
}
/**
* Navigate to the given fragment
* @param id the action id
*/
void navigate(@IdRes int id) {
this.getNavController().navigate(id);
}
/**
* Checks if the app has the given permission
* @param permission the permission to check
* @return true if the app has the permission, false otherwise
*/
protected boolean hasPermission(@NonNull String s) {
return ContextCompat.checkSelfPermission(this.requireContext(), s) == android.content.pm.PackageManager.PERMISSION_GRANTED;
}
/**
* Called on action error
* @param throwable the throwable
*/
protected void onActionError(@NonNull Throwable throwable) {
throwable.printStackTrace();
Snackbar.make(this.getBinding().getRoot(), R.string.error_generic, Snackbar.LENGTH_LONG)
.setBackgroundTint(ContextCompat.getColor(this.requireContext(), com.google.android.material.R.color.design_default_color_error))
.show();
}
/**
* Navigates to the given fragment
* @param fragmentDirection the fragment direction
*/
void navigate(NavDirections fragmentDirection) {
this.getNavController().navigate(fragmentDirection);
}
}

View file

@ -0,0 +1,223 @@
package rs.chir.invtracker.client;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.view.MenuProvider;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.client.databinding.FragmentItemDetailBinding;
import rs.chir.invtracker.client.model.LocationListAdapter;
import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.utils.ConnectionScorer;
/**
* The fragment that shows details about the item
*/
public class ItemDetailFragment extends FragmentBase<FragmentItemDetailBinding> {
/**
* Item id argument
*/
private static final String ARG_ID = "id";
/**
* Item id
*/
private long mId;
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param itemId The id of the item to show.
* @return A new instance of fragment ItemDetailFragment.
*/
@NonNull
public static ItemDetailFragment newInstance(long itemId) {
ItemDetailFragment fragment = new ItemDetailFragment();
Bundle args = new Bundle();
args.putLong(ARG_ID, itemId);
fragment.setArguments(args);
return fragment;
}
/**
* Called when the fragment is created.
* @param savedInstanceState The saved instance state.
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (this.getArguments() != null) {
mId = this.getArguments().getLong(ARG_ID);
}
}
/**
* Called when the fragment is attached to the activity.
* @param view The view.
* @param savedInstanceState The saved instance state.
*/
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
this.getBinding().fab.setOnClickListener(__ -> {
this.navigate(ItemDetailFragmentDirections.actionItemDetailFragmentToEditItemFragment(mId));
});
}
@Nullable
@Override
protected MenuProvider getMenuProvider() {
return new ItemDetailMenuProvider(this);
}
/**
* Called when the fragment is resumed
*/
@Override
public void onResume() {
super.onResume();
this.getBinding().nestedScrollView.setVisibility(View.GONE);
// Reload the item
this.subscribe(this.getClient().flatMap(client -> client.getObject(this.mId)), this::itemLoaded);
}
@Override
protected void onActionError(@NonNull Throwable throwable) {
Log.e("ItemDetailFragment", "Error loading item", throwable);
Toast.makeText(this.requireContext(), "Error loading item" + throwable.getMessage(), Toast.LENGTH_LONG).show();
// Return to the previous fragment on error
this.getNavController().navigateUp();
}
/**
* Called when the item is loaded
*
* @param item The item
*/
private void itemLoaded(@NonNull TrackedItem trackedItem) {
var binding = this.getBinding();
binding.nestedScrollView.setVisibility(View.VISIBLE);
binding.itemName.setText(trackedItem.name());
binding.itemDescription.setText(trackedItem.description());
// Display the image if present and the connection is good
trackedItem.picture().flatMap(this::getImageIfPresent).ifPresentOrElse(binding.itemPicture::setImageBitmap, () -> {
trackedItem.picture().ifPresent(image -> {
if (ConnectionScorer.score(this.requireContext()) == ConnectionScorer.ConnectionRating.CONNECTED) {
this.fetchImage(image);
}
});
});
// Initialize the location history recycler view
binding.locationHistory.setLayoutManager(new LinearLayoutManager(this.requireContext()));
binding.locationHistory.setAdapter(new LocationListAdapter(this.requireContext(), this.mId));
binding.locationHistory.setHasTransientState(true);
}
/**
* Fetches the image from the server
* @param image The image URI
*/
private void fetchImage(@NonNull URI image) {
String imageUrl = image.toString();
this.subscribe(this.getClient().flatMap(client -> client.fetchURL(imageUrl)), resp -> this.imageLoaded(image, resp));
}
/**
* Called when the URL is loaded
* @param image The image URI
* @param inputStream the response body
*/
private void imageLoaded(@NonNull URI image, @NonNull InputStream inputStream) {
// Save the image to the cache
var fileName = image.getPath().substring(image.getPath().lastIndexOf('/'));
var path = this.requireActivity().getCacheDir().getAbsolutePath();
new File(path, "images").mkdirs();
path += new File(new File(path, "images"), fileName);
// TODO: check if there is a better buffer size to use
// ideally I would use {@link java.nio.file.Files#copy(InputStream, Path, CopyOption...)} but it is not available on API level 21
var buffer = new byte[4096];
// Copy the entire stream to file
try (var outputStream = new FileOutputStream(path)) {
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
} catch (IOException e) {
Log.e("ItemDetailFragment", "Error saving image", e);
}
// Load the image
this.getImageIfPresent(image).ifPresent(this.getBinding().itemPicture::setImageBitmap);
}
/**
* Loads and returns the image if present
* @param image The image URI
* @return The image if present
*/
private Optional<Bitmap> getImageIfPresent(@NonNull URI uri) {
var fileName = uri.getPath().substring(uri.getPath().lastIndexOf('/'));
var path = this.requireActivity().getCacheDir().getAbsolutePath();
var file = new File(new File(path, "images"), fileName);
// Check if the image is present in the cache
if (file.exists()) {
try {
var inputStream = new FileInputStream(path);
return Optional.of(BitmapFactory.decodeStream(inputStream));
} catch (FileNotFoundException e) { // Should not happen
Log.e("ItemDetailFragment", "Error loading image", e);
return Optional.empty();
}
} else {
return Optional.empty();
}
}
/**
* Returns the item id
* @return The item id
*/
long getmId() {
return mId;
}
/**
* Asks the user if they want to delete the item
*/
public void delete() {
var builder = new AlertDialog.Builder(this.requireContext());
builder.setMessage(R.string.delete_warning)
.setPositiveButton(R.string.yes, (__1, __2) -> this.reallyDelete())
.setNegativeButton(R.string.no, (__1, __2) -> {});
builder.create().show();
}
/**
* Deletes the item
*/
private void reallyDelete() {
this.subscribe(this.getClient().flatMap(client -> client.deleteObject(this.mId)), __ -> {
Toast.makeText(this.requireContext(), "Item deleted", Toast.LENGTH_LONG).show();
this.getNavController().navigateUp();
});
}
}

View file

@ -0,0 +1,57 @@
package rs.chir.invtracker.client;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
/**
* The menu provider for {@link ItemDetailFragment}
*/
class ItemDetailMenuProvider implements MenuProvider {
/**
* The fragment
*/
private final ItemDetailFragment itemDetailFragment;
/**
* Creates a new instance of {@link ItemDetailMenuProvider}
* @param itemDetailFragment the fragment
*/
ItemDetailMenuProvider(ItemDetailFragment itemDetailFragment) {
this.itemDetailFragment = itemDetailFragment;
}
/**
* Called when the menu is created
* @param menu the menu
* @param inflater the menu inflater
*/
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) {
menuInflater.inflate(R.menu.menu_item_detail, menu);
}
/**
* Called when an item is selected
* @param item the item
* @return true if the item was handled, false otherwise
*/
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
// Retrieve and check the item ID
var itemId = menuItem.getItemId();
if (itemId == R.id.action_show_qr_code) {
// Show the QR code
itemDetailFragment.navigate(ItemDetailFragmentDirections.actionItemDetailFragmentToQRCodeFagment(itemDetailFragment.getmId()));
return true;
} else if (itemId == R.id.action_delete_item) {
// Delete the item
itemDetailFragment.delete();
return true;
}
return false;
}
}

View file

@ -0,0 +1,15 @@
package rs.chir.invtracker.client;
/**
* Filter modes for {@link NearbyFragment}
*/
public enum ListFilterMode {
/**
* Show items within 0.01° of the current location.
*/
NEARBY,
/**
* Show all items.
*/
ALL
}

View file

@ -0,0 +1,39 @@
package rs.chir.invtracker.client;
import android.view.View;
import androidx.annotation.NonNull;
import rs.chir.invtracker.client.databinding.FragmentLoadBinding;
import rs.chir.invtracker.client.model.APIKey;
/**
* The fragment that shows the loading screen and redirects to the login screen if the API key is not set.
*/
public class LoadFragment extends FragmentBase<FragmentLoadBinding> {
@Override
public void onResume() {
super.onResume();
this.subscribe(APIKey.hasToken(this.requireContext()), this::tokenResponse);
}
/**
* Called when the API key is set.
* @param hasToken {@code true} if the API key is set, {@code false} otherwise.
*/
private void tokenResponse(boolean hasToken) {
if (hasToken) {
this.navigate(R.id.action_loadFragment_to_nearbyFragment);
} else {
this.navigate(R.id.action_loadFragment_to_loginFragment);
}
}
@Override
protected void onActionError(@NonNull Throwable throwable) {
throwable.printStackTrace();
this.getBinding().progressBar.setVisibility(View.GONE);
this.getBinding().loadErrorMessageView.setText(R.string.error_token_verify);
this.getBinding().loadErrorMessageView.setVisibility(View.VISIBLE);
}
}

View file

@ -0,0 +1,74 @@
package rs.chir.invtracker.client;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.client.databinding.FragmentLoginBinding;
import rs.chir.invtracker.client.model.APIKey;
import rs.chir.invtracker.model.PasetoToken;
/**
* The fragment that shows the login screen.
*/
public class LoginFragment extends FragmentBase<FragmentLoginBinding> {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
this.getBinding().loginButton.setOnClickListener(this::login);
}
/**
* Disable login elements and show progress bar.
*/
private void disableLogin() {
this.getBinding().loginButton.setEnabled(false);
this.getBinding().progressBar.setVisibility(View.VISIBLE);
this.getBinding().loginError.setVisibility(View.INVISIBLE);
}
/**
* Enable login elements and hide progress bar.
*/
private void enableLogin() {
this.getBinding().loginButton.setEnabled(true);
this.getBinding().progressBar.setVisibility(View.INVISIBLE);
}
/**
* Called when the login button is clicked.
* @param view The view.
*/
private void login(View view) {
this.disableLogin();
this.subscribe(this.getClient()
.flatMap(client -> {
var username = this.getBinding().loginUsername.getText().toString();
var password = this.getBinding().loginPassword.getText().toString();
return client.login(username, password);
}), this::loginResponse);
}
/**
* Called when the login response is received.
* @param token The token.
*/
private void loginResponse(@NonNull PasetoToken pasetoToken) {
this.subscribe(
APIKey.setAPIToken(this.requireContext(), Optional.of(pasetoToken.token())),
__ -> {
this.enableLogin();
this.navigate(R.id.action_loginFragment_to_nearbyFragment);
});
}
@Override
protected void onActionError(@NonNull Throwable throwable) {
throwable.printStackTrace();
this.enableLogin();
this.getBinding().loginError.setVisibility(View.VISIBLE);
}
}

View file

@ -0,0 +1,56 @@
package rs.chir.invtracker.client;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.navigation.Navigation;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;
import rs.chir.invtracker.client.databinding.ActivityMainBinding;
/**
* The main activity.
*/
public class MainActivity extends AppCompatActivity {
/**
* The application bar configuration.
*/
private AppBarConfiguration appBarConfiguration;
/**
* The activity binding.
*/
private ActivityMainBinding binding;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.binding = ActivityMainBinding.inflate(this.getLayoutInflater());
this.setContentView(this.binding.getRoot());
this.setSupportActionBar(this.binding.toolbar);
var navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
this.appBarConfiguration = new AppBarConfiguration.Builder(R.id.loadFragment, R.id.loginFragment, R.id.nearbyFragment).build();
NavigationUI.setupActionBarWithNavController(this, navController, this.appBarConfiguration);
}
/**
* Returns the activity binding.
* @return The activity binding.
*/
@NonNull
public ActivityMainBinding getBinding() {
return this.binding;
}
@Override
public boolean onSupportNavigateUp() {
var navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
return NavigationUI.navigateUp(navController, this.appBarConfiguration) || super.onSupportNavigateUp();
}
}

View file

@ -0,0 +1,152 @@
package rs.chir.invtracker.client;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.res.ResourcesCompat;
import androidx.core.view.MenuProvider;
import androidx.navigation.fragment.NavHostFragment;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationServices;
import org.osmdroid.api.IGeoPoint;
import org.osmdroid.config.Configuration;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.overlay.Marker;
import java.util.ArrayList;
import java.util.List;
import rs.chir.invtracker.client.databinding.FragmentMapBinding;
import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.utils.SingleLocation;
/**
* Fragment that displays a map with the locations of all tracked items.
*/
public class MapFragment extends FragmentBase<FragmentMapBinding> {
/**
* The fused location provider client.
*/
private FusedLocationProviderClient fusedLocationClient;
/**
* The persisted map center point.
*/
private IGeoPoint currentCenter;
/**
* The persisted map zoom level.
*/
private double currentZoom;
/**
* Attempts to restore state, or initializes the map if it is the first time the fragment is
* created.
*/
private void tryRestoreState() {
// always update markers
this.updateMarkers();
// currentCenter is null if the fragment is created for the first time
if (currentCenter != null) {
this.getBinding().map.getController().setCenter(currentCenter);
this.getBinding().map.getController().setZoom(currentZoom);
return;
}
// we failed, recalculate state
var map = this.getBinding().map;
this.subscribe(SingleLocation.getSingleLocation(fusedLocationClient, this.requireContext()),
location -> {
var controller = map.getController();
controller.setZoom(15.0);
controller.setCenter(new GeoPoint(location.latitude(), location.longitude()));
}
);
}
/**
* Creates and adds a marker for a tracked item
* @param item the tracked item to add a marker for
*/
private void addMarker(@NonNull TrackedItem item) {
var marker = new Marker(this.getBinding().map);
// Set marker position
marker.setPosition(new GeoPoint(item.lastKnownLocation().get().latitude(), item.lastKnownLocation().get().longitude()));
// Set marker title
marker.setTitle(item.name());
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM);
// Load resource for marker icon
var drawable = ResourcesCompat.getDrawable(this.getResources(), R.drawable.ic_baseline_map_marker_48, this.requireActivity().getTheme());
marker.setIcon(drawable);
marker.setOnMarkerClickListener((index, item2) -> {
// Move to the item detail fragment for this item
NavHostFragment.findNavController(this).navigate(MapFragmentDirections.actionMapFragmentToItemDetailFragment(item.id()));
return true;
});
// Add the marker and refresh the map
this.getBinding().map.getOverlays().add(marker);
this.getBinding().map.invalidate();
}
/**
* Fetches and updates the markers on the map.
*/
private void updateMarkers() {
var map = this.getBinding().map;
map.getOverlays().clear();
this.subscribe(this.getClient().toObservable()
.flatMap(client -> client.streamObjects().toObservable()),
this::addMarker);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
var map = this.getBinding().map;
// Set up the map
var config = Configuration.getInstance();
config.setUserAgentValue(this.requireActivity().getPackageName()); // Set the user agent for mapnik
config.setOsmdroidBasePath(this.requireActivity().getFilesDir()); // Technically not necessary, but recommended
config.setOsmdroidTileCache(this.requireActivity().getCacheDir()); // Set the cache directory
map.setMultiTouchControls(true);
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this.requireActivity()); // Initialize the fused location provider
this.tryRestoreState(); // Restore state if possible
this.getBinding().fab.setOnClickListener(__ -> this.navigate(R.id.action_mapFragment_to_QRScanFragment));
}
// propagate onPause and onResume to the map
@Override
public void onResume() {
super.onResume();
this.getBinding().map.onResume();
}
@Override
public void onPause() {
super.onPause();
this.getBinding().map.onPause();
}
@Override
public void onDestroyView() {
// Persist map state
currentCenter = this.getBinding().map.getMapCenter();
currentZoom = this.getBinding().map.getZoomLevelDouble();
super.onDestroyView();
}
@Nullable
@Override
protected MenuProvider getMenuProvider() {
return new MapMenuProvider(this);
}
}

View file

@ -0,0 +1,39 @@
package rs.chir.invtracker.client;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
public class MapMenuProvider implements MenuProvider {
private final MapFragment mapFragment;
public MapMenuProvider(MapFragment mapFragment) {
this.mapFragment = mapFragment;
}
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) {
menuInflater.inflate(R.menu.menu_map, menu);
}
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
int itemId = menuItem.getItemId();
if (itemId == R.id.action_settings) {
this.mapFragment.navigate(R.id.action_mapFragment_to_settingsFragment);
return true;
} else if (itemId == R.id.action_list_view) {
this.mapFragment.getNavController().navigateUp();
return true;
} else if (itemId == R.id.action_create_item) {
this.mapFragment.navigate(MapFragmentDirections.actionMapFragmentToEditItemFragment(0));
return true;
}
return false;
}
}

View file

@ -0,0 +1,94 @@
package rs.chir.invtracker.client;
import android.Manifest;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.MenuProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.google.android.gms.location.LocationServices;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.client.databinding.FragmentNearbyBinding;
import rs.chir.invtracker.client.model.ItemListAdapter;
import rs.chir.invtracker.model.GeoLocation;
import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.utils.SingleLocation;
import rs.chir.invtracker.utils.SinglePermission;
public class NearbyFragment extends FragmentBase<FragmentNearbyBinding> {
private ListFilterMode filterMode = ListFilterMode.NEARBY;
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if (this.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
this.fetchData();
} else {
this.subscribe(new SinglePermission(this, Manifest.permission.ACCESS_FINE_LOCATION), __ -> this.fetchData());
}
this.getBinding().nearbyRecyclerView.setLayoutManager(new LinearLayoutManager(this.getContext()));
this.getBinding().nearbyRecyclerView.addOnItemTouchListener(new NearbyOnTouchListener(this));
this.getBinding().fab.setOnClickListener(__ -> this.navigate(R.id.action_nearbyFragment_to_QRScanFragment));
}
@Nullable
@Override
protected MenuProvider getMenuProvider() {
return new NearbyMenuProvider(this);
}
@Override
protected void onActionError(@NonNull Throwable throwable) {
super.onActionError(throwable);
this.switchFilter(ListFilterMode.ALL);
}
void switchFilter(ListFilterMode filterMode) {
this.filterMode = filterMode;
this.getMenuHost().invalidateMenu();
this.getBinding().nearbyRecyclerView.setAdapter(null);
this.fetchData();
}
private void fetchData() {
Log.i("NearbyFragment", "fetchData");
switch (this.filterMode) {
case ALL -> this.getBinding().nearbyRecyclerView.setAdapter(new ItemListAdapter(this.requireContext()));
case NEARBY -> {
var fusedLocationClient = LocationServices.getFusedLocationProviderClient(this.requireActivity());
this.subscribe(SingleLocation.getSingleLocation(fusedLocationClient, this.requireContext()), this::onLocationResponse);
}
}
}
private void onLocationResponse(@NonNull GeoLocation location) {
double latitude = location.latitude();
double longitude = location.longitude();
double southBound = latitude - 0.001;
double northBound = latitude + 0.001;
double westBound = longitude - 0.001;
double eastBound = longitude + 0.001;
this.getBinding()
.nearbyRecyclerView
.setAdapter(new ItemListAdapter(
this.getContext(),
Optional.of(new GeoRect(
new GeoLocation(southBound, westBound, location.altitude(), location.locationTime()),
new GeoLocation(northBound, eastBound, location.altitude(), location.locationTime())
))
));
}
ListFilterMode getFilterMode() {
return filterMode;
}
}

View file

@ -0,0 +1,60 @@
package rs.chir.invtracker.client;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
import androidx.navigation.fragment.NavHostFragment;
class NearbyMenuProvider implements MenuProvider {
private final NearbyFragment nearbyFragment;
public NearbyMenuProvider(NearbyFragment nearbyFragment) {
this.nearbyFragment = nearbyFragment;
}
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) {
menuInflater.inflate(R.menu.menu_nearby, menu);
}
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
int itemId = menuItem.getItemId();
if (itemId == R.id.action_settings) {
nearbyFragment.navigate(R.id.action_nearbyFragment_to_settingsFragment);
return true;
} else if (itemId == R.id.action_local_only) {
nearbyFragment.switchFilter(ListFilterMode.NEARBY);
return true;
} else if (itemId == R.id.action_list_all) {
nearbyFragment.switchFilter(ListFilterMode.ALL);
return true;
} else if (itemId == R.id.action_show_map) {
nearbyFragment.navigate(R.id.action_nearbyFragment_to_mapFragment);
return true;
} else if (itemId == R.id.action_create_item) {
nearbyFragment.navigate(NearbyFragmentDirections.actionNearbyFragmentToEditItemFragment(0));
}
return false;
}
@Override
public void onPrepareMenu(@NonNull Menu menu) {
MenuProvider.super.onPrepareMenu(menu);
switch (nearbyFragment.getFilterMode()) {
case ALL -> {
menu.findItem(R.id.action_local_only).setVisible(true);
menu.findItem(R.id.action_list_all).setVisible(false);
}
case NEARBY -> {
menu.findItem(R.id.action_local_only).setVisible(false);
menu.findItem(R.id.action_list_all).setVisible(true);
}
}
}
}

View file

@ -0,0 +1,41 @@
package rs.chir.invtracker.client;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import rs.chir.invtracker.client.model.ItemListAdapter;
class NearbyOnTouchListener implements RecyclerView.OnItemTouchListener {
private final NearbyFragment nearbyFragment;
public NearbyOnTouchListener(NearbyFragment nearbyFragment) {
this.nearbyFragment = nearbyFragment;
}
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
var childView = rv.findChildViewUnder(e.getX(), e.getY());
var adapter = (ItemListAdapter) rv.getAdapter();
if (childView != null && adapter != null && e.getAction() == MotionEvent.ACTION_UP) {
var position = rv.getChildAdapterPosition(childView);
if (position != RecyclerView.NO_POSITION) {
long id = adapter.positionToId(position);
nearbyFragment.navigate(NearbyFragmentDirections.actionNearbyFragmentToItemDetailFragment(id));
return true;
}
}
return false;
}
@Override
public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
}

View file

@ -0,0 +1,116 @@
package rs.chir.invtracker.client;
import android.Manifest;
import android.graphics.Bitmap;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.print.PrintHelper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.io.IOException;
import java.util.Map;
import rs.chir.invtracker.client.databinding.FragmentQRCodeBinding;
import rs.chir.invtracker.utils.BitmapStorage;
import rs.chir.invtracker.utils.SinglePermission;
/**
* A simple {@link Fragment} subclass.
* Use the {@link QRCodeFragment#newInstance} factory method to
* create an instance of this fragment.
*/
public class QRCodeFragment extends FragmentBase<FragmentQRCodeBinding> {
private static final String ARG_ID = "id";
private long mId;
private Bitmap bitmap;
public QRCodeFragment() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param itemId The id of the item to show.
* @return A new instance of fragment ItemDetailFragment.
*/
@NonNull
public static QRCodeFragment newInstance(long itemId) {
QRCodeFragment fragment = new QRCodeFragment();
Bundle args = new Bundle();
args.putLong(ARG_ID, itemId);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (this.getArguments() != null) {
mId = this.getArguments().getLong(ARG_ID);
}
}
private void generateQRCode() throws WriterException {
var hints = Map.of(
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H,
EncodeHintType.CHARACTER_SET, "UTF-8");
var writer = new QRCodeWriter();
var matrix = writer.encode("https://invtracker.chir.rs/objects/" + mId, BarcodeFormat.QR_CODE, 256, 256, hints);
var bitmap = Bitmap.createBitmap(matrix.getWidth(), matrix.getHeight(), Bitmap.Config.RGB_565);
for (int x = 0; x < matrix.getWidth(); x++) {
for (int y = 0; y < matrix.getHeight(); y++) {
bitmap.setPixel(x, y, matrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
}
}
this.bitmap = bitmap;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
try {
this.generateQRCode();
} catch (WriterException e) {
this.onActionError(e);
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
this.getBinding().saveToGalleryButton.setEnabled(false);
this.subscribe(new SinglePermission(this.requireActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE),
__ -> this.getBinding().saveToGalleryButton.setEnabled(true),
__ -> this.getBinding().saveToGalleryButton.setEnabled(false));
}
this.getBinding().imageView.setImageBitmap(this.bitmap);
this.getBinding().imageView.setContentDescription("https://invtracker.chir.rs/objects/" + mId);
this.getBinding().saveToGalleryButton.setOnClickListener(__ -> {
try {
BitmapStorage.create(this.requireContext()).storeBitmap(this.bitmap, "qr_code_" + mId);
} catch (IOException e) {
this.onActionError(e);
}
this.getNavController().navigateUp();
});
this.getBinding().print.setOnClickListener(__ -> {
var printHelper = new PrintHelper(this.requireContext());
printHelper.printBitmap("QR code", this.bitmap);
this.getNavController().navigateUp();
});
}
}

View file

@ -0,0 +1,193 @@
package rs.chir.invtracker.client;
import android.Manifest;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.camera.core.AspectRatio;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.core.content.ContextCompat;
import com.google.android.gms.location.LocationServices;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.snackbar.BaseTransientBottomBar;
import com.google.android.material.snackbar.Snackbar;
import org.jetbrains.annotations.Contract;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import rs.chir.cv.QRAnalyzer;
import rs.chir.invtracker.client.databinding.FragmentQrScanBinding;
import rs.chir.invtracker.model.GeoLocation;
import rs.chir.invtracker.utils.ListenableFutureAdapter;
import rs.chir.invtracker.utils.SingleLocation;
import rs.chir.invtracker.utils.SinglePermission;
import rs.chir.utils.math.Vec2;
public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implements QRAnalyzer.QRAnalyzerListener {
private final Pattern qrPattern = Pattern.compile("^https://invtracker.chir.rs/objects/(\\d+)$");
private final AtomicBoolean hasScanned = new AtomicBoolean(false);
private boolean hasPaused = false;
private void initViewfinderBox() {
var viewfinderSquare = this.getBinding().viewfinderSquare;
int primaryColor = MaterialColors.getColor(viewfinderSquare, com.google.android.material.R.attr.colorPrimary);
var border = new GradientDrawable();
border.setColor(0);
border.setStroke(8, primaryColor);
viewfinderSquare.setBackground(border);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
this.initViewfinderBox();
// Make sure we have the needed permissions
this.subscribe(new SinglePermission(this, Manifest.permission.CAMERA), __ -> this.startCamera());
}
@Override
protected void onActionError(@NonNull Throwable throwable) {
super.onActionError(throwable);
this.getNavController().popBackStack();
}
@Override
public void onPause() {
super.onPause();
this.hasPaused = true;
}
@Override
public void onResume() {
super.onResume();
if (this.hasPaused) {
this.hasPaused = false;
this.startCamera();
}
}
@NonNull
@Contract(" -> new")
private Vec2<Integer> getScreenSize() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
var metrics = this.requireActivity().getWindowManager().getCurrentWindowMetrics();
var bounds = metrics.getBounds();
return new Vec2<>(bounds.width(), bounds.height());
} else {
var metrics = new DisplayMetrics();
this.requireActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics);
return new Vec2<>(metrics.widthPixels, metrics.heightPixels);
}
}
private void startCamera() {
this.subscribe(new ListenableFutureAdapter<>(ProcessCameraProvider.getInstance(this.requireContext()), ContextCompat.getMainExecutor(this.requireContext())), this::openedCamera);
}
@Override
public void onQRFound(@NonNull String qrCode) {
if (this.hasScanned.getAndSet(true)) {
return;
}
var matcher = this.qrPattern.matcher(qrCode);
if (matcher.find()) {
var group = matcher.group(1);
if (group == null)
return;
// while we are determining the location, we dont want to show the user the viewfinder
this.hideUI(true);
var id = Long.parseLong(group);
// TODO: request location permission
if (this.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
var fusedLocationClient = LocationServices.getFusedLocationProviderClient(this.requireActivity());
this.subscribe(SingleLocation.getNextLocation(fusedLocationClient, this.requireContext()), location -> this.updateLocation(id, location), err -> {
err.printStackTrace();
this.showDetailView(id);
});
} else {
this.showDetailView(id);
}
} else {
Snackbar.make(this.getBinding().getRoot(), "Invalid QR code", BaseTransientBottomBar.LENGTH_LONG).show();
this.hasScanned.set(false);
}
}
private void hideUI(boolean b) {
var visibility = b ? View.INVISIBLE : View.VISIBLE;
this.getBinding().fab.setVisibility(visibility);
this.getBinding().viewfinderSquare.setVisibility(visibility);
this.getBinding().viewFinder.setVisibility(visibility);
}
private void updateLocation(long id, GeoLocation location) {
this.subscribe(
this.getClient()
.flatMap(client -> client.updateLocation(id, location)),
__ -> this.showDetailView(id), err -> {
err.printStackTrace();
Snackbar.make(this.getBinding().getRoot(), "Error: " + err.getMessage(), BaseTransientBottomBar.LENGTH_LONG).show();
this.hasScanned.set(false);
this.hideUI(false);
}
);
}
private void showDetailView(long id) {
this.navigate(QRScanFragmentDirections.actionQRScanFragmentToItemDetailFragment(id));
}
private void openedCamera(@NonNull ProcessCameraProvider cameraProvider) {
var rotation = this.getBinding().viewFinder.getDisplay().getRotation();
var cameraSelector = new CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build();
var preview = new Preview.Builder()
.setTargetAspectRatio(AspectRatio.RATIO_16_9)
.setTargetRotation(rotation)
.build();
var imageAnalysis = new ImageAnalysis.Builder()
.setTargetRotation(rotation)
.setTargetAspectRatio(AspectRatio.RATIO_16_9)
.build();
imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor(), new QRAnalyzer(this));
cameraProvider.unbindAll();
try {
var camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis);
preview.setSurfaceProvider(this.getBinding().viewFinder.getSurfaceProvider());
// Add the flashlight button if the device has one
if (camera.getCameraInfo().hasFlashUnit()) {
this.getBinding().fab.setVisibility(View.VISIBLE);
AtomicBoolean flash = new AtomicBoolean(false);
this.getBinding().fab.setOnClickListener(v -> {
var control = camera.getCameraControl();
if (flash.get()) {
control.enableTorch(false);
flash.set(false);
} else {
control.enableTorch(true);
flash.set(true);
}
});
}
} catch (RuntimeException e) {
e.printStackTrace();
Snackbar.make(this.getBinding().getRoot(), "Error: " + e.getMessage(), BaseTransientBottomBar.LENGTH_LONG).show();
}
}
}

View file

@ -0,0 +1,43 @@
package rs.chir.invtracker.client;
import android.os.Bundle;
import androidx.preference.ListPreference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SeekBarPreference;
import rs.chir.compat.java.util.function.Consumer;
public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
this.setPreferencesFromResource(R.xml.root_preferences, rootKey);
ListPreference listPreference = this.findPreference("location_privacy");
if (listPreference != null) {
listPreference.setSummaryProvider(__ -> {
var packageName = this.getContext().getPackageName();
var resId = this.getResources().getIdentifier(listPreference.getValue(), "string", packageName);
return this.getString(resId);
});
}
SeekBarPreference seekBarPreference = this.findPreference("location_accuracy");
if (seekBarPreference != null) {
seekBarPreference.setUpdatesContinuously(true);
Consumer<Integer> setSeekbarPercentage = value -> {
var summary = "???";
var stringArray = this.getResources().getStringArray(R.array.demo_privacy_accuracy);
if (value >= 0 && value < stringArray.length) {
summary = stringArray[value];
}
seekBarPreference.setSummary(summary);
};
setSeekbarPercentage.accept(seekBarPreference.getValue());
seekBarPreference.setOnPreferenceChangeListener((preference, newValue) -> {
setSeekbarPercentage.accept((Integer) newValue);
return true;
});
}
}
}

View file

@ -0,0 +1,73 @@
package rs.chir.invtracker.client.model;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.datastore.preferences.core.Preferences;
import androidx.datastore.preferences.core.PreferencesKeys;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.Objects;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.client.Application;
/**
* Utility class for saving and loading the API key from the preferences datastore.
*/
public enum APIKey {
;
/**
* The key for the API key in the preferences datastore.
*/
private static final Preferences.Key<String> KEY = PreferencesKeys.stringKey("api_key");
/**
* Sets the API key in the preferences datastore.
*
* @param context the context to use for accessing the preferences datastore.
* @param key the API key to set.
* @return a {@link Single} that completes when the API key is set.
*/
@NonNull
public static Single<Preferences> setAPIToken(@NonNull Context context, @NonNull Optional<String> token) {
return Application.getInstance(context).getDataStore().updateDataAsync(prefs -> {
var mutable = prefs.toMutablePreferences();
if (token.isPresent()) {
mutable.set(KEY, token.get());
} else {
mutable.remove(KEY);
}
return Single.just(mutable);
});
}
/**
* Checks if the API key is set in the preferences datastore.
*
* @param context the context to use for accessing the preferences datastore.
* @return a {@link Single} that completes with {@code true} if the API key is set, {@code false} otherwise.
*/
@NonNull
public static Single<Boolean> hasToken(@NonNull Context context) {
return Application.getInstance(context).getDataStore().data().map(prefs -> prefs.contains(KEY)).firstOrError();
}
/**
* Gets the API key from the preferences datastore.
*
* @param context the context to use for accessing the preferences datastore.
* @return a {@link Single} that completes with the API key if it is set, and an error otherwise.
*/
@NonNull
public static Single<String> get(@NonNull Context context) {
var token = Application
.getInstance(context)
.getDataStore()
.data()
.map(prefs -> Optional.ofNullable(prefs.get(KEY)))
.filter(Optional::isPresent)
.firstOrError()
.map(Optional::get);
return token;
}
}

View file

@ -0,0 +1,196 @@
package rs.chir.invtracker.client.model;
import android.content.Context;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import rs.chir.compat.java.util.Optional;
import rs.chir.invtracker.api.Client;
import rs.chir.invtracker.client.Application;
import rs.chir.invtracker.client.R;
import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.model.TrackedItem;
/**
* Recycler view adapter for the list of tracked items.
*/
public class ItemListAdapter extends RecyclerView.Adapter<ItemListAdapter.ViewHolder> {
/**
* A mapping from list IDs to item IDs
*/
private final List<Long> idMapping = new ArrayList<>();
/**
* The list of tracked items.
*/
private final Map<Long, TrackedItem> items = new HashMap<>();
/**
* View holders that have been requested to be updated, but are still waiting for data.
*/
private final Map<Integer, ViewHolder> positionsToUpdate = new HashMap<>();
/**
* Creates a new adapter without a bound.
*
* @param context the context to use for accessing the client.
*/
public ItemListAdapter(@NonNull Context context) {
this(context, Optional.empty());
}
/**
* Creates a new adapter with a bound.
*
* @param context the context to use for accessing the client.
* @param bound the geo bound to use for filtering the items.
*/
public ItemListAdapter(@NonNull Context context, @NonNull Optional<GeoRect> bounds) {
Application.getInstance(context)
.getClient().toObservable().flatMap(client -> client.streamObjects(bounds).toObservable())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(item -> {
Log.d("ItemListAdapter", "Received item: " + item);
int idMappingId;
synchronized (this.idMapping) {
idMappingId = this.idMapping.size();
this.idMapping.add(item.id());
}
synchronized (this.items) {
this.items.put(item.id(), item);
}
// Notify the view holder about the new item.
this.notifyItemInserted(idMappingId);
// Update positions as needed.
synchronized (this.positionsToUpdate) {
var viewHolder = this.positionsToUpdate.remove(idMappingId);
if (viewHolder != null) {
this.onBindViewHolder(viewHolder, idMappingId);
}
}
});
}
/**
* Called when a new view holder is needed.
*
* @param parent the parent view group.
* @param viewType the view type.
*/
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
Log.d("LocationListAdapter", String.format("onCreateViewHolder(%s, %d)", parent, viewType));
var view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_list_row_item, parent, false);
return new ViewHolder(view);
}
/**
* Called when a view holder is bound.
*
* @param holder the view holder.
* @param position the position of the item.
*/
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Log.d("LocationListAdapter", String.format("onBindViewHolder(%s, %d)", holder, position));
// Check if we already downloaded the item.
if (this.idMapping.size() > position) {
// Bind the data.
var id = this.idMapping.get(position);
var item = this.items.get(id);
if (item != null) { // should never be null, but just in case.
holder.bind(item);
return;
}
}
// Otherwise, defer the binding.
synchronized (this.positionsToUpdate) {
this.positionsToUpdate.put(position, holder);
}
}
/**
* Returns the number of items in the list.
*
* @return the number of items in the list.
*/
@Override
public int getItemCount() {
Log.d("LocationListAdapter", String.format("getItemCount() -> %d", this.idMapping.size()));
return this.idMapping.size();
}
/**
* Converts the position to an ID.
*/
public long positionToId(int position) {
return this.idMapping.get(position);
}
/**
* The view holder for an item in the list.
*/
public static class ViewHolder extends RecyclerView.ViewHolder {
/**
* Name view.
*/
private final TextView name;
/**
* Description view.
*/
private final TextView description;
/**
* Time view.
*/
private final TextView time;
/**
* Location view.
*/
private final TextView location;
/**
* Creates a new view holder.
* @param itemView the view to use.
*/
ViewHolder(@NonNull View view) {
super(view);
name = view.findViewById(R.id.name);
description = view.findViewById(R.id.description);
time = view.findViewById(R.id.time);
location = view.findViewById(R.id.location);
}
/**
* Binds the data to the view.
* @param item the item to bind.
*/
void bind(@NonNull TrackedItem item) {
name.setText(item.name());
description.setText(item.description());
// The location and time string come from the last known location
item.lastKnownLocation().ifPresentOrElse(location -> {
this.location.setText(location.toReadableString());
time.setText(location.locationTime().toString());
}, () -> {
location.setText("???");
time.setText("");
});
}
}
}

View file

@ -0,0 +1,211 @@
package rs.chir.invtracker.client.model;
import android.annotation.SuppressLint;
import android.content.Context;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.res.ResourcesCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.osmdroid.config.Configuration;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.CustomZoomButtonsController;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.Marker;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import rs.chir.invtracker.api.Client;
import rs.chir.invtracker.client.Application;
import rs.chir.invtracker.client.R;
import rs.chir.invtracker.model.GeoLocation;
import rs.chir.invtracker.utils.ConnectionScorer;
/**
* Recycler view adapter for the list of tracked items.
*/
public class LocationListAdapter extends RecyclerView.Adapter<LocationListAdapter.ViewHolder> {
/**
* A list of currently loaded locations.
*/
private final List<GeoLocation> items = new ArrayList<>();
/**
* ViewHolders that are waiting for data to be loaded.
*/
private final Map<Integer, ViewHolder> positionsToUpdate = new HashMap<>(10);
/**
* The context for accessing map resources.
*/
private final Context context;
/**
* Creates a new adapter.
*
* @param context the context to use for accessing the client and map resources.
* @param itemId the ID of the item to load the locations for.
*/
public LocationListAdapter(@NonNull Context context, long itemId) {
this.context = context;
Application.getInstance(context)
.getClient().toObservable().flatMap(client -> client.streamLocations(itemId).toObservable())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(item -> {
Log.d("LocationListAdapter", "Received item: " + item);
int id;
synchronized (this.items) {
id = this.items.size();
this.items.add(item);
}
// Even though the item is appended to the list, @link{#onBindViewHolder} will reverse the order on the fly.
// Notify that an item has been prepended.
this.notifyItemInserted(0);
synchronized (this.positionsToUpdate) {
var viewHolder = this.positionsToUpdate.remove(id);
if (viewHolder != null) {
this.onBindViewHolder(viewHolder, id);
}
}
});
}
/**
* Creates a new view holder.
* @param parent the parent view group.
* @param viewType the view type.
*/
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
Log.d("LocationListAdapter", String.format("onCreateViewHolder(%s, %d)", parent, viewType));
var view = LayoutInflater.from(parent.getContext()).inflate(R.layout.location_list_row_item, parent, false);
return new ViewHolder(view, this.context);
}
/**
* Binds the view holder to the data.
* @param holder the view holder.
* @param position the position in the list.
*/
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Log.d("LocationListAdapter", String.format("onBindViewHolder(%s, %d)", holder, position));
// Recalculate the position as the ordering is reversed
position = this.items.size() - position - 1;
if (position >= 0) {
// if the number is non-negative, we have already loaded the item
var item = this.items.get(position);
if (item != null) { // should not be null, but just in case
holder.bind(item);
return;
}
}
synchronized (this.positionsToUpdate) {
this.positionsToUpdate.put(position, holder);
}
}
/**
* Returns the number of items in the list.
* @return the number of items in the list.
*/
@Override
public int getItemCount() {
Log.d("LocationListAdapter", String.format("getItemCount() -> %d", this.items.size()));
return this.items.size();
}
/**
* View holder for the list items.
*/
public static class ViewHolder extends RecyclerView.ViewHolder {
/**
* The map view to show the location on.
*/
private final MapView map;
/**
* The text view for the time of the check-in.
*/
private final TextView time;
/**
* The text view for the location.
*/
private final TextView location;
/**
* The context to use for accessing the map resources.
*/
private final Context context;
/**
* Creates a new view holder.
* @param view the view to use.
* @param context the context to use for accessing the map resources.
*/
ViewHolder(@NonNull View view, @NonNull Context context) {
super(view);
map = view.findViewById(R.id.map);
time = view.findViewById(R.id.time);
location = view.findViewById(R.id.location);
this.context = context;
// Hide the map view if the internet connection is limited
if (ConnectionScorer.score(this.context) != ConnectionScorer.ConnectionRating.CONNECTED) {
map.setVisibility(View.GONE);
} else {
map.setVisibility(View.VISIBLE);
}
}
/**
* Binds the view holder to the data.
* @param location the location to bind to.
*/
@SuppressLint("ClickableViewAccessibility")
void bind(@NonNull GeoLocation location) {
this.time.setText(location.locationTime().toString());
this.location.setText(location.toReadableString());
// Only init the map if it is visible
// It is invisible when the network connectivity is limited
if (this.map.getVisibility() == View.VISIBLE) {
// Create the map config
var config = Configuration.getInstance();
config.setUserAgentValue(this.context.getPackageName());
config.setOsmdroidBasePath(this.context.getFilesDir()); // unnecessary, but just in case
config.setOsmdroidTileCache(this.context.getCacheDir());
// Center the map around the geo location
var controller = this.map.getController();
controller.setZoom(15.0);
controller.setCenter(new GeoPoint(location.latitude(), location.longitude()));
// Hide all interactive elements
this.map.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER);
// Disable all interactions
this.map.setOnTouchListener((__1, __2) -> true);
// Add the marker to the map
var marker = new Marker(this.map);
marker.setPosition(new GeoPoint(location.latitude(), location.longitude()));
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM);
// Load the marker icon
var drawable = ResourcesCompat.getDrawable(this.context.getResources(), R.drawable.ic_baseline_map_marker_48, this.context.getTheme());
marker.setIcon(drawable);
this.map.getOverlays().add(marker);
// Redraw the map
this.map.invalidate();
}
}
}
}

View file

@ -0,0 +1,115 @@
package rs.chir.invtracker.client.model;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import java.security.SecureRandom;
import java.util.Random;
import rs.chir.invtracker.model.GeoLocation;
/**
* Class for rounding geographic locations.
*/
public class LocationRounder {
/**
* The number of decimal places to round to.
*/
private final int digits;
/**
* The method to use for rounding.
*/
private final String method;
/**
* The random number generator to use.
*/
private final Random random;
/**
* Constructs a new {@link LocationRounder}.
* @param digits the number of decimal places to round to.
* @param method the method to use for rounding.
*/
public LocationRounder(int digits, String method) {
this.digits = digits;
this.method = method;
this.random = new SecureRandom();
}
/**
* Constructs a new location rounder from the application preferences.
* @param context the context to use for accessing the preferences datastore.
* @return a {@link LocationRounder} constructed from the application preferences.
*/
@NonNull
public static LocationRounder fromContext(@NonNull Context context) {
var sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
var digits = sharedPrefs.getInt("location_accuracy", 0);
var method = sharedPrefs.getString("location_privacy", "random");
return new LocationRounder(digits, method);
}
/**
* Rounds double to the nearest multiple of 10^-digits.
* @param value the value to round.
* @return the rounded value.
*/
private double round(double val) {
double pow10 = Math.pow(10, digits);
return Math.round(val * pow10) / pow10;
}
/**
* Truncates double to a multiple of 10^-digits.
* @param value the value to truncate.
* @return the truncated value.
*/
private double truncate(double val) {
if (val < 0)
return Math.ceil(val * Math.pow(10, digits)) / Math.pow(10, digits);
else
return Math.floor(val * Math.pow(10, digits)) / Math.pow(10, digits);
}
/**
* Randomizes double into the range <code>[trunc(val), trunc(val) + 1)</code>.
* @param val the value to randomize.
* @return the randomized value.
*/
private double randomize(double val) {
double randVal = random.nextDouble();
randVal -= this.truncate(randVal);
return this.truncate(val) + randVal;
}
/**
* Performs the rounding operation.
* @param val the value to round.
* @return the rounded value.
*/
private double doRound(double val) {
return switch (method) {
case "privacy_round" -> this.round(val);
case "privacy_truncate" -> this.truncate(val);
case "privacy_randomize" -> this.randomize(val);
default -> val;
};
}
/**
* Rounds a {@link GeoLocation}.
* @param location the location to round.
* @return the rounded location.
*/
@NonNull
public GeoLocation round(@NonNull GeoLocation location) {
return new GeoLocation(
this.doRound(location.latitude()),
this.doRound(location.longitude()),
location.altitude(),
location.locationTime()
);
}
}

View file

@ -0,0 +1,81 @@
package rs.chir.invtracker.client.model;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.datastore.preferences.core.Preferences;
import androidx.datastore.preferences.core.PreferencesKeys;
import java.security.PublicKey;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import rs.chir.compat.java.util.Base64;
import rs.chir.compat.java.util.Objects;
import rs.chir.invtracker.api.Client;
import rs.chir.invtracker.client.Application;
/**
* Utility class for saving and loading the public key from the preferences datastore.
*/
public enum ServerPublicKey {
;
/**
* The key for the public key in the preferences datastore.
*/
private static final Preferences.Key<String> KEY = PreferencesKeys.stringKey("server_public_key");
/**
* Returns a flowable for the serialized public key.
* @param context the context to use for accessing the preferences datastore.
* @return a flowable for the serialized public key.
*/
private static Flowable<String> getRawFlowable(Context context) {
return Application.getInstance(context).getDataStore().data().map(prefs -> prefs.get(KEY));
}
/**
* Retrieve the public key from the server and saves it to the datastore
*
* @param context the context to use for accessing the preferences datastore.
* @return a {@link Single} that completes with the public key if it is set, and an error otherwise.
* @see rs.chir.invtracker.api.Client#getPublicKey()
*/
private static Single<PublicKey> fetchPublicKey(Context context) {
return Application.getInstance(context)
.getClient()
.flatMap(Client::getPublicKey)
.flatMap(pubkey -> Application.getInstance(context).getDataStore().updateDataAsync(prefs -> {
var mutable = prefs.toMutablePreferences();
try (var bos = new java.io.ByteArrayOutputStream();
var oos = new java.io.ObjectOutputStream(bos)) {
oos.writeObject(pubkey.publicKey());
var arr = bos.toByteArray();
mutable.set(KEY, Base64.getEncoder().encodeToString(arr));
}
return Single.just(mutable);
}).map(prefs -> pubkey.publicKey()));
}
/**
* Returns the public key from the preferences datastore, or fetches it from the server if it is not set.
*
* @param context the context to use for accessing the preferences datastore.
* @return a {@link Single} that completes with the public key.
*/
@NonNull
public static Single<PublicKey> getPublicKey(@NonNull Context context) {
return ServerPublicKey.getRawFlowable(context)
.filter(Objects::nonNull)
.map(s -> Base64.getDecoder().decode(s))
.map(arr -> {
try (var bis = new java.io.ByteArrayInputStream(arr);
var ois = new java.io.ObjectInputStream(bis)) {
return (PublicKey) ois.readObject();
}
})
.firstOrError()
.onErrorResumeWith(ServerPublicKey.fetchPublicKey(context));
}
}

View file

@ -0,0 +1,22 @@
package rs.chir.invtracker.utils;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.Build;
import androidx.annotation.NonNull;
import java.io.IOException;
public interface BitmapStorage {
@NonNull
static BitmapStorage create(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return new BitmapStorageQ(context);
} else {
return new BitmapStorageOld();
}
}
void storeBitmap(@NonNull Bitmap bitmap, @NonNull String name) throws IOException;
}

View file

@ -0,0 +1,17 @@
package rs.chir.invtracker.utils;
import android.graphics.Bitmap;
import android.os.Environment;
import androidx.annotation.NonNull;
import java.io.IOException;
public class BitmapStorageOld implements BitmapStorage {
@Override
public void storeBitmap(@NonNull Bitmap bitmap, @NonNull String name) throws IOException {
String path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getAbsolutePath();
String fileName = path + "/" + name + ".png";
BitmapUtils.saveBitmap(bitmap, fileName);
}
}

View file

@ -0,0 +1,36 @@
package rs.chir.invtracker.utils;
import android.content.ContentValues;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import java.io.IOException;
import rs.chir.invtracker.client.Application;
@RequiresApi(api = Build.VERSION_CODES.Q)
public class BitmapStorageQ implements BitmapStorage {
private final Context context;
public BitmapStorageQ(@NonNull Context context) {
this.context = context;
}
@Override
public void storeBitmap(@NonNull Bitmap bitmap, @NonNull String name) throws IOException {
var contentResolver = Application.getInstance(this.context).getContentResolver();
var values = new ContentValues();
values.put(MediaStore.MediaColumns.TITLE, name);
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png");
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES);
var uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
var os = contentResolver.openOutputStream(uri);
BitmapUtils.saveBitmap(bitmap, os);
}
}

View file

@ -0,0 +1,24 @@
package rs.chir.invtracker.utils;
import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
public enum BitmapUtils {
;
public static void saveBitmap(@NonNull Bitmap bitmap, @NonNull String fileName) throws FileNotFoundException, IOException {
BitmapUtils.saveBitmap(bitmap, new FileOutputStream(fileName));
}
public static void saveBitmap(@NonNull Bitmap bitmap, @NonNull OutputStream os) throws IOException {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
os.flush();
os.close();
}
}

View file

@ -0,0 +1,38 @@
package rs.chir.invtracker.utils;
import java.io.IOException;
import java.util.List;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Response;
public class CallAdapter extends Single<Response> implements Callback {
private final List<SingleObserver<? super Response>> subscribers = new java.util.ArrayList<>(1);
public CallAdapter(@androidx.annotation.NonNull Call call) {
call.enqueue(this);
}
@Override
protected void subscribeActual(@NonNull SingleObserver<? super Response> observer) {
subscribers.add(observer);
}
@Override
public void onFailure(@androidx.annotation.NonNull Call call, @androidx.annotation.NonNull IOException e) {
for (var subscriber : subscribers) {
subscriber.onError(e);
}
}
@Override
public void onResponse(@androidx.annotation.NonNull Call call, @androidx.annotation.NonNull Response response) throws IOException {
for (var subscriber : subscribers) {
subscriber.onSuccess(response);
}
}
}

View file

@ -0,0 +1,49 @@
package rs.chir.invtracker.utils;
import android.content.Context;
import android.net.ConnectivityManager;
import android.os.Build;
import android.telephony.TelephonyManager;
import androidx.annotation.NonNull;
public enum ConnectionScorer {
;
private static boolean isRestrictedData(@NonNull ConnectivityManager cm) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
return cm.getRestrictBackgroundStatus() == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
else
return false;
}
@NonNull
public static ConnectionRating score(@NonNull Context context) {
var cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
var activeNetwork = cm.getActiveNetworkInfo();
if (activeNetwork == null || !activeNetwork.isConnected()) {
return ConnectionRating.DISCONNECTED;
}
// If background data saver is on, we try to avoid using the network
if (ConnectionScorer.isRestrictedData(cm)) {
return ConnectionRating.LIMITED;
}
return switch (activeNetwork.getType()) {
case ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_ETHERNET, ConnectivityManager.TYPE_WIMAX -> ConnectionRating.CONNECTED;
case ConnectivityManager.TYPE_MOBILE -> switch (activeNetwork.getSubtype()) {
case TelephonyManager.NETWORK_TYPE_1xRTT, TelephonyManager.NETWORK_TYPE_CDMA, TelephonyManager.NETWORK_TYPE_EDGE, TelephonyManager.NETWORK_TYPE_GPRS, TelephonyManager.NETWORK_TYPE_IDEN -> ConnectionRating.LIMITED;
case TelephonyManager.NETWORK_TYPE_EVDO_0, TelephonyManager.NETWORK_TYPE_EVDO_A, TelephonyManager.NETWORK_TYPE_EVDO_B, TelephonyManager.NETWORK_TYPE_EHRPD, TelephonyManager.NETWORK_TYPE_HSPAP, TelephonyManager.NETWORK_TYPE_HSDPA, TelephonyManager.NETWORK_TYPE_HSUPA, TelephonyManager.NETWORK_TYPE_UMTS, TelephonyManager.NETWORK_TYPE_LTE -> ConnectionRating.CONNECTED;
default -> ConnectionRating.CONNECTED;
};
default -> ConnectionRating.LIMITED;
};
}
public enum ConnectionRating {
CONNECTED,
LIMITED,
DISCONNECTED
}
}

View file

@ -0,0 +1,57 @@
package rs.chir.invtracker.utils;
import org.reactivestreams.Subscriber;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import rs.chir.compat.java.util.Optional;
import rs.chir.compat.java.util.function.Function;
import rs.chir.invtracker.model.Cursor;
import rs.chir.utils.xml.XMLSerializable;
public class CursorStreamable<T extends XMLSerializable> extends Flowable<T> {
private final List<Subscriber<? super T>> subscribers = new ArrayList<>(1);
private final Function<Optional<String>, Single<Cursor<T>>> cursorSupplier;
private final AtomicBoolean isLoading = new AtomicBoolean(false);
public CursorStreamable(@NonNull Function<Optional<String>, Single<Cursor<T>>> cursorSupplier) {
this.cursorSupplier = cursorSupplier;
}
@Override
protected void subscribeActual(@NonNull Subscriber<? super T> subscriber) {
subscribers.add(subscriber);
if (this.isLoading.compareAndSet(false, true)) {
this.loadNextChunk(Optional.empty());
}
}
private void loadNextChunk(Optional<String> lastId) {
this.cursorSupplier.apply(lastId)
.subscribeOn(Schedulers.io())
.subscribe(cursor -> {
for (var item : cursor.items()) {
for (var subscriber : subscribers) {
subscriber.onNext(item);
}
}
if (cursor.nextId().isEmpty() || cursor.items().isEmpty()) {
for (var subscriber : subscribers) {
subscriber.onComplete();
}
} else {
this.loadNextChunk(cursor.nextId());
}
}, t -> {
for (var subscriber : subscribers) {
subscriber.onError(t);
}
});
}
}

View file

@ -0,0 +1,36 @@
package rs.chir.invtracker.utils;
import androidx.annotation.NonNull;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
public class ListenableFutureAdapter<T> extends Single<T> {
private final List<SingleObserver<? super T>> subscribers = new java.util.ArrayList<>(1);
public ListenableFutureAdapter(@NonNull ListenableFuture<T> future, @NonNull Executor executor) {
future.addListener(() -> {
try {
var result = future.get();
for (var subscriber : subscribers) {
subscriber.onSuccess(result);
}
} catch (InterruptedException | ExecutionException e) {
for (var subscriber : subscribers) {
subscriber.onError(e);
}
}
}, executor);
}
@Override
protected void subscribeActual(@NonNull SingleObserver<? super T> observer) {
subscribers.add(observer);
}
}

View file

@ -0,0 +1,70 @@
package rs.chir.invtracker.utils;
import android.util.Log;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import io.reactivex.rxjava3.disposables.Disposable;
import rs.chir.compat.java.util.function.Predicate;
import rs.chir.compat.java.util.function.Supplier;
public class SingleBackoff<T> extends Single<T> {
public static final int MAX_DELAY = 300;
private final Supplier<Single<T>> singleSupplier;
private final List<SingleObserver<? super T>> observers = new ArrayList<>(1);
private final Predicate<? super Throwable> exceptionFilter;
private int delay = 1;
private Disposable disposable;
public SingleBackoff(@NonNull Supplier<Single<T>> singleSupplier) {
this(singleSupplier, __ -> true);
}
public SingleBackoff(@androidx.annotation.NonNull Supplier<Single<T>> singleSupplier, @NonNull Predicate<? super Throwable> exceptionFilter) {
this.singleSupplier = singleSupplier;
this.disposable = singleSupplier.get().subscribe(this::onSuccess, this::restart);
this.exceptionFilter = exceptionFilter;
}
private void restart(@NonNull Throwable error) {
Log.e("SingleBackoff", "Error: " + error.getMessage());
error.printStackTrace();
if (exceptionFilter.test(error)) {
if (this.delay > MAX_DELAY) {
Log.i("SingleBackoff", "Max delay reached, terminating");
this.onError(error);
} else {
this.delay *= 2;
Log.i("SingleBackoff", "Retrying in " + this.delay + " seconds");
this.disposable = Single.just(0).delay(delay, TimeUnit.SECONDS).flatMap(__ -> singleSupplier.get()).subscribe(this::onSuccess, this::restart);
}
} else {
this.onError(error);
}
}
private void onSuccess(T result) {
for (SingleObserver<? super T> observer : this.observers) {
observer.onSuccess(result);
}
this.disposable.dispose();
}
private void onError(Throwable error) {
for (SingleObserver<? super T> observer : this.observers) {
observer.onError(error);
}
this.disposable.dispose();
}
@Override
protected void subscribeActual(@NonNull SingleObserver<? super T> observer) {
this.observers.add(observer);
}
}

View file

@ -0,0 +1,94 @@
package rs.chir.invtracker.utils;
import android.annotation.SuppressLint;
import android.content.Context;
import android.location.Location;
import android.os.Looper;
import android.util.AndroidException;
import androidx.annotation.NonNull;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationCallback;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationResult;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.location.LocationSettingsRequest;
import com.google.android.gms.location.Priority;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import rs.chir.compat.java.util.OptionalDouble;
import rs.chir.invtracker.client.model.LocationRounder;
import rs.chir.invtracker.model.GeoLocation;
public class SingleLocation extends Single<GeoLocation> {
final List<SingleObserver<? super GeoLocation>> observers = new ArrayList<>(1);
@SuppressLint("MissingPermission")
private SingleLocation(@NonNull FusedLocationProviderClient client, LocationRequest request, Context context) {
client.requestLocationUpdates(request, new LocationCallback() {
@Override
public void onLocationResult(LocationResult locationResult) {
var location = locationResult.getLastLocation();
if (location != null) {
for (var observer : observers) {
observer.onSuccess(SingleLocation.fromAndroidLocation(location, context));
}
} else {
for (var observer : observers) {
observer.onError(new AndroidException("No location available"));
}
}
}
}, Looper.getMainLooper());
}
@NonNull
static GeoLocation fromAndroidLocation(@NonNull Location location, @NonNull Context context) {
var lat = location.getLatitude();
var lon = location.getLongitude();
var alt = OptionalDouble.empty();
if (location.hasAltitude()) {
alt = OptionalDouble.of(location.getAltitude());
}
var time = new Timestamp(location.getTime());
var geoLocation = new GeoLocation(lat, lon, alt, time);
return LocationRounder.fromContext(context).round(geoLocation);
}
@NonNull
@SuppressLint("MissingPermission")
public static Single<GeoLocation> getSingleLocation(@NonNull FusedLocationProviderClient client, @NonNull Context context) {
return TaskAdapter.fromTask(client.getLastLocation())
.flatMap(location -> {
if (location.isPresent()) {
return Single.just(SingleLocation.fromAndroidLocation(location.get(), context));
} else {
return SingleLocation.getNextLocation(client, context);
}
});
}
@NonNull
public static Single<GeoLocation> getNextLocation(@NonNull FusedLocationProviderClient client, @NonNull Context context) {
var request = LocationRequest.create();
request.setNumUpdates(1);
request.setInterval(1000);
request.setPriority(Priority.PRIORITY_HIGH_ACCURACY);
var builder = new LocationSettingsRequest.Builder()
.addLocationRequest(request);
var settingsClient = LocationServices.getSettingsClient(context);
return TaskAdapter.fromTask(settingsClient.checkLocationSettings(builder.build()))
.flatMap(resp -> new SingleLocation(client, request, context));
}
@Override
protected void subscribeActual(@io.reactivex.rxjava3.annotations.NonNull SingleObserver<? super GeoLocation> observer) {
observers.add(observer);
}
}

View file

@ -0,0 +1,42 @@
package rs.chir.invtracker.utils;
import android.util.AndroidException;
import androidx.activity.result.ActivityResultCaller;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import java.util.List;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
public class SinglePermission extends Single<String> {
private final List<SingleObserver<? super String>> subscribers = new java.util.ArrayList<>(1);
private final String permission;
private final ActivityResultLauncher<String> launcher;
public SinglePermission(@NonNull ActivityResultCaller activity, String permission) {
this.launcher = activity.registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (isGranted) {
for (var subscriber : subscribers) {
subscriber.onSuccess(permission);
}
} else {
for (var subscriber : subscribers) {
subscriber.onError(new AndroidException("Permission denied"));
}
}
});
this.permission = permission;
}
@Override
protected void subscribeActual(@io.reactivex.rxjava3.annotations.NonNull SingleObserver<? super String> observer) {
subscribers.add(observer);
if (subscribers.size() == 1) {
launcher.launch(permission);
}
}
}

View file

@ -0,0 +1,53 @@
package rs.chir.invtracker.utils;
import android.util.Log;
import androidx.annotation.NonNull;
import com.google.android.gms.tasks.Task;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import rs.chir.compat.java.util.Optional;
public class TaskAdapter<T> extends Single<Optional<T>> {
private final List<SingleObserver<? super Optional<T>>> observers = new ArrayList<>(1);
private TaskAdapter(@NonNull Task<T> task) {
task.addOnCompleteListener(result -> {
Log.i("TaskAdapter", "Task completed: " + result);
if (result.isSuccessful()) {
var out = Optional.ofNullable(result.getResult());
for (var observer : observers) {
observer.onSuccess(out);
}
} else {
for (var observer : observers) {
observer.onError(result.getException());
}
}
});
}
@NonNull
public static <T> Single<Optional<T>> fromTask(@NonNull Task<T> task) {
if (task.isComplete()) {
if (task.isSuccessful()) {
return Single.just(Optional.ofNullable(task.getResult()));
} else {
return Single.error(Objects.requireNonNull(task.getException()));
}
} else {
return new TaskAdapter<>(task);
}
}
@Override
protected void subscribeActual(@io.reactivex.rxjava3.annotations.NonNull SingleObserver<? super Optional<T>> observer) {
observers.add(observer);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,16 @@
package rs.chir.invtracker.client
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

11
build.gradle Normal file
View file

@ -0,0 +1,11 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.2.2' apply false
id 'com.android.library' version '7.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.jvm' version '1.6.10' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}

42
flake.lock Normal file
View file

@ -0,0 +1,42 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1660124653,
"narHash": "sha256-dZDxdypUyYHOMFb6Tvd7yVOkHoCSuw9O5K/CECcfjbE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e64038901158896687b271079848ef46563565fd",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

38
flake.nix Normal file
View file

@ -0,0 +1,38 @@
{
description = "A very basic flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config = {
android_sdk.accept_license = true;
allowUnfree = true;
};
};
in {
formatter = pkgs.alejandra;
devShells.default = let
androidSdk = pkgs.androidenv.androidPkgs_9_0.androidsdk;
in
pkgs.mkShell {
buildInputs = with pkgs; [
androidSdk
glibc
doxygen
graphviz
];
# override the aapt2 that gradle uses with the nix-shipped version
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidSdk}/libexec/android-sdk/build-tools/32.0.0/aapt2";
};
});
}

23
gradle.properties Normal file
View file

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,6 @@
#Wed Aug 10 11:16:39 GMT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

240
gradlew vendored Executable file
View file

@ -0,0 +1,240 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

91
gradlew.bat vendored Normal file
View file

@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
invtracker/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

16
invtracker/build.gradle Normal file
View file

@ -0,0 +1,16 @@
plugins {
id 'java-library'
}
java {
sourceCompatibility = JavaVersion.VERSION_15
targetCompatibility = JavaVersion.VERSION_15
}
dependencies {
implementation 'androidx.annotation:annotation:1.4.0'
implementation 'dev.paseto:jpaseto-api:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-impl:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-jackson:0.7.0'
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,532 @@
/*
* Copyright (c) 2009, 2022, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import rs.chir.compat.java.util.function.Supplier;
/**
* This class consists of {@code static} utility methods for operating
* on objects, or checking certain conditions before operation. These utilities
* include {@code null}-safe or {@code null}-tolerant methods for computing the
* hash code of an object, returning a string for an object, comparing two
* objects, and checking if indexes or sub-range values are out of bounds.
*
* @since 1.7
*/
public final class Objects {
private Objects() {
throw new AssertionError("No java.util.Objects instances for you!");
}
/**
* Returns {@code true} if the arguments are equal to each other
* and {@code false} otherwise.
* Consequently, if both arguments are {@code null}, {@code true}
* is returned. Otherwise, if the first argument is not {@code
* null}, equality is determined by calling the {@link
* Object#equals equals} method of the first argument with the
* second argument of this method. Otherwise, {@code false} is
* returned.
*
* @param a an object
* @param b an object to be compared with {@code a} for equality
* @return {@code true} if the arguments are equal to each other
* and {@code false} otherwise
* @see Object#equals(Object)
*/
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
private static boolean deepEquals0(Object e1, Object e2) {
assert e1 != null;
boolean eq;
if (e1 instanceof Object[] && e2 instanceof Object[])
eq = Arrays.deepEquals((Object[]) e1, (Object[]) e2);
else if (e1 instanceof byte[] && e2 instanceof byte[])
eq = Arrays.equals((byte[]) e1, (byte[]) e2);
else if (e1 instanceof short[] && e2 instanceof short[])
eq = Arrays.equals((short[]) e1, (short[]) e2);
else if (e1 instanceof int[] && e2 instanceof int[])
eq = Arrays.equals((int[]) e1, (int[]) e2);
else if (e1 instanceof long[] && e2 instanceof long[])
eq = Arrays.equals((long[]) e1, (long[]) e2);
else if (e1 instanceof char[] && e2 instanceof char[])
eq = Arrays.equals((char[]) e1, (char[]) e2);
else if (e1 instanceof float[] && e2 instanceof float[])
eq = Arrays.equals((float[]) e1, (float[]) e2);
else if (e1 instanceof double[] && e2 instanceof double[])
eq = Arrays.equals((double[]) e1, (double[]) e2);
else if (e1 instanceof boolean[] && e2 instanceof boolean[])
eq = Arrays.equals((boolean[]) e1, (boolean[]) e2);
else
eq = e1.equals(e2);
return eq;
}
/**
* Returns {@code true} if the arguments are deeply equal to each other
* and {@code false} otherwise.
* <p>
* Two {@code null} values are deeply equal. If both arguments are
* arrays, the algorithm in {@link Arrays#deepEquals(Object[],
* Object[]) Arrays.deepEquals} is used to determine equality.
* Otherwise, equality is determined by using the {@link
* Object#equals equals} method of the first argument.
*
* @param a an object
* @param b an object to be compared with {@code a} for deep equality
* @return {@code true} if the arguments are deeply equal to each other
* and {@code false} otherwise
* @see Arrays#deepEquals(Object[], Object[])
* @see Objects#equals(Object, Object)
*/
public static boolean deepEquals(Object a, Object b) {
if (a == b)
return true;
else if (a == null || b == null)
return false;
else
return Objects.deepEquals0(a, b);
}
/**
* Returns the hash code of a non-{@code null} argument and 0 for
* a {@code null} argument.
*
* @param o an object
* @return the hash code of a non-{@code null} argument and 0 for
* a {@code null} argument
* @see Object#hashCode
*/
public static int hashCode(Object o) {
return o != null ? o.hashCode() : 0;
}
/**
* Generates a hash code for a sequence of input values. The hash
* code is generated as if all the input values were placed into an
* array, and that array were hashed by calling {@link
* Arrays#hashCode(Object[])}.
*
* <p>This method is useful for implementing {@link
* Object#hashCode()} on objects containing multiple fields. For
* example, if an object that has three fields, {@code x}, {@code
* y}, and {@code z}, one could write:
*
* <blockquote><pre>
* &#064;Override public int hashCode() {
* return Objects.hash(x, y, z);
* }
* </pre></blockquote>
*
* <b>Warning: When a single object reference is supplied, the returned
* value does not equal the hash code of that object reference.</b> This
* value can be computed by calling {@link #hashCode(Object)}.
*
* @param values the values to be hashed
* @return a hash value of the sequence of input values
* @see Arrays#hashCode(Object[])
* @see List#hashCode
*/
public static int hash(Object... values) {
return Arrays.hashCode(values);
}
/**
* Returns the result of calling {@code toString} for a non-{@code
* null} argument and {@code "null"} for a {@code null} argument.
*
* @param o an object
* @return the result of calling {@code toString} for a non-{@code
* null} argument and {@code "null"} for a {@code null} argument
* @see Object#toString
* @see String#valueOf(Object)
*/
public static String toString(Object o) {
return String.valueOf(o);
}
/**
* Returns the result of calling {@code toString} on the first
* argument if the first argument is not {@code null} and returns
* the second argument otherwise.
*
* @param o an object
* @param nullDefault string to return if the first argument is
* {@code null}
* @return the result of calling {@code toString} on the first
* argument if it is not {@code null} and the second argument
* otherwise.
* @see Objects#toString(Object)
*/
public static String toString(Object o, String nullDefault) {
return (o != null) ? o.toString() : nullDefault;
}
/**
* {@return a string equivalent to the string returned by {@code
* Object.toString} if that method and {@code hashCode} are not
* overridden}
*
* @param o an object
* @throws NullPointerException if the argument is null
* @implNote This method constructs a string for an object without calling
* any overridable methods of the object.
* @implSpec The method returns a string equivalent to:<br>
* {@code o.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(o))}
* @see Object#toString
* @see System#identityHashCode(Object)
* @since 19
*/
public static String toIdentityString(Object o) {
Objects.requireNonNull(o);
return o.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(o));
}
/**
* Returns 0 if the arguments are identical and {@code
* c.compare(a, b)} otherwise.
* Consequently, if both arguments are {@code null} 0
* is returned.
*
* <p>Note that if one of the arguments is {@code null}, a {@code
* NullPointerException} may or may not be thrown depending on
* what ordering policy, if any, the {@link Comparator Comparator}
* chooses to have for {@code null} values.
*
* @param <T> the type of the objects being compared
* @param a an object
* @param b an object to be compared with {@code a}
* @param c the {@code Comparator} to compare the first two arguments
* @return 0 if the arguments are identical and {@code
* c.compare(a, b)} otherwise.
* @see Comparable
* @see Comparator
*/
public static <T> int compare(T a, T b, Comparator<? super T> c) {
return (a == b) ? 0 : c.compare(a, b);
}
/**
* Checks that the specified object reference is not {@code null}. This
* method is designed primarily for doing parameter validation in methods
* and constructors, as demonstrated below:
* <blockquote><pre>
* public Foo(Bar bar) {
* this.bar = Objects.requireNonNull(bar);
* }
* </pre></blockquote>
*
* @param obj the object reference to check for nullity
* @param <T> the type of the reference
* @return {@code obj} if not {@code null}
* @throws NullPointerException if {@code obj} is {@code null}
*/
public static <T> T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}
/**
* Checks that the specified object reference is not {@code null} and
* throws a customized {@link NullPointerException} if it is. This method
* is designed primarily for doing parameter validation in methods and
* constructors with multiple parameters, as demonstrated below:
* <blockquote><pre>
* public Foo(Bar bar, Baz baz) {
* this.bar = Objects.requireNonNull(bar, "bar must not be null");
* this.baz = Objects.requireNonNull(baz, "baz must not be null");
* }
* </pre></blockquote>
*
* @param obj the object reference to check for nullity
* @param message detail message to be used in the event that a {@code
* NullPointerException} is thrown
* @param <T> the type of the reference
* @return {@code obj} if not {@code null}
* @throws NullPointerException if {@code obj} is {@code null}
*/
public static <T> T requireNonNull(T obj, String message) {
if (obj == null)
throw new NullPointerException(message);
return obj;
}
/**
* Returns {@code true} if the provided reference is {@code null} otherwise
* returns {@code false}.
*
* @param obj a reference to be checked against {@code null}
* @return {@code true} if the provided reference is {@code null} otherwise
* {@code false}
* @apiNote This method exists to be used as a
* {@link java.util.function.Predicate}, {@code filter(Objects::isNull)}
* @see java.util.function.Predicate
* @since 1.8
*/
public static boolean isNull(Object obj) {
return obj == null;
}
/**
* Returns {@code true} if the provided reference is non-{@code null}
* otherwise returns {@code false}.
*
* @param obj a reference to be checked against {@code null}
* @return {@code true} if the provided reference is non-{@code null}
* otherwise {@code false}
* @apiNote This method exists to be used as a
* {@link java.util.function.Predicate}, {@code filter(Objects::nonNull)}
* @see java.util.function.Predicate
* @since 1.8
*/
public static boolean nonNull(Object obj) {
return obj != null;
}
/**
* Returns the first argument if it is non-{@code null} and
* otherwise returns the non-{@code null} second argument.
*
* @param obj an object
* @param defaultObj a non-{@code null} object to return if the first argument
* is {@code null}
* @param <T> the type of the reference
* @return the first argument if it is non-{@code null} and
* otherwise the second argument if it is non-{@code null}
* @throws NullPointerException if both {@code obj} is null and
* {@code defaultObj} is {@code null}
* @since 9
*/
public static <T> T requireNonNullElse(T obj, T defaultObj) {
return (obj != null) ? obj : Objects.requireNonNull(defaultObj, "defaultObj");
}
/**
* Returns the first argument if it is non-{@code null} and otherwise
* returns the non-{@code null} value of {@code supplier.get()}.
*
* @param obj an object
* @param supplier of a non-{@code null} object to return if the first argument
* is {@code null}
* @param <T> the type of the first argument and return type
* @return the first argument if it is non-{@code null} and otherwise
* the value from {@code supplier.get()} if it is non-{@code null}
* @throws NullPointerException if both {@code obj} is null and
* either the {@code supplier} is {@code null} or
* the {@code supplier.get()} value is {@code null}
* @since 9
*/
public static <T> T requireNonNullElseGet(T obj, Supplier<? extends T> supplier) {
return (obj != null) ? obj
: Objects.requireNonNull(Objects.requireNonNull(supplier, "supplier").get(), "supplier.get()");
}
/**
* Checks that the specified object reference is not {@code null} and
* throws a customized {@link NullPointerException} if it is.
*
* <p>Unlike the method {@link #requireNonNull(Object, String)},
* this method allows creation of the message to be deferred until
* after the null check is made. While this may confer a
* performance advantage in the non-null case, when deciding to
* call this method care should be taken that the costs of
* creating the message supplier are less than the cost of just
* creating the string message directly.
*
* @param obj the object reference to check for nullity
* @param messageSupplier supplier of the detail message to be
* used in the event that a {@code NullPointerException} is thrown
* @param <T> the type of the reference
* @return {@code obj} if not {@code null}
* @throws NullPointerException if {@code obj} is {@code null}
* @since 1.8
*/
public static <T> T requireNonNull(T obj, Supplier<String> messageSupplier) {
if (obj == null)
throw new NullPointerException(messageSupplier == null ?
null : messageSupplier.get());
return obj;
}
/**
* Checks if the {@code index} is within the bounds of the range from
* {@code 0} (inclusive) to {@code length} (exclusive).
*
* <p>The {@code index} is defined to be out of bounds if any of the
* following inequalities is true:
* <ul>
* <li>{@code index < 0}</li>
* <li>{@code index >= length}</li>
* <li>{@code length < 0}, which is implied from the former inequalities</li>
* </ul>
*
* @param index the index
* @param length the upper-bound (exclusive) of the range
* @return {@code index} if it is within bounds of the range
* @throws IndexOutOfBoundsException if the {@code index} is out of bounds
* @since 9
*/
public static int checkIndex(int index, int length) {
if (index < 0 || index >= length)
throw new IndexOutOfBoundsException("index=" + index + ", length=" + length);
return index;
}
/**
* Checks if the sub-range from {@code fromIndex} (inclusive) to
* {@code toIndex} (exclusive) is within the bounds of range from {@code 0}
* (inclusive) to {@code length} (exclusive).
*
* <p>The sub-range is defined to be out of bounds if any of the following
* inequalities is true:
* <ul>
* <li>{@code fromIndex < 0}</li>
* <li>{@code fromIndex > toIndex}</li>
* <li>{@code toIndex > length}</li>
* <li>{@code length < 0}, which is implied from the former inequalities</li>
* </ul>
*
* @param fromIndex the lower-bound (inclusive) of the sub-range
* @param toIndex the upper-bound (exclusive) of the sub-range
* @param length the upper-bound (exclusive) the range
* @return {@code fromIndex} if the sub-range within bounds of the range
* @throws IndexOutOfBoundsException if the sub-range is out of bounds
* @since 9
*/
public static int checkFromToIndex(int fromIndex, int toIndex, int length) {
if (fromIndex < 0 || toIndex > length || toIndex < fromIndex)
throw new IndexOutOfBoundsException("fromIndex=" + fromIndex + ", toIndex=" + toIndex + ", length=" + length);
return fromIndex;
}
/**
* Checks if the sub-range from {@code fromIndex} (inclusive) to
* {@code fromIndex + size} (exclusive) is within the bounds of range from
* {@code 0} (inclusive) to {@code length} (exclusive).
*
* <p>The sub-range is defined to be out of bounds if any of the following
* inequalities is true:
* <ul>
* <li>{@code fromIndex < 0}</li>
* <li>{@code size < 0}</li>
* <li>{@code fromIndex + size > length}, taking into account integer overflow</li>
* <li>{@code length < 0}, which is implied from the former inequalities</li>
* </ul>
*
* @param fromIndex the lower-bound (inclusive) of the sub-interval
* @param size the size of the sub-range
* @param length the upper-bound (exclusive) of the range
* @return {@code fromIndex} if the sub-range within bounds of the range
* @throws IndexOutOfBoundsException if the sub-range is out of bounds
* @since 9
*/
public static int checkFromIndexSize(int fromIndex, int size, int length) {
return Objects.checkFromToIndex(fromIndex, fromIndex + size, length);
}
/**
* Checks if the {@code index} is within the bounds of the range from
* {@code 0} (inclusive) to {@code length} (exclusive).
*
* <p>The {@code index} is defined to be out of bounds if any of the
* following inequalities is true:
* <ul>
* <li>{@code index < 0}</li>
* <li>{@code index >= length}</li>
* <li>{@code length < 0}, which is implied from the former inequalities</li>
* </ul>
*
* @param index the index
* @param length the upper-bound (exclusive) of the range
* @return {@code index} if it is within bounds of the range
* @throws IndexOutOfBoundsException if the {@code index} is out of bounds
* @since 16
*/
public static long checkIndex(long index, long length) {
if (index < 0 || index >= length)
throw new IndexOutOfBoundsException("index=" + index + ", length=" + length);
return index;
}
/**
* Checks if the sub-range from {@code fromIndex} (inclusive) to
* {@code toIndex} (exclusive) is within the bounds of range from {@code 0}
* (inclusive) to {@code length} (exclusive).
*
* <p>The sub-range is defined to be out of bounds if any of the following
* inequalities is true:
* <ul>
* <li>{@code fromIndex < 0}</li>
* <li>{@code fromIndex > toIndex}</li>
* <li>{@code toIndex > length}</li>
* <li>{@code length < 0}, which is implied from the former inequalities</li>
* </ul>
*
* @param fromIndex the lower-bound (inclusive) of the sub-range
* @param toIndex the upper-bound (exclusive) of the sub-range
* @param length the upper-bound (exclusive) the range
* @return {@code fromIndex} if the sub-range within bounds of the range
* @throws IndexOutOfBoundsException if the sub-range is out of bounds
* @since 16
*/
public static long checkFromToIndex(long fromIndex, long toIndex, long length) {
if (fromIndex < 0 || toIndex > length || toIndex < fromIndex)
throw new IndexOutOfBoundsException("fromIndex=" + fromIndex + ", toIndex=" + toIndex + ", length=" + length);
return fromIndex;
}
/**
* Checks if the sub-range from {@code fromIndex} (inclusive) to
* {@code fromIndex + size} (exclusive) is within the bounds of range from
* {@code 0} (inclusive) to {@code length} (exclusive).
*
* <p>The sub-range is defined to be out of bounds if any of the following
* inequalities is true:
* <ul>
* <li>{@code fromIndex < 0}</li>
* <li>{@code size < 0}</li>
* <li>{@code fromIndex + size > length}, taking into account integer overflow</li>
* <li>{@code length < 0}, which is implied from the former inequalities</li>
* </ul>
*
* @param fromIndex the lower-bound (inclusive) of the sub-interval
* @param size the size of the sub-range
* @param length the upper-bound (exclusive) of the range
* @return {@code fromIndex} if the sub-range within bounds of the range
* @throws IndexOutOfBoundsException if the sub-range is out of bounds
* @since 16
*/
public static long checkFromIndexSize(long fromIndex, long size, long length) {
return Objects.checkFromToIndex(fromIndex, fromIndex + size, length);
}
}

View file

@ -0,0 +1,445 @@
/*
* Copyright (c) 2012, 2020, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util;
import androidx.annotation.NonNull;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import rs.chir.compat.java.util.function.Consumer;
import rs.chir.compat.java.util.function.Function;
import rs.chir.compat.java.util.function.Predicate;
import rs.chir.compat.java.util.function.Supplier;
/**
* A container object which may or may not contain a non-{@code null} value.
* If a value is present, {@code isPresent()} returns {@code true}. If no
* value is present, the object is considered <i>empty</i> and
* {@code isPresent()} returns {@code false}.
*
* <p>Additional methods that depend on the presence or absence of a contained
* value are provided, such as {@link #orElse(Object) orElse()}
* (returns a default value if no value is present) and
* {@link #ifPresent(Consumer) ifPresent()} (performs an
* action if a value is present).
*
* <p>This is a <a href="{@docRoot}/java.base/java/lang/doc-files/ValueBased.html">value-based</a>
* class; programmers should treat instances that are
* {@linkplain #equals(Object) equal} as interchangeable and should not
* use instances for synchronization, or unpredictable behavior may
* occur. For example, in a future release, synchronization may fail.
*
* @param <T> the type of value
* @apiNote {@code Optional} is primarily intended for use as a method return type where
* there is a clear need to represent "no result," and where using {@code null}
* is likely to cause errors. A variable whose type is {@code Optional} should
* never itself be {@code null}; it should always point to an {@code Optional}
* instance.
* @since 1.8
*/
public final class Optional<T> {
/**
* Common instance for {@code empty()}.
*/
private static final Optional<?> EMPTY = new Optional<>(null);
/**
* If non-null, the value; if null, indicates no value is present
*/
private final T value;
/**
* Constructs an instance with the described value.
*
* @param value the value to describe; it's the caller's responsibility to
* ensure the value is non-{@code null} unless creating the singleton
* instance returned by {@code empty()}.
*/
private Optional(T value) {
this.value = value;
}
/**
* Returns an empty {@code Optional} instance. No value is present for this
* {@code Optional}.
*
* @param <T> The type of the non-existent value
* @return an empty {@code Optional}
* @apiNote Though it may be tempting to do so, avoid testing if an object is empty
* by comparing with {@code ==} or {@code !=} against instances returned by
* {@code Optional.empty()}. There is no guarantee that it is a singleton.
* Instead, use {@link #isEmpty()} or {@link #isPresent()}.
*/
public static <T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
/**
* Returns an {@code Optional} describing the given non-{@code null}
* value.
*
* @param value the value to describe, which must be non-{@code null}
* @param <T> the type of the value
* @return an {@code Optional} with the value present
* @throws NullPointerException if value is {@code null}
*/
public static <T> Optional<T> of(T value) {
return new Optional<>(Objects.requireNonNull(value));
}
/**
* Returns an {@code Optional} describing the given value, if
* non-{@code null}, otherwise returns an empty {@code Optional}.
*
* @param value the possibly-{@code null} value to describe
* @param <T> the type of the value
* @return an {@code Optional} with a present value if the specified value
* is non-{@code null}, otherwise an empty {@code Optional}
*/
@SuppressWarnings("unchecked")
public static <T> Optional<T> ofNullable(T value) {
return value == null ? (Optional<T>) EMPTY
: new Optional<>(value);
}
/**
* Attempts to retrieve an item from a map.
*
* @param <T> the type of the item
* @param <U> the type of the key
* @param map the map
* @param key the key
*/
@NonNull
public static <T, U> Optional<T> tryIndex(@NonNull Map<U, ? extends T> map, U key) {
return Optional.ofNullable(map.get(key));
}
/**
* If a value is present, returns the value, otherwise throws
* {@code NoSuchElementException}.
*
* @return the non-{@code null} value described by this {@code Optional}
* @throws NoSuchElementException if no value is present
* @apiNote The preferred alternative to this method is {@link #orElseThrow()}.
*/
public T get() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}
/**
* If a value is present, returns {@code true}, otherwise {@code false}.
*
* @return {@code true} if a value is present, otherwise {@code false}
*/
public boolean isPresent() {
return value != null;
}
/**
* If a value is not present, returns {@code true}, otherwise
* {@code false}.
*
* @return {@code true} if a value is not present, otherwise {@code false}
* @since 11
*/
public boolean isEmpty() {
return value == null;
}
/**
* If a value is present, performs the given action with the value,
* otherwise does nothing.
*
* @param action the action to be performed, if a value is present
* @throws NullPointerException if value is present and the given action is
* {@code null}
*/
public void ifPresent(Consumer<? super T> action) {
if (value != null) {
action.accept(value);
}
}
/**
* If a value is present, performs the given action with the value,
* otherwise performs the given empty-based action.
*
* @param action the action to be performed, if a value is present
* @param emptyAction the empty-based action to be performed, if no value is
* present
* @throws NullPointerException if a value is present and the given action
* is {@code null}, or no value is present and the given empty-based
* action is {@code null}.
* @since 9
*/
public void ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction) {
if (value != null) {
action.accept(value);
} else {
emptyAction.run();
}
}
/**
* If a value is present, and the value matches the given predicate,
* returns an {@code Optional} describing the value, otherwise returns an
* empty {@code Optional}.
*
* @param predicate the predicate to apply to a value, if present
* @return an {@code Optional} describing the value of this
* {@code Optional}, if a value is present and the value matches the
* given predicate, otherwise an empty {@code Optional}
* @throws NullPointerException if the predicate is {@code null}
*/
public Optional<T> filter(Predicate<? super T> predicate) {
Objects.requireNonNull(predicate);
if (!this.isPresent()) {
return this;
} else {
return predicate.test(value) ? this : Optional.empty();
}
}
/**
* If a value is present, returns an {@code Optional} describing (as if by
* {@link #ofNullable}) the result of applying the given mapping function to
* the value, otherwise returns an empty {@code Optional}.
*
* <p>If the mapping function returns a {@code null} result then this method
* returns an empty {@code Optional}.
*
* @param mapper the mapping function to apply to a value, if present
* @param <U> The type of the value returned from the mapping function
* @return an {@code Optional} describing the result of applying a mapping
* function to the value of this {@code Optional}, if a value is
* present, otherwise an empty {@code Optional}
* @throws NullPointerException if the mapping function is {@code null}
* @apiNote This method supports post-processing on {@code Optional} values, without
* the need to explicitly check for a return status. For example, the
* following code traverses a stream of URIs, selects one that has not
* yet been processed, and creates a path from that URI, returning
* an {@code Optional<Path>}:
*
* <pre>{@code
* Optional<Path> p =
* uris.stream().filter(uri -> !isProcessedYet(uri))
* .findFirst()
* .map(Paths::get);
* }</pre>
* <p>
* Here, {@code findFirst} returns an {@code Optional<URI>}, and then
* {@code map} returns an {@code Optional<Path>} for the desired
* URI if one exists.
*/
public <U> Optional<U> map(Function<? super T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!this.isPresent()) {
return Optional.empty();
} else {
return Optional.ofNullable(mapper.apply(value));
}
}
/**
* If a value is present, returns the result of applying the given
* {@code Optional}-bearing mapping function to the value, otherwise returns
* an empty {@code Optional}.
*
* <p>This method is similar to {@link #map(Function)}, but the mapping
* function is one whose result is already an {@code Optional}, and if
* invoked, {@code flatMap} does not wrap it within an additional
* {@code Optional}.
*
* @param <U> The type of value of the {@code Optional} returned by the
* mapping function
* @param mapper the mapping function to apply to a value, if present
* @return the result of applying an {@code Optional}-bearing mapping
* function to the value of this {@code Optional}, if a value is
* present, otherwise an empty {@code Optional}
* @throws NullPointerException if the mapping function is {@code null} or
* returns a {@code null} result
*/
public <U> Optional<U> flatMap(Function<? super T, ? extends Optional<? extends U>> mapper) {
Objects.requireNonNull(mapper);
if (!this.isPresent()) {
return Optional.empty();
} else {
@SuppressWarnings("unchecked")
Optional<U> r = (Optional<U>) mapper.apply(value);
return Objects.requireNonNull(r);
}
}
/**
* If a value is present, returns an {@code Optional} describing the value,
* otherwise returns an {@code Optional} produced by the supplying function.
*
* @param supplier the supplying function that produces an {@code Optional}
* to be returned
* @return returns an {@code Optional} describing the value of this
* {@code Optional}, if a value is present, otherwise an
* {@code Optional} produced by the supplying function.
* @throws NullPointerException if the supplying function is {@code null} or
* produces a {@code null} result
* @since 9
*/
public Optional<T> or(Supplier<? extends Optional<? extends T>> supplier) {
Objects.requireNonNull(supplier);
if (this.isPresent()) {
return this;
} else {
@SuppressWarnings("unchecked")
Optional<T> r = (Optional<T>) supplier.get();
return Objects.requireNonNull(r);
}
}
/**
* If a value is present, returns the value, otherwise returns
* {@code other}.
*
* @param other the value to be returned, if no value is present.
* May be {@code null}.
* @return the value, if present, otherwise {@code other}
*/
public T orElse(T other) {
return value != null ? value : other;
}
/**
* If a value is present, returns the value, otherwise returns the result
* produced by the supplying function.
*
* @param supplier the supplying function that produces a value to be returned
* @return the value, if present, otherwise the result produced by the
* supplying function
* @throws NullPointerException if no value is present and the supplying
* function is {@code null}
*/
public T orElseGet(Supplier<? extends T> supplier) {
return value != null ? value : supplier.get();
}
/**
* If a value is present, returns the value, otherwise throws
* {@code NoSuchElementException}.
*
* @return the non-{@code null} value described by this {@code Optional}
* @throws NoSuchElementException if no value is present
* @since 10
*/
public T orElseThrow() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}
/**
* If a value is present, returns the value, otherwise throws an exception
* produced by the exception supplying function.
*
* @param <X> Type of the exception to be thrown
* @param exceptionSupplier the supplying function that produces an
* exception to be thrown
* @return the value, if present
* @throws X if no value is present
* @throws NullPointerException if no value is present and the exception
* supplying function is {@code null}
* @apiNote A method reference to the exception constructor with an empty argument
* list can be used as the supplier. For example,
* {@code IllegalStateException::new}
*/
public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X {
if (value != null) {
return value;
} else {
throw exceptionSupplier.get();
}
}
/**
* Indicates whether some other object is "equal to" this {@code Optional}.
* The other object is considered equal if:
* <ul>
* <li>it is also an {@code Optional} and;
* <li>both instances have no value present or;
* <li>the present values are "equal to" each other via {@code equals()}.
* </ul>
*
* @param obj an object to be tested for equality
* @return {@code true} if the other object is "equal to" this object
* otherwise {@code false}
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof Optional)) {
return false;
}
return Objects.equals(value, ((Optional<?>) obj).value);
}
/**
* Returns the hash code of the value, if present, otherwise {@code 0}
* (zero) if no value is present.
*
* @return hash code value of the present value or {@code 0} if no value is
* present
*/
@Override
public int hashCode() {
return Objects.hashCode(value);
}
/**
* Returns a non-empty string representation of this {@code Optional}
* suitable for debugging. The exact presentation format is unspecified and
* may vary between implementations and versions.
*
* @return the string representation of this instance
* @implSpec If a value is present the result must include its string representation
* in the result. Empty and present {@code Optional}s must be unambiguously
* differentiable.
*/
@Override
public String toString() {
return value != null
? ("Optional[" + value + "]")
: "Optional.empty";
}
}

View file

@ -0,0 +1,305 @@
/*
* Copyright (c) 2012, 2020, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util;
import java.util.NoSuchElementException;
import rs.chir.compat.java.util.function.DoubleConsumer;
import rs.chir.compat.java.util.function.DoubleSupplier;
import rs.chir.compat.java.util.function.Supplier;
/**
* A container object which may or may not contain a {@code double} value.
* If a value is present, {@code isPresent()} returns {@code true}. If no
* value is present, the object is considered <i>empty</i> and
* {@code isPresent()} returns {@code false}.
*
* <p>Additional methods that depend on the presence or absence of a contained
* value are provided, such as {@link #orElse(double) orElse()}
* (returns a default value if no value is present) and
* {@link #ifPresent(DoubleConsumer) ifPresent()} (performs
* an action if a value is present).
*
* <p>This is a <a href="{@docRoot}/java.base/java/lang/doc-files/ValueBased.html">value-based</a>
* class; programmers should treat instances that are
* {@linkplain #equals(Object) equal} as interchangeable and should not
* use instances for synchronization, or unpredictable behavior may
* occur. For example, in a future release, synchronization may fail.
*
* @apiNote {@code OptionalDouble} is primarily intended for use as a method return type where
* there is a clear need to represent "no result." A variable whose type is
* {@code OptionalDouble} should never itself be {@code null}; it should always point
* to an {@code OptionalDouble} instance.
* @since 1.8
*/
public final class OptionalDouble {
/**
* Common instance for {@code empty()}.
*/
private static final OptionalDouble EMPTY = new OptionalDouble();
/**
* If true then the value is present, otherwise indicates no value is present
*/
private final boolean isPresent;
private final double value;
/**
* Construct an empty instance.
*
* @implNote generally only one empty instance, {@link OptionalDouble#EMPTY},
* should exist per VM.
*/
private OptionalDouble() {
this.isPresent = false;
this.value = Double.NaN;
}
/**
* Construct an instance with the described value.
*
* @param value the double value to describe.
*/
private OptionalDouble(double value) {
this.isPresent = true;
this.value = value;
}
/**
* Returns an empty {@code OptionalDouble} instance. No value is present
* for this {@code OptionalDouble}.
*
* @return an empty {@code OptionalDouble}.
* @apiNote Though it may be tempting to do so, avoid testing if an object is empty
* by comparing with {@code ==} or {@code !=} against instances returned by
* {@code OptionalDouble.empty()}. There is no guarantee that it is a singleton.
* Instead, use {@link #isEmpty()} or {@link #isPresent()}.
*/
public static OptionalDouble empty() {
return EMPTY;
}
/**
* Returns an {@code OptionalDouble} describing the given value.
*
* @param value the value to describe
* @return an {@code OptionalDouble} with the value present
*/
public static OptionalDouble of(double value) {
return new OptionalDouble(value);
}
/**
* If a value is present, returns the value, otherwise throws
* {@code NoSuchElementException}.
*
* @return the value described by this {@code OptionalDouble}
* @throws NoSuchElementException if no value is present
* @apiNote The preferred alternative to this method is {@link #orElseThrow()}.
*/
public double getAsDouble() {
if (!isPresent) {
throw new NoSuchElementException("No value present");
}
return value;
}
/**
* If a value is present, returns {@code true}, otherwise {@code false}.
*
* @return {@code true} if a value is present, otherwise {@code false}
*/
public boolean isPresent() {
return isPresent;
}
/**
* If a value is not present, returns {@code true}, otherwise
* {@code false}.
*
* @return {@code true} if a value is not present, otherwise {@code false}
* @since 11
*/
public boolean isEmpty() {
return !isPresent;
}
/**
* If a value is present, performs the given action with the value,
* otherwise does nothing.
*
* @param action the action to be performed, if a value is present
* @throws NullPointerException if value is present and the given action is
* {@code null}
*/
public void ifPresent(DoubleConsumer action) {
if (isPresent) {
action.accept(value);
}
}
/**
* If a value is present, performs the given action with the value,
* otherwise performs the given empty-based action.
*
* @param action the action to be performed, if a value is present
* @param emptyAction the empty-based action to be performed, if no value is
* present
* @throws NullPointerException if a value is present and the given action
* is {@code null}, or no value is present and the given empty-based
* action is {@code null}.
* @since 9
*/
public void ifPresentOrElse(DoubleConsumer action, Runnable emptyAction) {
if (isPresent) {
action.accept(value);
} else {
emptyAction.run();
}
}
/**
* If a value is present, returns the value, otherwise returns
* {@code other}.
*
* @param other the value to be returned, if no value is present
* @return the value, if present, otherwise {@code other}
*/
public double orElse(double other) {
return isPresent ? value : other;
}
/**
* If a value is present, returns the value, otherwise returns the result
* produced by the supplying function.
*
* @param supplier the supplying function that produces a value to be returned
* @return the value, if present, otherwise the result produced by the
* supplying function
* @throws NullPointerException if no value is present and the supplying
* function is {@code null}
*/
public double orElseGet(DoubleSupplier supplier) {
return isPresent ? value : supplier.getAsDouble();
}
/**
* If a value is present, returns the value, otherwise throws
* {@code NoSuchElementException}.
*
* @return the value described by this {@code OptionalDouble}
* @throws NoSuchElementException if no value is present
* @since 10
*/
public double orElseThrow() {
if (!isPresent) {
throw new NoSuchElementException("No value present");
}
return value;
}
/**
* If a value is present, returns the value, otherwise throws an exception
* produced by the exception supplying function.
*
* @param <X> Type of the exception to be thrown
* @param exceptionSupplier the supplying function that produces an
* exception to be thrown
* @return the value, if present
* @throws X if no value is present
* @throws NullPointerException if no value is present and the exception
* supplying function is {@code null}
* @apiNote A method reference to the exception constructor with an empty argument
* list can be used as the supplier. For example,
* {@code IllegalStateException::new}
*/
public <X extends Throwable> double orElseThrow(Supplier<? extends X> exceptionSupplier) throws X {
if (isPresent) {
return value;
} else {
throw exceptionSupplier.get();
}
}
/**
* Indicates whether some other object is "equal to" this
* {@code OptionalDouble}. The other object is considered equal if:
* <ul>
* <li>it is also an {@code OptionalDouble} and;
* <li>both instances have no value present or;
* <li>the present values are "equal to" each other via
* {@code Double.compare() == 0}.
* </ul>
*
* @param obj an object to be tested for equality
* @return {@code true} if the other object is "equal to" this object
* otherwise {@code false}
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof OptionalDouble)) {
return false;
}
OptionalDouble other = (OptionalDouble) obj;
return (isPresent && other.isPresent
? Double.compare(value, other.value) == 0
: isPresent == other.isPresent);
}
/**
* Returns the hash code of the value, if present, otherwise {@code 0}
* (zero) if no value is present.
*
* @return hash code value of the present value or {@code 0} if no value is
* present
*/
@Override
public int hashCode() {
return isPresent ? Double.hashCode(value) : 0;
}
/**
* Returns a non-empty string representation of this {@code OptionalDouble}
* suitable for debugging. The exact presentation format is unspecified and
* may vary between implementations and versions.
*
* @return the string representation of this instance
* @implSpec If a value is present the result must include its string representation
* in the result. Empty and present {@code OptionalDouble}s must be
* unambiguously differentiable.
*/
@Override
public String toString() {
return isPresent
? ("OptionalDouble[" + value + "]")
: "OptionalDouble.empty";
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2010, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util.function;
import java.util.Objects;
/**
* Represents an operation that accepts a single input argument and returns no
* result. Unlike most other functional interfaces, {@code Consumer} is expected
* to operate via side-effects.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #accept(Object)}.
*
* @param <T> the type of the input to the operation
* @since 1.8
*/
@FunctionalInterface
public interface Consumer<T> {
/**
* Performs this operation on the given argument.
*
* @param t the input argument
*/
void accept(T t);
/**
* Returns a composed {@code Consumer} that performs, in sequence, this
* operation followed by the {@code after} operation. If performing either
* operation throws an exception, it is relayed to the caller of the
* composed operation. If performing this operation throws an exception,
* the {@code after} operation will not be performed.
*
* @param after the operation to perform after this operation
* @return a composed {@code Consumer} that performs in sequence this
* operation followed by the {@code after} operation
* @throws NullPointerException if {@code after} is null
*/
default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> {
this.accept(t);
after.accept(t);
};
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2010, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util.function;
import java.util.Objects;
/**
* Represents an operation that accepts a single {@code double}-valued argument and
* returns no result. This is the primitive type specialization of
* {@link Consumer} for {@code double}. Unlike most other functional interfaces,
* {@code DoubleConsumer} is expected to operate via side-effects.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #accept(double)}.
*
* @see Consumer
* @since 1.8
*/
@FunctionalInterface
public interface DoubleConsumer {
/**
* Performs this operation on the given argument.
*
* @param value the input argument
*/
void accept(double value);
/**
* Returns a composed {@code DoubleConsumer} that performs, in sequence, this
* operation followed by the {@code after} operation. If performing either
* operation throws an exception, it is relayed to the caller of the
* composed operation. If performing this operation throws an exception,
* the {@code after} operation will not be performed.
*
* @param after the operation to perform after this operation
* @return a composed {@code DoubleConsumer} that performs in sequence this
* operation followed by the {@code after} operation
* @throws NullPointerException if {@code after} is null
*/
default DoubleConsumer andThen(DoubleConsumer after) {
Objects.requireNonNull(after);
return (double t) -> {
this.accept(t);
after.accept(t);
};
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2012, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util.function;
/**
* Represents a supplier of {@code double}-valued results. This is the
* {@code double}-producing primitive specialization of {@link Supplier}.
*
* <p>There is no requirement that a distinct result be returned each
* time the supplier is invoked.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #getAsDouble()}.
*
* @see Supplier
* @since 1.8
*/
@FunctionalInterface
public interface DoubleSupplier {
/**
* Gets a result.
*
* @return a result
*/
double getAsDouble();
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2010, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util.function;
import java.util.Objects;
/**
* Represents a function that accepts one argument and produces a result.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #apply(Object)}.
*
* @param <T> the type of the input to the function
* @param <R> the type of the result of the function
* @since 1.8
*/
@FunctionalInterface
public interface Function<T, R> {
/**
* Returns a function that always returns its input argument.
*
* @param <T> the type of the input and output objects to the function
* @return a function that always returns its input argument
*/
static <T> Function<T, T> identity() {
return t -> t;
}
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
*/
R apply(T t);
/**
* Returns a composed function that first applies the {@code before}
* function to its input, and then applies this function to the result.
* If evaluation of either function throws an exception, it is relayed to
* the caller of the composed function.
*
* @param <V> the type of input to the {@code before} function, and to the
* composed function
* @param before the function to apply before this function is applied
* @return a composed function that first applies the {@code before}
* function and then applies this function
* @throws NullPointerException if before is null
* @see #andThen(Function)
*/
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> this.apply(before.apply(v));
}
/**
* Returns a composed function that first applies this function to
* its input, and then applies the {@code after} function to the result.
* If evaluation of either function throws an exception, it is relayed to
* the caller of the composed function.
*
* @param <V> the type of output of the {@code after} function, and of the
* composed function
* @param after the function to apply after this function is applied
* @return a composed function that first applies this function and then
* applies the {@code after} function
* @throws NullPointerException if after is null
* @see #compose(Function)
*/
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(this.apply(t));
}
}

View file

@ -0,0 +1,136 @@
/*
* Copyright (c) 2010, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util.function;
import java.util.Objects;
/**
* Represents a predicate (boolean-valued function) of one argument.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #test(Object)}.
*
* @param <T> the type of the input to the predicate
* @since 1.8
*/
@FunctionalInterface
public interface Predicate<T> {
/**
* Returns a predicate that tests if two arguments are equal according
* to {@link Objects#equals(Object, Object)}.
*
* @param <T> the type of arguments to the predicate
* @param targetRef the object reference with which to compare for equality,
* which may be {@code null}
* @return a predicate that tests if two arguments are equal according
* to {@link Objects#equals(Object, Object)}
*/
static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
/**
* Returns a predicate that is the negation of the supplied predicate.
* This is accomplished by returning result of the calling
* {@code target.negate()}.
*
* @param <T> the type of arguments to the specified predicate
* @param target predicate to negate
* @return a predicate that negates the results of the supplied
* predicate
* @throws NullPointerException if target is null
* @since 11
*/
@SuppressWarnings("unchecked")
static <T> Predicate<T> not(Predicate<? super T> target) {
Objects.requireNonNull(target);
return (Predicate<T>) target.negate();
}
/**
* Evaluates this predicate on the given argument.
*
* @param t the input argument
* @return {@code true} if the input argument matches the predicate,
* otherwise {@code false}
*/
boolean test(T t);
/**
* Returns a composed predicate that represents a short-circuiting logical
* AND of this predicate and another. When evaluating the composed
* predicate, if this predicate is {@code false}, then the {@code other}
* predicate is not evaluated.
*
* <p>Any exceptions thrown during evaluation of either predicate are relayed
* to the caller; if evaluation of this predicate throws an exception, the
* {@code other} predicate will not be evaluated.
*
* @param other a predicate that will be logically-ANDed with this
* predicate
* @return a composed predicate that represents the short-circuiting logical
* AND of this predicate and the {@code other} predicate
* @throws NullPointerException if other is null
*/
default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> this.test(t) && other.test(t);
}
/**
* Returns a predicate that represents the logical negation of this
* predicate.
*
* @return a predicate that represents the logical negation of this
* predicate
*/
default Predicate<T> negate() {
return (t) -> !this.test(t);
}
/**
* Returns a composed predicate that represents a short-circuiting logical
* OR of this predicate and another. When evaluating the composed
* predicate, if this predicate is {@code true}, then the {@code other}
* predicate is not evaluated.
*
* <p>Any exceptions thrown during evaluation of either predicate are relayed
* to the caller; if evaluation of this predicate throws an exception, the
* {@code other} predicate will not be evaluated.
*
* @param other a predicate that will be logically-ORed with this
* predicate
* @return a composed predicate that represents the short-circuiting logical
* OR of this predicate and the {@code other} predicate
* @throws NullPointerException if other is null
*/
default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> this.test(t) || other.test(t);
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2012, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package rs.chir.compat.java.util.function;
/**
* Represents a supplier of results.
*
* <p>There is no requirement that a new or distinct result be returned each
* time the supplier is invoked.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #get()}.
*
* @param <T> the type of results supplied by this supplier
* @since 1.8
*/
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}

View file

@ -0,0 +1,4 @@
/**
* Openjdk backports for android.
*/
package rs.chir.compat;

View file

@ -0,0 +1,107 @@
package rs.chir.invtracker.model;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Contract;
import org.w3c.dom.Node;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import rs.chir.compat.java.util.Optional;
import rs.chir.utils.IterableNodeList;
import rs.chir.utils.MappableList;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
public final class Cursor<T extends XMLSerializable> implements XMLSerializable {
public static final XMLDeserializable DESERIALIZER = (node) -> {
MappableList<XMLSerializable> items;
try {
items = new IterableNodeList(node.getChildNodes()).filterMap(item -> {
if (item.getNodeType() != Node.ELEMENT_NODE) {
return Optional.empty();
}
try {
return Optional.of(SimpleXML.deserializeGeneric(item));
} catch (IOException e) {
throw new IllegalStateException(e);
}
});
} catch (IllegalStateException ex) {
if (ex.getCause() instanceof IOException) {
throw (IOException) ex.getCause();
} else {
throw ex;
}
}
var attrs = node.getAttributes();
Optional<String> nextId = Optional.empty();
if (attrs.getNamedItem("next") != null) {
nextId = Optional.of(attrs.getNamedItem("next").getNodeValue());
}
return new Cursor<XMLSerializable>(items, nextId);
};
private final List<T> items;
private final Optional<String> nextId;
public Cursor(List<T> items, Optional<String> nextId) {
this.items = items;
this.nextId = nextId;
}
@NonNull
public List<T> items() {
return this.items;
}
@NonNull
public Optional<String> nextId() {
return this.nextId;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
return Objects.equals(this.items, ((Cursor<?>) obj).items) &&
Objects.equals(this.nextId, ((Cursor<?>) obj).nextId);
}
@Override
public int hashCode() {
return Objects.hash(this.items, this.nextId);
}
@NonNull
@Override
public String rootElementName() {
return "cursor";
}
@Override
public void serialize(@NonNull Node node) throws IOException {
var document = node.getOwnerDocument();
this.nextId.ifPresent(id -> {
var attrs = node.getAttributes();
var attrNode = document.createAttribute("next");
attrNode.setValue(id);
attrs.setNamedItem(attrNode);
});
for (var item : items) {
SimpleXML.serializeInto(node, item);
}
}
@NonNull
@Contract(pure = true)
@Override
public String toString() {
return "Cursor[" +
"items=" + items +
", nextId=" + nextId +
']';
}
}

View file

@ -0,0 +1,136 @@
package rs.chir.invtracker.model;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Contract;
import org.w3c.dom.Node;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.Objects;
import rs.chir.compat.java.util.Optional;
import rs.chir.compat.java.util.OptionalDouble;
import rs.chir.utils.IterableNodeList;
import rs.chir.utils.xml.XMLSerializable;
// WGS84 geographic coordinates
public final class GeoLocation implements XMLSerializable {
public static final XMLDeserializable DESERIALIZER = (node) -> {
var latitude = OptionalDouble.empty();
var longitude = OptionalDouble.empty();
var altitude = OptionalDouble.empty();
Optional<Timestamp> locationTime = Optional.empty();
for (var child : new IterableNodeList(node.getChildNodes())) {
if (child.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
var childName = child.getNodeName();
if ("lat".equals(childName)) {
latitude = OptionalDouble.of(Double.parseDouble(child.getTextContent()));
} else if ("lon".equals(childName)) {
longitude = OptionalDouble.of(Double.parseDouble(child.getTextContent()));
} else if ("alt".equals(childName)) {
altitude = OptionalDouble.of(Double.parseDouble(child.getTextContent()));
} else if ("time".equals(childName)) {
locationTime = Optional.of(new Timestamp(Long.parseLong(child.getTextContent())));
}
}
return new GeoLocation(latitude.orElseThrow(), longitude.orElseThrow(), altitude, locationTime.orElseThrow());
};
private final double latitude;
private final double longitude;
private final OptionalDouble altitude;
private final Timestamp locationTime;
public GeoLocation(double latitude, double longitude, OptionalDouble altitude, Timestamp locationTime) {
this.latitude = latitude;
this.longitude = longitude;
this.altitude = altitude;
this.locationTime = locationTime;
}
private static String formatDegrees(double degrees) {
int degreesInt = (int) degrees;
double minutes = (degrees - degreesInt) * 60;
int minutesInt = (int) minutes;
double seconds = (minutes - minutesInt) * 60;
return String.format("%d° %d %.2f″", degreesInt, minutesInt, seconds);
}
public double latitude() {
return latitude;
}
public double longitude() {
return longitude;
}
public OptionalDouble altitude() {
return altitude;
}
public Timestamp locationTime() {
return locationTime;
}
@NonNull
public String toReadableString() {
return String.format("%s, %s", GeoLocation.formatDegrees(latitude), GeoLocation.formatDegrees(longitude));
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (GeoLocation) obj;
return Double.doubleToLongBits(this.latitude) == Double.doubleToLongBits(that.latitude) &&
Double.doubleToLongBits(this.longitude) == Double.doubleToLongBits(that.longitude) &&
Objects.equals(this.altitude, that.altitude) &&
Objects.equals(this.locationTime, that.locationTime);
}
@Override
public int hashCode() {
return Objects.hash(latitude, longitude, altitude, locationTime);
}
@NonNull
@Contract(pure = true)
@Override
public String toString() {
return "GeoLocation[" +
"latitude=" + latitude + ", " +
"longitude=" + longitude + ", " +
"altitude=" + altitude + ", " +
"locationTime=" + locationTime + ']';
}
@NonNull
@Override
public String rootElementName() {
return "geo";
}
@Override
public void serialize(@NonNull Node node) throws IOException {
var document = node.getOwnerDocument();
var latNode = document.createElement("lat");
var lonNode = document.createElement("lon");
var altNode = document.createElement("alt");
var timeNode = document.createElement("time");
latNode.setTextContent(String.valueOf(latitude));
node.appendChild(latNode);
lonNode.setTextContent(String.valueOf(longitude));
node.appendChild(lonNode);
altitude.ifPresent(aDouble -> {
altNode.setTextContent(String.valueOf(aDouble));
node.appendChild(altNode);
});
timeNode.setTextContent(String.valueOf(locationTime.getTime()));
node.appendChild(timeNode);
}
}

View file

@ -0,0 +1,79 @@
package rs.chir.invtracker.model;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Contract;
import org.w3c.dom.Node;
import java.io.IOException;
import java.util.Objects;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
public final class GeoRect implements XMLSerializable {
public static final XMLDeserializable DESERIALIZER = node -> {
var southWest = SimpleXML.deserialize(node.getFirstChild(), GeoLocation.class);
var northEast = SimpleXML.deserialize(node.getLastChild(), GeoLocation.class);
return new GeoRect(southWest, northEast);
};
private final GeoLocation southWest;
private final GeoLocation northEast;
public GeoRect(@NonNull GeoLocation southWest, @NonNull GeoLocation northEast) {
this.southWest = southWest;
this.northEast = northEast;
}
@NonNull
public GeoLocation southWest() {
return southWest;
}
@NonNull
public GeoLocation northEast() {
return northEast;
}
@NonNull
@Contract(pure = true)
@Override
public String toString() {
return "GeoRect[" +
"southWest=" + southWest +
", northEast=" + northEast +
']';
}
public boolean contains(@NonNull GeoLocation location) {
return location.latitude() >= southWest.latitude() &&
location.latitude() <= northEast.latitude() &&
location.longitude() >= southWest.longitude() &&
location.longitude() <= northEast.longitude();
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (GeoRect) obj;
return Objects.equals(this.southWest, that.southWest) && Objects.equals(this.northEast, that.northEast);
}
@Override
public int hashCode() {
return Objects.hash(southWest, northEast);
}
@NonNull
@Override
public String rootElementName() {
return "rect";
}
@Override
public void serialize(@NonNull Node node) throws IOException {
SimpleXML.serializeInto(node, this.southWest);
SimpleXML.serializeInto(node, this.northEast);
}
}

View file

@ -0,0 +1,60 @@
package rs.chir.invtracker.model;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Contract;
import org.w3c.dom.Node;
import java.io.IOException;
import java.util.Objects;
import rs.chir.utils.xml.XMLSerializable;
public final class PasetoToken implements XMLSerializable {
public static final XMLDeserializable DESERIALIZER = (node) -> {
var token = node.getTextContent();
return new PasetoToken(token);
};
private final String token;
public PasetoToken(String token) {
this.token = token;
}
public String token() {
return token;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (PasetoToken) obj;
return Objects.equals(this.token, that.token);
}
@Override
public int hashCode() {
return Objects.hash(token);
}
@NonNull
@Contract(pure = true)
@Override
public String toString() {
return "PasetoToken[" +
"token=" + token + ']';
}
@NonNull
@Override
public String rootElementName() {
return "token";
}
@Override
public void serialize(@NonNull Node node) throws IOException {
var document = node.getOwnerDocument();
node.setTextContent(token);
}
}

View file

@ -0,0 +1,71 @@
package rs.chir.invtracker.model;
import androidx.annotation.NonNull;
import org.w3c.dom.Node;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Objects;
import rs.chir.compat.java.util.Base64;
import rs.chir.utils.xml.XMLSerializable;
public final class PublicKey implements XMLSerializable {
public static final XMLDeserializable DESERIALIZER = (node) -> {
var publicKey = Base64.getDecoder().decode(node.getTextContent());
try (var bis = new ByteArrayInputStream(publicKey);
var ois = new ObjectInputStream(bis)) {
return new PublicKey((java.security.PublicKey) ois.readObject());
} catch (ClassNotFoundException ex) {
throw new IllegalStateException("Class not found", ex);
}
};
private final java.security.PublicKey publicKey;
public PublicKey(java.security.PublicKey publicKey) {
this.publicKey = publicKey;
}
public java.security.PublicKey publicKey() {
return publicKey;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (PublicKey) obj;
return Objects.equals(this.publicKey, that.publicKey);
}
@Override
public int hashCode() {
return Objects.hash(publicKey);
}
@Override
public String toString() {
return "PublicKey[" +
"publicKey=" + publicKey + ']';
}
@NonNull
@Override
public String rootElementName() {
return "public-key";
}
@Override
public void serialize(@NonNull Node node) throws IOException {
try (var bos = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(bos)) {
oos.writeObject(publicKey);
var encoded = Base64.getEncoder().encodeToString(bos.toByteArray());
node.setTextContent(encoded);
}
}
}

View file

@ -0,0 +1,149 @@
package rs.chir.invtracker.model;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Contract;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import java.io.IOException;
import java.net.URI;
import java.util.Objects;
import rs.chir.compat.java.util.Optional;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
public final class TrackedItem implements XMLSerializable {
public static final XMLDeserializable DESERIALIZER = (node) -> {
var attrs = node.getAttributes();
var id = Long.parseUnsignedLong(attrs.getNamedItem("id").getNodeValue());
if (node instanceof Element) {
var element = (Element) node;
var name = element.getElementsByTagName("name").item(0).getTextContent();
var description = element.getElementsByTagName("description").item(0).getTextContent();
Optional<URI> picture = Optional.empty();
var pictureNode = element.getElementsByTagName("picture").item(0);
if (pictureNode != null) {
picture = Optional.of(URI.create(pictureNode.getTextContent()));
}
Optional<GeoLocation> location = Optional.empty();
try {
var geoElement = element.getElementsByTagName("geo").item(0);
if (geoElement != null) {
location = Optional.of(SimpleXML.deserialize(geoElement, GeoLocation.class));
}
} catch (IOException e) {
throw new IllegalStateException(e);
}
return new TrackedItem(id, name, description, picture, location);
} else {
throw new IllegalStateException("Expected element, got " + node.getClass());
}
};
private final long id;
private final String name;
private final String description;
private final Optional<URI> picture;
private final Optional<GeoLocation> lastKnownLocation;
public TrackedItem(long id, String name, String description, Optional<URI> picture, Optional<GeoLocation> lastKnownLocation) {
this.id = id;
this.name = name;
this.description = description;
this.picture = picture;
this.lastKnownLocation = lastKnownLocation;
}
public long id() {
return id;
}
public String name() {
return name;
}
public String description() {
return description;
}
public Optional<URI> picture() {
return picture;
}
public Optional<GeoLocation> lastKnownLocation() {
return lastKnownLocation;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (TrackedItem) obj;
return this.id == that.id &&
Objects.equals(this.name, that.name) &&
Objects.equals(this.description, that.description);
}
@Override
public int hashCode() {
return Objects.hash(id, name, description);
}
@NonNull
@Contract(pure = true)
@Override
public String toString() {
return "TrackedItem[" +
"id=" + id + ", " +
"name=" + name + ", " +
"description=" + description + ", " +
"picture=" + picture + ", " +
"lastKnownLocation=" + lastKnownLocation + ']';
}
@NonNull
@Override
public String rootElementName() {
return "tracked-item";
}
@Override
public void serialize(@NonNull Node node) throws IOException {
var document = node.getOwnerDocument();
var attrs = node.getAttributes();
var attrNode = document.createAttribute("id");
attrNode.setValue(Long.toString(id));
attrs.setNamedItem(attrNode);
var nameNode = document.createElement("name");
nameNode.setTextContent(name);
node.appendChild(nameNode);
var descriptionNode = document.createElement("description");
descriptionNode.setTextContent(description);
node.appendChild(descriptionNode);
picture.ifPresent(uri -> {
var pictureNode = document.createElement("picture");
pictureNode.setTextContent(uri.toString());
node.appendChild(pictureNode);
});
try {
lastKnownLocation.ifPresent(location -> {
try {
SimpleXML.serializeInto(node, location);
} catch (IOException e) {
throw new IllegalStateException(e);
}
});
} catch (IllegalStateException ex) {
if (ex.getCause() instanceof IOException) {
throw (IOException) ex.getCause();
} else {
throw ex;
}
}
}
}

View file

@ -0,0 +1,27 @@
package rs.chir.utils;
import androidx.annotation.NonNull;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.util.RandomAccess;
public class IterableNodeList extends MappableList<Node> implements RandomAccess {
private final NodeList nodeList;
public IterableNodeList(@NonNull NodeList nodeList) {
this.nodeList = nodeList;
}
@Override
public Node get(int i) {
return this.nodeList.item(i);
}
@Override
public int size() {
return this.nodeList.getLength();
}
}

View file

@ -0,0 +1,35 @@
package rs.chir.utils;
import java.util.ArrayList;
import java.util.HashMap;
public class LRUCache<K, V> {
private final HashMap<K, V> cache;
private final ArrayList<K> recency;
private final int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>(capacity);
this.recency = new ArrayList<>(capacity);
}
public V get(K key) {
V value = cache.get(key);
if (value != null) {
recency.remove(key);
recency.add(key);
}
return value;
}
public void put(K key, V value) {
cache.put(key, value);
recency.remove(key);
recency.add(key);
if (recency.size() > capacity) {
K oldest = recency.remove(0);
cache.remove(oldest);
}
}
}

View file

@ -0,0 +1,28 @@
package rs.chir.utils;
import androidx.annotation.NonNull;
import java.io.IOException;
import java.io.InputStream;
public class LimitedInputStream extends InputStream {
private final InputStream in;
private final long limit;
private long index;
public LimitedInputStream(@NonNull InputStream in, long limit) {
this.in = in;
this.index = 0;
this.limit = limit;
}
@Override
public int read() throws IOException {
if (index >= limit) {
return -1;
}
index++;
return in.read();
}
}

View file

@ -0,0 +1,51 @@
package rs.chir.utils;
import androidx.annotation.NonNull;
import java.util.AbstractList;
import java.util.ArrayList;
import java.util.List;
import rs.chir.compat.java.util.Optional;
import rs.chir.compat.java.util.function.Function;
public abstract class MappableList<T> extends AbstractList<T> {
@NonNull
public <U> MappableList<U> map(@NonNull Function<T, U> mapper) {
var list = new ArrayList<U>(this.size());
for (var item : this) {
list.add(mapper.apply(item));
}
return new WrappedList<>(list);
}
@NonNull
public <U> MappableList<U> filterMap(@NonNull Function<T, Optional<U>> mapper) {
var list = new ArrayList<U>(this.size());
for (var item : this) {
mapper.apply(item).ifPresent(list::add);
}
return new WrappedList<>(list);
}
private static class WrappedList<U> extends MappableList<U> {
private final List<U> list;
public WrappedList(List<U> list) {
this.list = list;
}
@Override
public U get(int index) {
return list.get(index);
}
public int size() {
return list.size();
}
}
}

View file

@ -0,0 +1,27 @@
package rs.chir.utils;
public class Pair<T1, T2> {
private T1 first;
private T2 second;
public Pair(T1 first, T2 second) {
this.first = first;
this.second = second;
}
public T1 getFirst() {
return first;
}
public void setFirst(T1 first) {
this.first = first;
}
public T2 getSecond() {
return second;
}
public void setSecond(T2 second) {
this.second = second;
}
}

View file

@ -0,0 +1,41 @@
package rs.chir.utils;
public enum Snowflake {
;
private static final ThreadLocal<Short> increment = ThreadLocal.withSupplier(() -> (short) 0);
private static final short SNOWFLAKE_COUNTER_MAX = 0xFFF;
private static final int THREAD_ID_MAX = 0x1F;
private static final int PROCESS_ID_MAX = 0x1F;
private static final long EPOCH = 1659366782000L;
private static final int TIMESTAMP_SHIFT = 22;
private static final int PID_SHIFT = 17;
private static final int TID_SHIFT = 12;
private static short getIncrement() {
var increment = Snowflake.increment.get();
if (increment == SNOWFLAKE_COUNTER_MAX) {
Snowflake.increment.set((short) 0);
} else {
Snowflake.increment.set((short) (increment + 1));
}
return increment;
}
private static char getThreadId() {
return (char) (Thread.currentThread().getId() & THREAD_ID_MAX);
}
private static char getProcessId() {
return 0; // TODO
}
public static long generate() {
var timestamp = System.currentTimeMillis() - EPOCH;
var increment = Snowflake.getIncrement();
var threadId = Snowflake.getThreadId();
var processId = Snowflake.getProcessId();
return (timestamp << TIMESTAMP_SHIFT) | ((long) processId << PID_SHIFT) | ((long) threadId << TID_SHIFT) | increment;
}
}

View file

@ -0,0 +1,49 @@
package rs.chir.utils;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Contract;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import rs.chir.compat.java.util.function.Supplier;
public class ThreadLocal<T> {
private final Map<Long, T> map = new ConcurrentHashMap<>(1);
private final Supplier<T> supplier;
private ThreadLocal(Supplier<T> supplier) {
this.supplier = supplier;
}
public ThreadLocal() {
this(() -> null);
}
@NonNull
@Contract("_ -> new")
public static <T> ThreadLocal<T> withSupplier(@NonNull Supplier<T> supplier) {
return new ThreadLocal<>(supplier);
}
public T get() {
var threadId = Thread.currentThread().getId();
var value = map.get(threadId);
if (value == null) {
value = supplier.get();
map.put(threadId, value);
}
return value;
}
public void set(T value) {
var threadId = Thread.currentThread().getId();
map.put(threadId, value);
}
public void remove() {
var threadId = Thread.currentThread().getId();
map.remove(threadId);
}
}

View file

@ -0,0 +1,14 @@
package rs.chir.utils;
import androidx.annotation.NonNull;
import java.lang.reflect.ParameterizedType;
@SuppressWarnings("unchecked")
public interface TypeParameterGetter<T> {
// https://stackoverflow.com/questions/1901164/get-type-of-a-generic-parameter-in-java-with-reflection
@NonNull
default Class<T> getTypeParameter() {
return (Class<T>) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
}
}

View file

@ -0,0 +1,27 @@
package rs.chir.utils.math;
public class Vec2<T extends Number> {
private T x;
private T y;
public Vec2(T x, T y) {
this.x = x;
this.y = y;
}
public T getX() {
return x;
}
public void setX(T x) {
this.x = x;
}
public T getY() {
return y;
}
public void setY(T y) {
this.y = y;
}
}

Some files were not shown because too many files have changed in this diff Show more