Add comments

This commit is contained in:
Morten Delenk 2022-08-13 11:09:40 +01:00
parent f3534a8431
commit cb905eec81
No known key found for this signature in database
GPG key ID: 5130416C797067B6
85 changed files with 1650 additions and 194 deletions

View file

@ -78,7 +78,7 @@ OUTPUT_DIRECTORY = /home/darkkirb/AndroidStudioProjects/Invtracker/doc
# control the number of sub-directories.
# The default value is: NO.
CREATE_SUBDIRS = NO
CREATE_SUBDIRS = YES
# Controls the number of sub-directories that will be created when
# CREATE_SUBDIRS tag is set to YES. Level 0 represents 16 directories, and every
@ -164,7 +164,7 @@ ALWAYS_DETAILED_SEC = NO
# operators of the base classes will not be shown.
# The default value is: NO.
INLINE_INHERITED_MEMB = NO
INLINE_INHERITED_MEMB = YES
# If the FULL_PATH_NAMES tag is set to YES, doxygen will prepend the full path
# before files name in the file list and in the header files. If set to NO the
@ -208,7 +208,7 @@ SHORT_NAMES = NO
# description.)
# The default value is: NO.
JAVADOC_AUTOBRIEF = NO
JAVADOC_AUTOBRIEF = YES
# If the JAVADOC_BANNER tag is set to YES then doxygen will interpret a line
# such as
@ -484,7 +484,7 @@ LOOKUP_CACHE_SIZE = 0
# DOT_NUM_THREADS setting.
# Minimum value: 0, maximum value: 32, default value: 1.
NUM_PROC_THREADS = 1
NUM_PROC_THREADS = 0
#---------------------------------------------------------------------------
# Build related configuration options
@ -1117,7 +1117,7 @@ USE_MDFILE_AS_MAINPAGE =
# also VERBATIM_HEADERS is set to NO.
# The default value is: NO.
SOURCE_BROWSER = NO
SOURCE_BROWSER = YES
# Setting the INLINE_SOURCES tag to YES will include the body of functions,
# classes and enums directly into the documentation.
@ -1136,13 +1136,13 @@ STRIP_CODE_COMMENTS = YES
# entity all documented functions referencing it will be listed.
# The default value is: NO.
REFERENCED_BY_RELATION = NO
REFERENCED_BY_RELATION = YES
# If the REFERENCES_RELATION tag is set to YES then for each documented function
# all documented entities called/used by that function will be listed.
# The default value is: NO.
REFERENCES_RELATION = NO
REFERENCES_RELATION = YES
# If the REFERENCES_LINK_SOURCE tag is set to YES and SOURCE_BROWSER tag is set
# to YES then the hyperlinks from functions in REFERENCES_RELATION and

View file

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

View file

@ -7,9 +7,19 @@ import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
/**
* The menu provider for {@link MapFragment}
*/
public class MapMenuProvider implements MenuProvider {
/**
* The fragment
*/
private final MapFragment mapFragment;
/**
* Creates a new instance of {@link MapMenuProvider}
* @param mapFragment the fragment
*/
public MapMenuProvider(MapFragment mapFragment) {
this.mapFragment = mapFragment;
}
@ -23,12 +33,15 @@ public class MapMenuProvider implements MenuProvider {
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
int itemId = menuItem.getItemId();
if (itemId == R.id.action_settings) {
// Show the settings
this.mapFragment.navigate(R.id.action_mapFragment_to_settingsFragment);
return true;
} else if (itemId == R.id.action_list_view) {
// Show the list view
this.mapFragment.getNavController().navigateUp();
return true;
} else if (itemId == R.id.action_create_item) {
// Create a new item
this.mapFragment.navigate(MapFragmentDirections.actionMapFragmentToEditItemFragment(0));
return true;
}

View file

@ -20,18 +20,27 @@ import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.utils.SingleLocation;
import rs.chir.invtracker.utils.SinglePermission;
/**
* Fragment that displays a list of items.
*/
public class NearbyFragment extends FragmentBase<FragmentNearbyBinding> {
/**
* The filter mode
*/
private ListFilterMode filterMode = ListFilterMode.NEARBY;
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Check permissions
if (this.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
this.fetchData();
} else {
// Request permission before fetching data
this.subscribe(new SinglePermission(this, Manifest.permission.ACCESS_FINE_LOCATION), __ -> this.fetchData());
}
// Initialize recyclerview
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));
@ -49,18 +58,28 @@ public class NearbyFragment extends FragmentBase<FragmentNearbyBinding> {
this.switchFilter(ListFilterMode.ALL);
}
/**
* Change the filter mode.
* @param filterMode The new filter mode.
*/
void switchFilter(ListFilterMode filterMode) {
this.filterMode = filterMode;
// Redraw the menu to reflect the new filter mode
this.getMenuHost().invalidateMenu();
// clear the recyclerview
this.getBinding().nearbyRecyclerView.setAdapter(null);
this.fetchData();
}
/**
* Fetch data from the server.
*/
private void fetchData() {
Log.i("NearbyFragment", "fetchData");
switch (this.filterMode) {
// Fetch all items
case ALL -> this.getBinding().nearbyRecyclerView.setAdapter(new ItemListAdapter(this.requireContext()));
// Fetch items within 0.001° of the current location
case NEARBY -> {
var fusedLocationClient = LocationServices.getFusedLocationProviderClient(this.requireActivity());
this.subscribe(SingleLocation.getSingleLocation(fusedLocationClient, this.requireContext()), this::onLocationResponse);
@ -68,10 +87,15 @@ public class NearbyFragment extends FragmentBase<FragmentNearbyBinding> {
}
}
/**
* Called when the location response is received.
* @param location The location.
*/
private void onLocationResponse(@NonNull GeoLocation location) {
double latitude = location.latitude();
double longitude = location.longitude();
// Calculate the bounds of the search area
double southBound = latitude - 0.001;
double northBound = latitude + 0.001;
double westBound = longitude - 0.001;
@ -81,6 +105,7 @@ public class NearbyFragment extends FragmentBase<FragmentNearbyBinding> {
.nearbyRecyclerView
.setAdapter(new ItemListAdapter(
this.getContext(),
// Create a geo rect that contains the search area
Optional.of(new GeoRect(
new GeoLocation(southBound, westBound, location.altitude(), location.locationTime()),
new GeoLocation(northBound, eastBound, location.altitude(), location.locationTime())

View file

@ -8,9 +8,19 @@ import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
import androidx.navigation.fragment.NavHostFragment;
/**
* The menu provider for {@link NearbyFragment}
*/
class NearbyMenuProvider implements MenuProvider {
/**
* The fragment
*/
private final NearbyFragment nearbyFragment;
/**
* Creates a new instance of {@link NearbyMenuProvider}
* @param nearbyFragment the fragment
*/
public NearbyMenuProvider(NearbyFragment nearbyFragment) {
this.nearbyFragment = nearbyFragment;
}
@ -24,18 +34,23 @@ class NearbyMenuProvider implements MenuProvider {
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
int itemId = menuItem.getItemId();
if (itemId == R.id.action_settings) {
// Show the settings
nearbyFragment.navigate(R.id.action_nearbyFragment_to_settingsFragment);
return true;
} else if (itemId == R.id.action_local_only) {
// Show the local items only
nearbyFragment.switchFilter(ListFilterMode.NEARBY);
return true;
} else if (itemId == R.id.action_list_all) {
// Show all items
nearbyFragment.switchFilter(ListFilterMode.ALL);
return true;
} else if (itemId == R.id.action_show_map) {
// Show the map
nearbyFragment.navigate(R.id.action_nearbyFragment_to_mapFragment);
return true;
} else if (itemId == R.id.action_create_item) {
// Create a new item
nearbyFragment.navigate(NearbyFragmentDirections.actionNearbyFragmentToEditItemFragment(0));
}
return false;
@ -46,10 +61,12 @@ class NearbyMenuProvider implements MenuProvider {
MenuProvider.super.onPrepareMenu(menu);
switch (nearbyFragment.getFilterMode()) {
case ALL -> {
// show the show local items only menu item
menu.findItem(R.id.action_local_only).setVisible(true);
menu.findItem(R.id.action_list_all).setVisible(false);
}
case NEARBY -> {
// show the show all items menu item
menu.findItem(R.id.action_local_only).setVisible(false);
menu.findItem(R.id.action_list_all).setVisible(true);
}

View file

@ -7,18 +7,30 @@ import androidx.recyclerview.widget.RecyclerView;
import rs.chir.invtracker.client.model.ItemListAdapter;
/**
* OnTouchListener for the recycler view in {@link NearbyFragment}.
*/
class NearbyOnTouchListener implements RecyclerView.OnItemTouchListener {
/**
* The fragment
*/
private final NearbyFragment nearbyFragment;
/**
* Creates a new instance of {@link NearbyOnTouchListener}
* @param nearbyFragment the fragment
*/
public NearbyOnTouchListener(NearbyFragment nearbyFragment) {
this.nearbyFragment = nearbyFragment;
}
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
// Find the element that was touched
var childView = rv.findChildViewUnder(e.getX(), e.getY());
var adapter = (ItemListAdapter) rv.getAdapter();
if (childView != null && adapter != null && e.getAction() == MotionEvent.ACTION_UP) {
// We have found one and the touch has ended. Get the position and try to navigate to it
var position = rv.getChildAdapterPosition(childView);
if (position != RecyclerView.NO_POSITION) {
long id = adapter.positionToId(position);

View file

@ -25,26 +25,28 @@ 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.
* Fragment that displays a QR code for the item.
*/
public class QRCodeFragment extends FragmentBase<FragmentQRCodeBinding> {
/**
* Item id argument
*/
private static final String ARG_ID = "id";
/**
* Item id
*/
private long mId;
/**
* QR code bitmap
*/
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.
* @param itemId The id of the item to show QR code for.
* @return A new instance of fragment QRCodeFragment.
*/
@NonNull
public static QRCodeFragment newInstance(long itemId) {
@ -63,12 +65,20 @@ public class QRCodeFragment extends FragmentBase<FragmentQRCodeBinding> {
}
}
/**
* Generates the QR code bitmap.
*/
private void generateQRCode() throws WriterException {
var hints = Map.of(
// Use a QR code with error correction level H (30%).
// This is because the QR is outdoors or in a dark place or could be damaged or dirty.
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H,
EncodeHintType.CHARACTER_SET, "UTF-8");
var writer = new QRCodeWriter();
// In theory the QR could could just be the mId, however to make it clear that the QR is for this application,
// we use a full URL.
var matrix = writer.encode("https://invtracker.chir.rs/objects/" + mId, BarcodeFormat.QR_CODE, 256, 256, hints);
// Somehow there are no greyscale bitmaps in Android.
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++) {
@ -82,17 +92,20 @@ public class QRCodeFragment extends FragmentBase<FragmentQRCodeBinding> {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Generate QR code
try {
this.generateQRCode();
} catch (WriterException e) {
} catch (WriterException e) { // Should never happen
this.onActionError(e);
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// pre android 10, we need external storage permission to save the QR code to the gallery
// so we ask for it here and disable the save button until we have it
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().saveToGalleryButton.setEnabled(false)); // may be extraneous
}
this.getBinding().imageView.setImageBitmap(this.bitmap);
@ -100,17 +113,19 @@ public class QRCodeFragment extends FragmentBase<FragmentQRCodeBinding> {
this.getBinding().saveToGalleryButton.setOnClickListener(__ -> {
try {
// save the QR code to the gallery
BitmapStorage.create(this.requireContext()).storeBitmap(this.bitmap, "qr_code_" + mId);
} catch (IOException e) {
this.onActionError(e);
}
this.getNavController().navigateUp();
this.getNavController().navigateUp(); // we are done here
});
this.getBinding().print.setOnClickListener(__ -> {
// Open the print dialog
var printHelper = new PrintHelper(this.requireContext());
printHelper.printBitmap("QR code", this.bitmap);
this.getNavController().navigateUp();
});
}
}
}

View file

@ -34,12 +34,27 @@ import rs.chir.invtracker.utils.SingleLocation;
import rs.chir.invtracker.utils.SinglePermission;
import rs.chir.utils.math.Vec2;
/**
* QR Scan fragment.
*/
public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implements QRAnalyzer.QRAnalyzerListener {
/**
* QR code link pattern
*/
private final Pattern qrPattern = Pattern.compile("^https://invtracker.chir.rs/objects/(\\d+)$");
/**
* True when the QR code has been scanned
*/
private final AtomicBoolean hasScanned = new AtomicBoolean(false);
/**
* True when the fragment is paused
*/
private boolean hasPaused = false;
/**
* Initializes the cosmetic alignment box
*/
private void initViewfinderBox() {
var viewfinderSquare = this.getBinding().viewfinderSquare;
int primaryColor = MaterialColors.getColor(viewfinderSquare, com.google.android.material.R.attr.colorPrimary);
@ -62,6 +77,7 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
@Override
protected void onActionError(@NonNull Throwable throwable) {
super.onActionError(throwable);
// return back on error
this.getNavController().popBackStack();
}
@ -80,6 +96,10 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
}
}
/**
* Returns the size of the application window in pixels
* @return the size of the application window in pixels
*/
@NonNull
@Contract(" -> new")
private Vec2<Integer> getScreenSize() {
@ -95,19 +115,20 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
}
private void startCamera() {
// request the camera provider
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;
return; // currently still processing another QR code
}
var matcher = this.qrPattern.matcher(qrCode);
if (matcher.find()) {
var group = matcher.group(1);
var group = matcher.group(1); // find the group that contains the object id
if (group == null)
return;
return; // should not happen
// while we are determining the location, we dont want to show the user the viewfinder
this.hideUI(true);
var id = Long.parseLong(group);
@ -127,6 +148,10 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
}
}
/**
* Show or hide the viewfinder
* @param hide true to hide the viewfinder, false to show it
*/
private void hideUI(boolean b) {
var visibility = b ? View.INVISIBLE : View.VISIBLE;
this.getBinding().fab.setVisibility(visibility);
@ -134,6 +159,11 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
this.getBinding().viewFinder.setVisibility(visibility);
}
/**
* Update the location of the object
* @param id the object id
* @param location the location of the object
*/
private void updateLocation(long id, GeoLocation location) {
this.subscribe(
this.getClient()
@ -147,27 +177,44 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
);
}
/**
* Show the detail view of the object
* @param id the object id
*/
private void showDetailView(long id) {
this.navigate(QRScanFragmentDirections.actionQRScanFragmentToItemDetailFragment(id));
}
/**
* Initializes the camera
* @param cameraProvider the camera provider
*/
private void openedCamera(@NonNull ProcessCameraProvider cameraProvider) {
// Get the device rotation
var rotation = this.getBinding().viewFinder.getDisplay().getRotation();
// Select a back-facing camera
var cameraSelector = new CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build();
// create a preview
var preview = new Preview.Builder()
.setTargetAspectRatio(AspectRatio.RATIO_16_9)
.setTargetAspectRatio(AspectRatio.RATIO_16_9) // TODO: handle other aspect ratios
.setTargetRotation(rotation)
.build();
// and an image analyzer
var imageAnalysis = new ImageAnalysis.Builder()
.setTargetRotation(rotation)
.setTargetAspectRatio(AspectRatio.RATIO_16_9)
.setTargetAspectRatio(AspectRatio.RATIO_4_3) // 🤷 Really, how many cameras dont have a 4:3 sensor?
.build();
imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor(), new QRAnalyzer(this));
// give the analyzer a threadpool and a callback
imageAnalysis.setAnalyzer(Executors.newCachedThreadPool(), new QRAnalyzer(this));
// unbind any exsting things
cameraProvider.unbindAll();
try {
// bind the camera to the life cycle of the fragment
var camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis);
// give the preview an image
preview.setSurfaceProvider(this.getBinding().viewFinder.getSurfaceProvider());
// Add the flashlight button if the device has one
@ -176,6 +223,7 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
AtomicBoolean flash = new AtomicBoolean(false);
this.getBinding().fab.setOnClickListener(v -> {
var control = camera.getCameraControl();
// toggle the flash
if (flash.get()) {
control.enableTorch(false);
flash.set(false);
@ -190,4 +238,4 @@ public class QRScanFragment extends FragmentBase<FragmentQrScanBinding> implemen
Snackbar.make(this.getBinding().getRoot(), "Error: " + e.getMessage(), BaseTransientBottomBar.LENGTH_LONG).show();
}
}
}
}

View file

@ -8,11 +8,16 @@ import androidx.preference.SeekBarPreference;
import rs.chir.compat.java.util.function.Consumer;
/**
* The fragment that shows the settings screen.
*/
public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
this.setPreferencesFromResource(R.xml.root_preferences, rootKey);
// Set summary to current values, for some reason this isnt the default behavior.
ListPreference listPreference = this.findPreference("location_privacy");
if (listPreference != null) {
listPreference.setSummaryProvider(__ -> {
@ -24,7 +29,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
SeekBarPreference seekBarPreference = this.findPreference("location_accuracy");
if (seekBarPreference != null) {
seekBarPreference.setUpdatesContinuously(true);
seekBarPreference.setUpdatesContinuously(true); // so that you get updates to the summary immediately
// For some reason setSummaryProvider doesnt work for SeekBarPreference, so we have to do it manually.
Consumer<Integer> setSeekbarPercentage = value -> {
var summary = "???";
var stringArray = this.getResources().getStringArray(R.array.demo_privacy_accuracy);
@ -40,4 +46,4 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
}
}
}

View file

@ -8,7 +8,14 @@ import androidx.annotation.NonNull;
import java.io.IOException;
/**
* Bitmap storage utility.
*/
public interface BitmapStorage {
/**
* Creates an instance of {@link BitmapStorage} optimized for the current android version.
* @param context The application context.
*/
@NonNull
static BitmapStorage create(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -18,5 +25,11 @@ public interface BitmapStorage {
}
}
/**
* Saves the bitmap to the storage.
* @param bitmap The bitmap to save.
* @param fileName The name of the file to save the bitmap to.
* @throws IOException If the bitmap cannot be saved.
*/
void storeBitmap(@NonNull Bitmap bitmap, @NonNull String name) throws IOException;
}

View file

@ -7,6 +7,9 @@ import androidx.annotation.NonNull;
import java.io.IOException;
/**
* Pre Android 10 compatibility class for BitmapStorage.
*/
public class BitmapStorageOld implements BitmapStorage {
@Override
public void storeBitmap(@NonNull Bitmap bitmap, @NonNull String name) throws IOException {

View file

@ -14,14 +14,25 @@ import java.io.IOException;
import rs.chir.invtracker.client.Application;
/**
* Android 10+ bitmap storage.
*/
@RequiresApi(api = Build.VERSION_CODES.Q)
public class BitmapStorageQ implements BitmapStorage {
/**
* The context.
*/
private final Context context;
/**
* Creates a new instance of {@link BitmapStorageQ}.
* @param context the context to use.
*/
public BitmapStorageQ(@NonNull Context context) {
this.context = context;
}
// Based on https://stackoverflow.com/questions/56904485/how-to-save-an-image-in-android-q-using-mediastore
@Override
public void storeBitmap(@NonNull Bitmap bitmap, @NonNull String name) throws IOException {
var contentResolver = Application.getInstance(this.context).getContentResolver();

View file

@ -9,13 +9,29 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
/**
* Utility class for saving bitmaps to file
*/
public enum BitmapUtils {
;
/**
* Saves the bitmap to file
* @param bitmap The bitmap to save
* @param file The file to save to
* @throws IOException If the file cannot be saved
* @throws FileNotFoundException If the file cannot be found
*/
public static void saveBitmap(@NonNull Bitmap bitmap, @NonNull String fileName) throws FileNotFoundException, IOException {
BitmapUtils.saveBitmap(bitmap, new FileOutputStream(fileName));
}
/**
* Saves the bitmap to an output stream
* @param bitmap The bitmap to save
* @param os The output stream to save to
* @throws IOException If the file cannot be saved
*/
public static void saveBitmap(@NonNull Bitmap bitmap, @NonNull OutputStream os) throws IOException {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
os.flush();

View file

@ -10,9 +10,18 @@ import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Response;
/**
* Adapts a {@link Call} to a {@link Single}.
*/
public class CallAdapter extends Single<Response> implements Callback {
/**
* Subscribers to the call.
*/
private final List<SingleObserver<? super Response>> subscribers = new java.util.ArrayList<>(1);
/**
* Creates a new adapter.
*/
public CallAdapter(@androidx.annotation.NonNull Call call) {
call.enqueue(this);
}

View file

@ -7,22 +7,37 @@ import android.telephony.TelephonyManager;
import androidx.annotation.NonNull;
/**
* Utility class for scoring the connection quality.
*/
public enum ConnectionScorer {
;
/**
* Check if the data saver is enabled
* @param cm the connectivity manager
* @return true if the data saver is enabled
*/
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;
return false; // No data saver before Nougat
}
/**
* Scores the connection quality.
* @param context
* @return the connection quality score
*/
@NonNull
public static ConnectionRating score(@NonNull Context context) {
// Get the connectivity manager
var cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
var activeNetwork = cm.getActiveNetworkInfo();
// check for active network
if (activeNetwork == null || !activeNetwork.isConnected()) {
return ConnectionRating.DISCONNECTED;
return ConnectionRating.DISCONNECTED; // no connection
}
// If background data saver is on, we try to avoid using the network
@ -31,19 +46,34 @@ public enum ConnectionScorer {
}
return switch (activeNetwork.getType()) {
// We assume that WiFi, Ethernet and WiMAX are always fast
case ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_ETHERNET, ConnectivityManager.TYPE_WIMAX -> ConnectionRating.CONNECTED;
// Check if the mobile network can do at least 1Mbps
// A lot of these networks are not used in Germany, but I included them for completeness
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;
case TelephonyManager.NETWORK_TYPE_1xRTT, TelephonyManager.NETWORK_TYPE_CDMA, TelephonyManager.NETWORK_TYPE_EDGE, TelephonyManager.NETWORK_TYPE_GPRS, TelephonyManager.NETWORK_TYPE_IDEN -> ConnectionRating.LIMITED; // Too slow
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; // Probably fast enough
default -> ConnectionRating.CONNECTED;
};
default -> ConnectionRating.LIMITED;
default -> ConnectionRating.LIMITED; // Probably slow, like bluetooth
};
}
/**
* The connection rating.
*/
public enum ConnectionRating {
/**
* The device is connected to a network with acceptable speed.
*/
CONNECTED,
/**
* The device is connected to a network with limited speed.
*/
LIMITED,
/**
* The device is not connected to a network.
*/
DISCONNECTED
}
}

View file

@ -15,11 +15,28 @@ import rs.chir.compat.java.util.function.Function;
import rs.chir.invtracker.model.Cursor;
import rs.chir.utils.xml.XMLSerializable;
/**
* Adapts a {@link Cursor} to a {@link Flowable}.
*/
public class CursorStreamable<T extends XMLSerializable> extends Flowable<T> {
/**
* Subscribers
*/
private final List<Subscriber<? super T>> subscribers = new ArrayList<>(1);
/**
* The cursor supplier
*/
private final Function<Optional<String>, Single<Cursor<T>>> cursorSupplier;
/**
* Whether the loading has started. We dont start streaming data until the first subscriber is
* added.
*/
private final AtomicBoolean isLoading = new AtomicBoolean(false);
/**
* Creates a new streamable.
* @param cursorSupplier the cursor supplier
*/
public CursorStreamable(@NonNull Function<Optional<String>, Single<Cursor<T>>> cursorSupplier) {
this.cursorSupplier = cursorSupplier;
}
@ -32,23 +49,33 @@ public class CursorStreamable<T extends XMLSerializable> extends Flowable<T> {
}
}
/**
* Loads the next chunk of data.
* @param lastId the ID of the last item in the previous chunk
*/
private void loadNextChunk(Optional<String> lastId) {
this.cursorSupplier.apply(lastId)
.subscribeOn(Schedulers.io())
.subscribe(cursor -> {
for (var item : cursor.items()) {
// send all items to all subscribers
for (var subscriber : subscribers) {
subscriber.onNext(item);
}
}
// While the nextId should be empty if there are no more items,
// some places may still return it, so we check for how many items are in the cursor.
if (cursor.nextId().isEmpty() || cursor.items().isEmpty()) {
// no more items to load, tell that to all subscribers
for (var subscriber : subscribers) {
subscriber.onComplete();
}
} else {
// otherwise, load the next chunk
this.loadNextChunk(cursor.nextId());
}
}, t -> {
// notify all subscribers of the error
for (var subscriber : subscribers) {
subscriber.onError(t);
}

View file

@ -11,7 +11,13 @@ import java.util.concurrent.Executor;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
/**
* Adapts a {@link ListenableFuture} to a {@link Single}.
*/
public class ListenableFutureAdapter<T> extends Single<T> {
/**
* The subscribers that are waiting for the future to complete.
*/
private final List<SingleObserver<? super T>> subscribers = new java.util.ArrayList<>(1);
public ListenableFutureAdapter(@NonNull ListenableFuture<T> future, @NonNull Executor executor) {

View file

@ -14,24 +14,59 @@ import io.reactivex.rxjava3.disposables.Disposable;
import rs.chir.compat.java.util.function.Predicate;
import rs.chir.compat.java.util.function.Supplier;
/**
* Performs exponential backoff on a single.
*/
public class SingleBackoff<T> extends Single<T> {
/**
* Maximum backoff delay, in seconds
*/
public static final int MAX_DELAY = 300;
/**
* The supplier of the single to retry
*/
private final Supplier<Single<T>> singleSupplier;
/**
* Observer for the single
*/
private final List<SingleObserver<? super T>> observers = new ArrayList<>(1);
/**
* An exception filter for the single
*/
private final Predicate<? super Throwable> exceptionFilter;
/**
* Current backoff delay, in seconds
*/
private int delay = 1;
/**
* Current subscribed single
*/
private Disposable disposable;
/**
* Creates a new single backoff.
*
* @param singleSupplier the supplier of the single to retry
*/
public SingleBackoff(@NonNull Supplier<Single<T>> singleSupplier) {
this(singleSupplier, __ -> true);
}
/**
* Creates a new single backoff.
*
* @param singleSupplier the supplier of the single to retry
* @param exceptionFilter the exception filter for the single. If it returns true, the single is retried.
*/
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;
}
/**
* Called when the single fails
*/
private void restart(@NonNull Throwable error) {
Log.e("SingleBackoff", "Error: " + error.getMessage());
error.printStackTrace();
@ -42,6 +77,7 @@ public class SingleBackoff<T> extends Single<T> {
} else {
this.delay *= 2;
Log.i("SingleBackoff", "Retrying in " + this.delay + " seconds");
// Basically we need to delay the end of a Single by the backoff delay in order to start a new one with a delay.
this.disposable = Single.just(0).delay(delay, TimeUnit.SECONDS).flatMap(__ -> singleSupplier.get()).subscribe(this::onSuccess, this::restart);
}
} else {
@ -49,6 +85,10 @@ public class SingleBackoff<T> extends Single<T> {
}
}
/**
* Reports the success of the single
* @param result the result of the single
*/
private void onSuccess(T result) {
for (SingleObserver<? super T> observer : this.observers) {
observer.onSuccess(result);
@ -56,6 +96,10 @@ public class SingleBackoff<T> extends Single<T> {
this.disposable.dispose();
}
/**
* Reports the error of the single
* @param error the error of the single
*/
private void onError(Throwable error) {
for (SingleObserver<? super T> observer : this.observers) {
observer.onError(error);

View file

@ -26,11 +26,25 @@ import rs.chir.compat.java.util.OptionalDouble;
import rs.chir.invtracker.client.model.LocationRounder;
import rs.chir.invtracker.model.GeoLocation;
/**
* {@link Single} implementation that returns a location
*/
public class SingleLocation extends Single<GeoLocation> {
/**
* List of subscribers to this single.
*/
final List<SingleObserver<? super GeoLocation>> observers = new ArrayList<>(1);
/**
* Creates a new Location Single
*
* @param client the location client to use for getting the location
* @param locationRequest the location request to use for getting the location
* @param context the context to use for rounding the location
*/
@SuppressLint("MissingPermission")
private SingleLocation(@NonNull FusedLocationProviderClient client, LocationRequest request, Context context) {
// Request a location update
client.requestLocationUpdates(request, new LocationCallback() {
@Override
public void onLocationResult(LocationResult locationResult) {
@ -48,6 +62,13 @@ public class SingleLocation extends Single<GeoLocation> {
}, Looper.getMainLooper());
}
/**
* Converts an Android location to a GeoLocation
*
* @param location the location to convert
* @param context the context to use for rounding the location
* @return the converted location
*/
@NonNull
static GeoLocation fromAndroidLocation(@NonNull Location location, @NonNull Context context) {
var lat = location.getLatitude();
@ -58,30 +79,48 @@ public class SingleLocation extends Single<GeoLocation> {
}
var time = new Timestamp(location.getTime());
var geoLocation = new GeoLocation(lat, lon, alt, time);
// Round the location according to the application settings
return LocationRounder.fromContext(context).round(geoLocation);
}
/**
* Retrieve the most recent location, requesting a location update if necessary.
* @param client the location client to use for getting the location
* @param context the context to use for rounding the location
* @return the most recent location
*/
@NonNull
@SuppressLint("MissingPermission")
public static Single<GeoLocation> getSingleLocation(@NonNull FusedLocationProviderClient client, @NonNull Context context) {
// Try accessing the last location
return TaskAdapter.fromTask(client.getLastLocation())
.flatMap(location -> {
if (location.isPresent()) {
return Single.just(SingleLocation.fromAndroidLocation(location.get(), context));
} else {
return SingleLocation.getNextLocation(client, context);
// if there is no last location, request a location update
return SingleLocation.getNextLocation(client, context);
}
});
}
/**
* Retrieves a location update, requesting a location update if necessary.
* @param client the location client to use for getting the location
* @param context the context to use for rounding the location
* @return the next location
*/
@NonNull
public static Single<GeoLocation> getNextLocation(@NonNull FusedLocationProviderClient client, @NonNull Context context) {
var request = LocationRequest.create();
// We only need one location update
request.setNumUpdates(1);
request.setInterval(1000);
// It needs to be high accuracy
request.setPriority(Priority.PRIORITY_HIGH_ACCURACY);
var builder = new LocationSettingsRequest.Builder()
.addLocationRequest(request);
// TODO: you could probably cache the settings client?
var settingsClient = LocationServices.getSettingsClient(context);
return TaskAdapter.fromTask(settingsClient.checkLocationSettings(builder.build()))
.flatMap(resp -> new SingleLocation(client, request, context));

View file

@ -12,11 +12,32 @@ import java.util.List;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
/**
* {@link Single} that completes when the user grants the requested permissions and errors otherwise.
*/
public class SinglePermission extends Single<String> {
/**
* The subscribers to this {@link Single}.
*/
private final List<SingleObserver<? super String>> subscribers = new java.util.ArrayList<>(1);
/**
* Permission that is requested.
*/
private final String permission;
/**
* The {@link ActivityResultLauncher} for requesting the permission.
*/
private final ActivityResultLauncher<String> launcher;
/**
* Creates a new {@link SinglePermission} for the given permission.
*
* <br>
* This function needs to be called before the {@link android.app.Activity} or {@link android.app.Fragment} is started.
*
* @param activity An activity or fragment.
* @param permission The permission to request.
*/
public SinglePermission(@NonNull ActivityResultCaller activity, String permission) {
this.launcher = activity.registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (isGranted) {

View file

@ -14,13 +14,25 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import rs.chir.compat.java.util.Optional;
/**
* Single adapter for a GMS {@link Task};
* @param <T> the type of the result of the task.
*/
public class TaskAdapter<T> extends Single<Optional<T>> {
/**
* The subscribers to the task.
*/
private final List<SingleObserver<? super Optional<T>>> observers = new ArrayList<>(1);
/**
* Creates a new adapter.
* @param task the task to wrap.
*/
private TaskAdapter(@NonNull Task<T> task) {
task.addOnCompleteListener(result -> {
Log.i("TaskAdapter", "Task completed: " + result);
if (result.isSuccessful()) {
// rxjava does not like nulls, so we use Optional.empty() instead.
var out = Optional.ofNullable(result.getResult());
for (var observer : observers) {
observer.onSuccess(out);
@ -33,9 +45,17 @@ public class TaskAdapter<T> extends Single<Optional<T>> {
});
}
/**
* Creates a new adapter from a task
*
* @param task the task to wrap.
* @param <T> the type of the result of the task.
* @return the adapter.
*/
@NonNull
public static <T> Single<Optional<T>> fromTask(@NonNull Task<T> task) {
if (task.isComplete()) {
// if the task is successful, we dont need to wrap it in an adapter.
if (task.isSuccessful()) {
return Single.just(Optional.ofNullable(task.getResult()));
} else {

View file

@ -30,6 +30,7 @@
glibc
doxygen
graphviz
texlive.combined.scheme-full
];
# 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";

View file

@ -15,10 +15,19 @@ import rs.chir.utils.MappableList;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
/**
* The model for a Cursor (a page of results from the inventory tracker API).
*
* @param <T> the type of item in the cursor.
*/
public final class Cursor<T extends XMLSerializable> implements XMLSerializable {
/**
* The Deserializer for this cursor.
*/
public static final XMLDeserializable DESERIALIZER = (node) -> {
MappableList<XMLSerializable> items;
try {
// filter out all non-nodes (invalid) and deserialize valid nodes. Due to the way java works we cant ensure type-safety.
items = new IterableNodeList(node.getChildNodes()).filterMap(item -> {
if (item.getNodeType() != Node.ELEMENT_NODE) {
return Optional.empty();
@ -26,6 +35,7 @@ public final class Cursor<T extends XMLSerializable> implements XMLSerializable
try {
return Optional.of(SimpleXML.deserializeGeneric(item));
} catch (IOException e) {
// rethrow as unchecked exception to work around API limitations.
throw new IllegalStateException(e);
}
});
@ -36,6 +46,7 @@ public final class Cursor<T extends XMLSerializable> implements XMLSerializable
throw ex;
}
}
// The next id is an optional attribute of the cursor node.
var attrs = node.getAttributes();
Optional<String> nextId = Optional.empty();
if (attrs.getNamedItem("next") != null) {
@ -43,19 +54,38 @@ public final class Cursor<T extends XMLSerializable> implements XMLSerializable
}
return new Cursor<XMLSerializable>(items, nextId);
};
/**
* The items in the cursor.
*/
private final List<T> items;
/**
* The id of the next page of results.
*/
private final Optional<String> nextId;
/**
* Constructs a new cursor.
* @param items The items in the cursor.
* @param nextId The id of the next page of results.
*/
public Cursor(List<T> items, Optional<String> nextId) {
this.items = items;
this.nextId = nextId;
}
/**
* Gets the items in the cursor.
* @return The items in the cursor.
*/
@NonNull
public List<T> items() {
return this.items;
}
/**
* Gets the id of the next page of results.
* @return The id of the next page of results.
*/
@NonNull
public Optional<String> nextId() {
return this.nextId;
@ -83,6 +113,7 @@ public final class Cursor<T extends XMLSerializable> implements XMLSerializable
@Override
public void serialize(@NonNull Node node) throws IOException {
var document = node.getOwnerDocument();
// Serialize the nextId into the attributes, if present
this.nextId.ifPresent(id -> {
var attrs = node.getAttributes();
var attrNode = document.createAttribute("next");
@ -90,6 +121,7 @@ public final class Cursor<T extends XMLSerializable> implements XMLSerializable
attrs.setNamedItem(attrNode);
});
// Serialize all of the items
for (var item : items) {
SimpleXML.serializeInto(node, item);
}

View file

@ -14,14 +14,20 @@ import rs.chir.compat.java.util.OptionalDouble;
import rs.chir.utils.IterableNodeList;
import rs.chir.utils.xml.XMLSerializable;
// WGS84 geographic coordinates
/**
* Represents a WGS84 geographic coordinate with optional altitude and a timestamp.
*/
public final class GeoLocation implements XMLSerializable {
/**
* The Deserializer for Geographic locations
*/
public static final XMLDeserializable DESERIALIZER = (node) -> {
var latitude = OptionalDouble.empty();
var longitude = OptionalDouble.empty();
var altitude = OptionalDouble.empty();
Optional<Timestamp> locationTime = Optional.empty();
// Iterate over the children of the node to find the needed elements
for (var child : new IterableNodeList(node.getChildNodes())) {
if (child.getNodeType() != Node.ELEMENT_NODE) {
continue;
@ -40,11 +46,30 @@ public final class GeoLocation implements XMLSerializable {
return new GeoLocation(latitude.orElseThrow(), longitude.orElseThrow(), altitude, locationTime.orElseThrow());
};
/**
* The latitude of the location.
*/
private final double latitude;
/**
* The longitude of the location.
*/
private final double longitude;
/**
* The altitude of the location.
*/
private final OptionalDouble altitude;
/**
* The time of the location measurement.
*/
private final Timestamp locationTime;
/**
* Constructs a new {@link GeoLocation}.
* @param latitude the latitude of the location.
* @param longitude the longitude of the location.
* @param altitude the altitude of the location.
* @param locationTime the time of the location measurement.
*/
public GeoLocation(double latitude, double longitude, OptionalDouble altitude, Timestamp locationTime) {
this.latitude = latitude;
this.longitude = longitude;
@ -52,6 +77,11 @@ public final class GeoLocation implements XMLSerializable {
this.locationTime = locationTime;
}
/**
* Formats a double in degrees into a string in degrees, minutes and seconds.
* @param val the value to format.
* @return the formatted value.
*/
private static String formatDegrees(double degrees) {
int degreesInt = (int) degrees;
double minutes = (degrees - degreesInt) * 60;
@ -60,22 +90,42 @@ public final class GeoLocation implements XMLSerializable {
return String.format("%d° %d %.2f″", degreesInt, minutesInt, seconds);
}
/**
* Get the latitude of the location.
* @return the latitude of the location.
*/
public double latitude() {
return latitude;
}
/**
* Get the longitude of the location.
* @return the longitude of the location.
*/
public double longitude() {
return longitude;
}
/**
* Get the altitude of the location.
* @return the altitude of the location.
*/
public OptionalDouble altitude() {
return altitude;
}
/**
* Get the time of the location measurement.
* @return the time of the location measurement.
*/
public Timestamp locationTime() {
return locationTime;
}
/**
* Get the formatted latitude of the location.
* @return the formatted latitude of the location.
*/
@NonNull
public String toReadableString() {
return String.format("%s, %s", GeoLocation.formatDegrees(latitude), GeoLocation.formatDegrees(longitude));

View file

@ -11,25 +11,51 @@ import java.util.Objects;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
/**
* A coordinate rectangle as seen on a map using the Mercator projection.
*/
public final class GeoRect implements XMLSerializable {
/**
* The deserializer for this class
*/
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);
};
/**
* The south-west corner of the rectangle
*/
private final GeoLocation southWest;
/**
* The north-east corner of the rectangle
*/
private final GeoLocation northEast;
/**
* Constructs a new rectangle from the given corners.
* @param southWest The south-west corner of the rectangle.
* @param northEast The north-east corner of the rectangle.
*/
public GeoRect(@NonNull GeoLocation southWest, @NonNull GeoLocation northEast) {
this.southWest = southWest;
this.northEast = northEast;
}
/**
* Returns the south-west corner of the rectangle.
* @return The south-west corner of the rectangle.
*/
@NonNull
public GeoLocation southWest() {
return southWest;
}
/**
* Returns the north-east corner of the rectangle.
* @return The north-east corner of the rectangle.
*/
@NonNull
public GeoLocation northEast() {
return northEast;

View file

@ -10,17 +10,34 @@ import java.util.Objects;
import rs.chir.utils.xml.XMLSerializable;
/**
* A Paseto token.
*/
public final class PasetoToken implements XMLSerializable {
/**
* The deserialzer for this class.
*/
public static final XMLDeserializable DESERIALIZER = (node) -> {
var token = node.getTextContent();
return new PasetoToken(token);
};
/**
* The serializer for this class.
*/
private final String token;
/**
* Creates a new Paseto token.
* @param token the token to use.
*/
public PasetoToken(String token) {
this.token = token;
}
/**
* Gets the token.
* @return the token.
*/
public String token() {
return token;
}

View file

@ -14,22 +14,40 @@ import java.util.Objects;
import rs.chir.compat.java.util.Base64;
import rs.chir.utils.xml.XMLSerializable;
/**
* Represents a public key.
*/
public final class PublicKey implements XMLSerializable {
/**
* The Deserializer for this class.
*/
public static final XMLDeserializable DESERIALIZER = (node) -> {
// While the public key type has a well defined serialization structure, Java does not seem to easily export it, so we have to use java serialization.
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);
throw new IllegalStateException("Class not found", ex); // This should never happen.
}
};
/**
* The native public key.
*/
private final java.security.PublicKey publicKey;
/**
* Constructs a new public key.
* @param publicKey the native public key.
*/
public PublicKey(java.security.PublicKey publicKey) {
this.publicKey = publicKey;
}
/**
* Returns the native public key.
* @return the native public key.
*/
public java.security.PublicKey publicKey() {
return publicKey;
}

View file

@ -14,11 +14,18 @@ import rs.chir.compat.java.util.Optional;
import rs.chir.utils.xml.SimpleXML;
import rs.chir.utils.xml.XMLSerializable;
/**
* The model for a tracked item.
*/
public final class TrackedItem implements XMLSerializable {
/**
* The deserializer for this class.
*/
public static final XMLDeserializable DESERIALIZER = (node) -> {
var attrs = node.getAttributes();
// The id is an attribute, the rest are child elements.
var id = Long.parseUnsignedLong(attrs.getNamedItem("id").getNodeValue());
if (node instanceof Element) {
if (node instanceof Element) { // We have to cast to {@link Element} for DOM APIs like {@link Element#getElementsByTagName}.
var element = (Element) node;
var name = element.getElementsByTagName("name").item(0).getTextContent();
var description = element.getElementsByTagName("description").item(0).getTextContent();
@ -31,6 +38,7 @@ public final class TrackedItem implements XMLSerializable {
try {
var geoElement = element.getElementsByTagName("geo").item(0);
if (geoElement != null) {
// the last location is still serialized, deserialize it
location = Optional.of(SimpleXML.deserialize(geoElement, GeoLocation.class));
}
} catch (IOException e) {
@ -41,12 +49,35 @@ public final class TrackedItem implements XMLSerializable {
throw new IllegalStateException("Expected element, got " + node.getClass());
}
};
/**
* The ID of the item.
*/
private final long id;
/**
* The name of the item.
*/
private final String name;
/**
* The description of the item.
*/
private final String description;
/**
* The picture of the item.
*/
private final Optional<URI> picture;
/**
* The location of the item.
*/
private final Optional<GeoLocation> lastKnownLocation;
/**
* Constructs a new tracked item.
* @param id the ID of the item.
* @param name the name of the item.
* @param description the description of the item.
* @param picture the picture of the item.
* @param lastKnownLocation the location of the item.
*/
public TrackedItem(long id, String name, String description, Optional<URI> picture, Optional<GeoLocation> lastKnownLocation) {
this.id = id;
this.name = name;
@ -55,22 +86,42 @@ public final class TrackedItem implements XMLSerializable {
this.lastKnownLocation = lastKnownLocation;
}
/**
* Gets the ID of the item.
* @return the ID of the item.
*/
public long id() {
return id;
}
/**
* Gets the name of the item.
* @return the name of the item.
*/
public String name() {
return name;
}
/**
* Gets the description of the item.
* @return the description of the item.
*/
public String description() {
return description;
}
/**
* Gets the URI of the picture of the item.
* @return the URI of the picture of the item.
*/
public Optional<URI> picture() {
return picture;
}
/**
* Gets the last known location of the item.
* @return the last known location of the item.
*/
public Optional<GeoLocation> lastKnownLocation() {
return lastKnownLocation;
}

View file

@ -7,19 +7,40 @@ import org.w3c.dom.NodeList;
import java.util.RandomAccess;
/**
* Adapter for {@link NodeList} that is iterable.
*/
public class IterableNodeList extends MappableList<Node> implements RandomAccess {
/**
* The node list
*/
private final NodeList nodeList;
/**
* Creates a new instance of {@link IterableNodeList}
* @param nodeList the node list
*/
public IterableNodeList(@NonNull NodeList nodeList) {
this.nodeList = nodeList;
}
/**
* Retrieves the node at the specified index
* @param index the index
* @return the node, or null if the index is out of bounds
*/
@Override
public Node get(int i) {
if(i < 0 || i >= this.size()) {
return null;
}
return this.nodeList.item(i);
}
/**
* Retrieves the size of the node list
* @return the size
*/
@Override
public int size() {
return this.nodeList.getLength();

View file

@ -3,17 +3,38 @@ package rs.chir.utils;
import java.util.ArrayList;
import java.util.HashMap;
/**
* Simple LRU cache.
*/
public class LRUCache<K, V> {
/**
* The actual cache.
*/
private final HashMap<K, V> cache;
/**
* The list of keys in the order they were last accessed
*/
private final ArrayList<K> recency;
/**
* The maximum size of the cache.
*/
private final int capacity;
/**
* Creates a new instance of {@link LRUCache}.
* @param capacity the maximum size of the cache.
*/
public LRUCache(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>(capacity);
this.recency = new ArrayList<>(capacity);
}
/**
* Gets the value for the given key.
* @param key the key to get the value for.
* @return the value for the given key, or null if the key is not in the cache.
*/
public V get(K key) {
V value = cache.get(key);
if (value != null) {
@ -23,6 +44,11 @@ public class LRUCache<K, V> {
return value;
}
/**
* Puts the given key and value in the cache.
* @param key the key to put in the cache.
* @param value the value to put in the cache.
*/
public void put(K key, V value) {
cache.put(key, value);
recency.remove(key);
@ -32,4 +58,4 @@ public class LRUCache<K, V> {
cache.remove(oldest);
}
}
}
}

View file

@ -5,11 +5,31 @@ import androidx.annotation.NonNull;
import java.io.IOException;
import java.io.InputStream;
/**
* An InputStream that is limited to a certain number of bytes.
*
* This is useful in a server environment to avoid resource exhaustion.
*/
public class LimitedInputStream extends InputStream {
/**
* The underlying input stream.
*/
private final InputStream in;
/**
* The number of bytes that can be read from the stream.
*/
private final long limit;
/**
* The number of bytes that have been read from the stream.
*/
private long index;
/**
* Constructs a new LimitedInputStream.
*
* @param in The underlying input stream.
* @param limit The number of bytes that can be read from the stream.
*/
public LimitedInputStream(@NonNull InputStream in, long limit) {
this.in = in;
this.index = 0;

View file

@ -9,7 +9,16 @@ import java.util.List;
import rs.chir.compat.java.util.Optional;
import rs.chir.compat.java.util.function.Function;
/**
* A list that supports mapping and filter-mapping on old java.
*/
public abstract class MappableList<T> extends AbstractList<T> {
/**
* Maps the list according to the given function.
* @param function the function to use for mapping.
* @param <U> the type of the mapped list.
* @return the mapped list.
*/
@NonNull
public <U> MappableList<U> map(@NonNull Function<T, U> mapper) {
var list = new ArrayList<U>(this.size());
@ -21,6 +30,12 @@ public abstract class MappableList<T> extends AbstractList<T> {
return new WrappedList<>(list);
}
/**
* Filters and maps the list according to the given function.
* @param function the function to use for filtering and mapping.
* @param <U> the type of the filtered and mapped list.
* @return the filtered and mapped list.
*/
@NonNull
public <U> MappableList<U> filterMap(@NonNull Function<T, Optional<U>> mapper) {
var list = new ArrayList<U>(this.size());
@ -32,9 +47,36 @@ public abstract class MappableList<T> extends AbstractList<T> {
return new WrappedList<>(list);
}
/**
* Filters the list according to the given function
* @param filter the function to use for filtering.
* @return the filtered list.
*/
@NonNull
public MappableList<T> filter(@NonNull Function<T, Boolean> mapper) {
return this.filterMap(item -> {
if (mapper.apply(item)) {
return Optional.of(item);
} else {
return Optional.empty();
}
});
}
/**
* A wrapper type that allows a regular list to be mapped.
* @param <U> the type of the list.
*/
private static class WrappedList<U> extends MappableList<U> {
/**
* The wrapped list
*/
private final List<U> list;
/**
* Creates a new wrapper list.
* @param list the list to wrap.
*/
public WrappedList(List<U> list) {
this.list = list;
}
@ -44,6 +86,10 @@ public abstract class MappableList<T> extends AbstractList<T> {
return list.get(index);
}
/**
* Returns the size of the list.
* @return the size of the list.
*/
public int size() {
return list.size();
}

View file

@ -1,26 +1,56 @@
package rs.chir.utils;
/**
* Simple pair class.
*/
public class Pair<T1, T2> {
/**
* The first element.
*/
private T1 first;
/**
* The second element.
*/
private T2 second;
/**
* Creates a new instance of {@link Pair}
* @param first the first element
* @param second the second element
*/
public Pair(T1 first, T2 second) {
this.first = first;
this.second = second;
}
/**
* Gets the first element.
* @return the first element
*/
public T1 getFirst() {
return first;
}
/**
* Sets the first element.
* @param first the first element
*/
public void setFirst(T1 first) {
this.first = first;
}
/**
* Gets the second element.
* @return the second element
*/
public T2 getSecond() {
return second;
}
/**
* Sets the second element.
* @param second the second element
*/
public void setSecond(T2 second) {
this.second = second;
}

View file

@ -1,17 +1,48 @@
package rs.chir.utils;
/**
* Utility class for generating a unique, mostly-monotonically-increasing id for distributed systems.
*/
public enum Snowflake {
;
/**
* A counter for the current thread
*/
private static final ThreadLocal<Short> increment = ThreadLocal.withSupplier(() -> (short) 0);
/**
* Maximum value for the counter
*/
private static final short SNOWFLAKE_COUNTER_MAX = 0xFFF;
/**
* Maximum value for the thread ID
*/
private static final int THREAD_ID_MAX = 0x1F;
/**
* Maximum value for the thread ID
*/
private static final int PROCESS_ID_MAX = 0x1F;
/**
* The epoch in milliseconds
*/
private static final long EPOCH = 1659366782000L;
/**
* The number of bits the timestamp is shifted to the left
*/
private static final int TIMESTAMP_SHIFT = 22;
/**
* The number of bits the process id is shifted to the left
*/
private static final int PID_SHIFT = 17;
/**
* The number of bits the thread id is shifted to the left
*/
private static final int TID_SHIFT = 12;
/**
* Generates a new counter
* @return The new counter
*/
private static short getIncrement() {
var increment = Snowflake.increment.get();
if (increment == SNOWFLAKE_COUNTER_MAX) {
@ -22,14 +53,26 @@ public enum Snowflake {
return increment;
}
/**
* Gets the current thread id
* @return The current thread id
*/
private static char getThreadId() {
return (char) (Thread.currentThread().getId() & THREAD_ID_MAX);
}
/**
* Gets the current process id
* @return The current process id
*/
private static char getProcessId() {
return 0; // TODO
return 0; // TODO: Android and regular Java use incompatible methods of retrieving the process id, maybe use native code to get the process id?
}
/**
* Generates a new snowflake id
* @return The new snowflake id
*/
public static long generate() {
var timestamp = System.currentTimeMillis() - EPOCH;
var increment = Snowflake.getIncrement();

View file

@ -9,24 +9,48 @@ import java.util.concurrent.ConcurrentHashMap;
import rs.chir.compat.java.util.function.Supplier;
/**
* Thread local storage. Exists because Android 5 and 6 dont support native TLS.
*/
public class ThreadLocal<T> {
/**
* The thread local storage.
*/
private final Map<Long, T> map = new ConcurrentHashMap<>(1);
/**
* The supplier for the default values
*/
private final Supplier<T> supplier;
/**
* Creates a new thread local storage.
*
* @param supplier the supplier for the default values.
*/
private ThreadLocal(Supplier<T> supplier) {
this.supplier = supplier;
}
/**
* Creates a new thread local storage.
*/
public ThreadLocal() {
this(() -> null);
}
/**
* Creates a new thread local storage with a supplier
* @param supplier the supplier for the default values.
*/
@NonNull
@Contract("_ -> new")
public static <T> ThreadLocal<T> withSupplier(@NonNull Supplier<T> supplier) {
return new ThreadLocal<>(supplier);
}
/**
* Retrieves the value for the current thread.
*/
public T get() {
var threadId = Thread.currentThread().getId();
var value = map.get(threadId);
@ -37,11 +61,17 @@ public class ThreadLocal<T> {
return value;
}
/**
* Sets the value for the current thread.
*/
public void set(T value) {
var threadId = Thread.currentThread().getId();
map.put(threadId, value);
}
/**
* Clears the value for the current thread.
*/
public void remove() {
var threadId = Thread.currentThread().getId();
map.remove(threadId);

View file

@ -4,9 +4,17 @@ import androidx.annotation.NonNull;
import java.lang.reflect.ParameterizedType;
/**
* Utility class that retrieves the type parameter of a class.
*/
@SuppressWarnings("unchecked")
public interface TypeParameterGetter<T> {
// https://stackoverflow.com/questions/1901164/get-type-of-a-generic-parameter-in-java-with-reflection
// thanks java for erasing generic type parameters
/**
* Returns the class of the type parameter of the parent class.
* @return the class of the type parameter of the parent class
*/
@NonNull
default Class<T> getTypeParameter() {
return (Class<T>) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];

View file

@ -1,26 +1,54 @@
package rs.chir.utils.math;
/**
* A 2D vector.
*/
public class Vec2<T extends Number> {
/**
* X coordinate.
*/
private T x;
/**
* Y coordinate.
*/
private T y;
/**
* Constructor.
* @param x X coordinate.
* @param y Y coordinate.
*/
public Vec2(T x, T y) {
this.x = x;
this.y = y;
}
/**
* @return X coordinate.
*/
public T getX() {
return x;
}
/**
* Sets X coordinate.
* @param x X coordinate.
*/
public void setX(T x) {
this.x = x;
}
/**
* @return Y coordinate.
*/
public T getY() {
return y;
}
/**
* Sets Y coordinate.
* @param y Y coordinate.
*/
public void setY(T y) {
this.y = y;
}

View file

@ -23,27 +23,49 @@ import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
/**
* Utility class for easier XML manipulation.
*/
public enum SimpleXML {
;
/**
* A default {@link DocumentBuilderFactory} to use for creating {@link Document}s.
*/
private static final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
/**
* A default {@link TransformerFactory} to use for creating {@link Transformer}s.
*/
private static final TransformerFactory transformerFactory = TransformerFactory.newInstance();
/**
* Creates a new {@link Document}
* @return A new {@link Document}
*/
@NonNull
public static Document createDocument() {
return getBuilder().newDocument();
}
/**
* Serializes an {@link XMLSerializable} into a child element of a {@link Node}.
* @param node the node to serialize into
* @param serializable the serializable to serialize
* @throws IOException if an error occurs
*/
public static void serializeInto(@NonNull Node node, @NonNull XMLSerializable serializable) throws IOException {
var rootElementName = serializable.rootElementName();
Document doc;
// Get the owner document of the node. If the node is a document, {@link Node#getOwnerDocument()} unhelpfully returns null, so simply cast it to a {@link Document}.
if (node instanceof Document) {
doc = (Document) node;
} else {
doc = node.getOwnerDocument();
}
// Create a node to serialize into.
var root = doc.createElement(rootElementName);
// Create a type attr containing the canonical class name
var typeAttr = doc.createAttribute("type");
typeAttr.setValue(serializable.getClass().getCanonicalName());
root.getAttributes().setNamedItem(typeAttr);
@ -52,6 +74,12 @@ public enum SimpleXML {
serializable.serialize(root);
}
/**
* Serialize an {@link XMLSerializable}
* @param serializable the serializable to serialize
* @return the serialized XML
* @throws IOException if an error occurs
*/
@NonNull
public static Document serialize(@NonNull XMLSerializable serializable) throws IOException {
var doc = getBuilder().newDocument();
@ -59,64 +87,108 @@ public enum SimpleXML {
return doc;
}
/**
* Parses a {@link Document} from an {@link InputStream}.
* @param inputStream the input stream to parse from
* @return the parsed {@link Document}
* @throws IOException if an IO error occurs
* @throws SAXException if an XML Parsing error occurs
*/
@NonNull
public static Document parseDocument(@NonNull InputStream is) throws SAXException, IOException {
return getBuilder().parse(is);
}
/**
* Deserializes a type from a {@link Node}.
* @param node the node to deserialize from
* @param expectedClass the expected class of the type to deserialize
* @param <T> the type to deserialize
* @return the deserialized object
* @throws IOException if an error occurs
*/
@NonNull
public static <T extends XMLSerializable> T deserialize(@NonNull Node node, @NonNull Class<T> expectedClass) throws IOException {
// Get the type attribute
var typeAttr = node.getAttributes().getNamedItem("type");
if (typeAttr == null) {
throw new IllegalStateException("missing type attribute");
}
var type = typeAttr.getNodeValue();
// Try to find the class by name
Class<?> clazz;
try {
clazz = Class.forName(type);
} catch (ClassNotFoundException e) {
throw new IllegalStateException("Could not find class for type: " + type, e);
}
// Check if the class is a subclass of the expected class
if (!expectedClass.isAssignableFrom(clazz)) {
throw new IllegalStateException("Expected class " + expectedClass.getCanonicalName() + " but found " + clazz.getCanonicalName());
}
// Find the deserializer field
Field deserializerField;
try {
deserializerField = clazz.getField("DESERIALIZER");
} catch (NoSuchFieldException e) {
throw new IllegalStateException("Deserialized object uses invalid type: " + type, e);
}
// Try to obtain the deserializer
Object deserializerObj;
try {
deserializerObj = deserializerField.get(null);
} catch (IllegalAccessException e) {
throw new IllegalStateException("Deserialized object uses invalid type: " + type, e);
}
// Check if the deserializer is a deserializer
if (deserializerObj instanceof XMLSerializable.XMLDeserializable) {
// Yes, try to deserialize the node
var deserializer = (XMLSerializable.XMLDeserializable) deserializerObj;
var result = deserializer.deserialize(node);
if (!expectedClass.isInstance(result)) {
throw new IllegalStateException("Deserialized object is not an instance of " + expectedClass.getCanonicalName());
}
// We just checked that the result is an instance of the expected class, so cast it to the expected type and return it
//noinspection unchecked
return (T) result;
}
throw new IllegalStateException("Deserialized object uses invalid type: " + type);
}
/**
* Deserializes a type from a {@link Document}, without type checking.
* @param doc the document to deserialize from
* @throws IOException if an error occurs
*/
@NonNull
public static XMLSerializable deserializeGeneric(@NonNull Node node) throws IOException {
return SimpleXML.deserialize(node, XMLSerializable.class);
}
/**
* Writes an XML Document {@link Source} to a DOM {@link Result}.
* @param source the source to write
* @param result the result to write to
* @throws TransformerException if the transformation fails
*/
public static void writeTo(@NonNull Source source, @NonNull Result result) throws TransformerException {
getTransformer().transform(source, result);
}
/**
* Writes an XML {@link Document} to an {@link OutputStream}.
* @param document the document to write
* @param os the output stream to write to
* @throws TransformerException if the transformation fails
*/
public static void writeTo(@NonNull Document document, @NonNull OutputStream os) throws TransformerException {
SimpleXML.writeTo(new DOMSource(document), new StreamResult(os));
}
/**
* Creates a DocumentBuilder
* @return the DocumentBuilder
*/
private static DocumentBuilder getBuilder() {
try {
return factory.newDocumentBuilder();
@ -125,6 +197,10 @@ public enum SimpleXML {
}
}
/**
* Creates a Transformer
* @return the Transformer
*/
private static Transformer getTransformer() {
try {
return transformerFactory.newTransformer();
@ -132,4 +208,4 @@ public enum SimpleXML {
throw new IllegalStateException(e);
}
}
}
}

View file

@ -6,14 +6,36 @@ import org.w3c.dom.Node;
import java.io.IOException;
/**
* An interface implemented by all serializable classes.
*/
public interface XMLSerializable {
/**
* Returns the node name of the serializable class.
* @return The node name of the serializable class.
*/
@NonNull
String rootElementName();
/**
* Serializes the object to a node.
* @param node The node to serialize to.
*/
void serialize(@NonNull Node node) throws IOException;
/**
* Node Deserializer.
*
* All {@link XMLSerializable} classes must have a deserializer that is a public static constant named <code>DESERIALIZER</code>
* and that is a {@link XMLDeserializer} instance.
*/
@FunctionalInterface
interface XMLDeserializable {
/**
* Deserializes the object from a node.
* @param node The node to deserialize from.
* @return The deserialized object.
*/
@NonNull
Object deserialize(@NonNull Node node) throws IOException;
}

View file

@ -3,9 +3,25 @@ package rs.chir.auth;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* The password verifier interface
*/
public interface AuthPasswordVerifier {
/**
* Verifies if a password is valid for the given user.
*
* @param username The user.
* @param password The password.
* @return True if the password is valid. False in all other cases, including on error.
*/
boolean verify(@NonNull String username, @NonNull String password);
/**
* Returns the user ID for the given username.
*
* @param username The username.
* @return The user ID, or null if the username is not found.
*/
@Nullable
Object getUserId(@NonNull String username);
}

View file

@ -2,7 +2,16 @@ package rs.chir.auth;
import androidx.annotation.NonNull;
/**
* The password verifier interface
*/
@FunctionalInterface
public interface PasswordVerifier {
/**
* Verifies the password
* @param password The password
* @param expectedHash The expected hash of the password
* @return True if the password is correct, false otherwise
*/
boolean verify(@NonNull String password, @NonNull String expectedHash);
}

View file

@ -5,12 +5,30 @@ import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
/**
* A Bearer token issuer.
*/
public interface TokenIssuer {
/**
* Issues a new bearer token for the given user with no additional claims.
* <br>
* This function must only be called after authenticating the user.
* @param user the user to issue a token for
* @return a new bearer token
*/
@NonNull
default String issue(@NonNull String subject) {
return this.issue(subject, new HashMap<>(0));
}
/**
* Issues a new bearer token for the given user with the given additional claims.
* <br>
* This function must only be called after authenticating the user.
* @param user the user to issue a token for
* @param claims the additional claims to include in the token
* @return a new bearer token
*/
@NonNull
String issue(@NonNull String subject, @NonNull Map<String, Object> claims);
}

View file

@ -5,7 +5,15 @@ import androidx.annotation.NonNull;
import java.util.Map;
import java.util.Optional;
/**
* The verifier for a bearer token.
*/
public interface TokenVerifier {
/**
* Verifies the token.
* @param token The token.
* @return If the verification succeeds, the claims in the token. Otherwise, it returns an empty optional.
*/
@NonNull
Optional<Map<String, Object>> verify(@NonNull String token);
}
}

View file

@ -4,10 +4,19 @@ import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import rs.chir.auth.PasswordVerifier;
/**
* Argon2 password verifier.
*/
public class Argon2Verifier implements PasswordVerifier {
/**
* The Argon2 encoder used for verification.
*/
private final Argon2PasswordEncoder encoder;
/**
* Creates a new instance of {@link Argon2Verifier}.
*/
public Argon2Verifier() {
this.encoder = new Argon2PasswordEncoder();
}

View file

@ -17,13 +17,27 @@ import rs.chir.db.DBConnection;
import rs.chir.db.MigrationManager;
import rs.chir.db.SimpleMigrator;
/**
* A token ID storage that uses a database to store the token IDs.
*/
public class SQLTokenStorage implements Set<String> {
/**
* The database connection.
*/
private final DBConnection conn;
/**
* Creates a new token storage.
* @param conn the database connection.
*/
public SQLTokenStorage(@NonNull DBConnection conn) {
this.conn = conn;
}
/**
* Retreves the migration manager for the token storage.
* @return the migration manager.
*/
@NonNull
@Contract(value = " -> new", pure = true)
public static MigrationManager getMigratonManager() {
@ -38,6 +52,7 @@ public class SQLTokenStorage implements Set<String> {
AtomicInteger result = new AtomicInteger(-1);
try {
conn.runStatement(statement -> {
// Count the number of rows in the table.
try (var rs = statement.executeQuery("SELECT COUNT(*) FROM session_ids")) {
rs.next();
result.set(rs.getInt(1));
@ -59,6 +74,7 @@ public class SQLTokenStorage implements Set<String> {
public boolean contains(Object o) {
AtomicBoolean result = new AtomicBoolean(false);
try {
// counting is probably faster than requesting the ID
conn.runPreparedStatement("SELECT count(*) FROM session_ids WHERE session_id = ?", statement -> {
statement.setString(1, (String) o);
try (var rs = statement.executeQuery()) {
@ -76,25 +92,26 @@ public class SQLTokenStorage implements Set<String> {
@NonNull
@Override
public Iterator<String> iterator() {
return null; // TODO
throw new UnsupportedOperationException("Not implemented");
}
@NonNull
@Override
public Object[] toArray() {
return new Object[0]; // TODO
throw new UnsupportedOperationException("Not implemented");
}
@NonNull
@Override
public <T> T[] toArray(@NonNull T[] ts) {
return null; // TODO
throw new UnsupportedOperationException("Not implemented");
}
@Override
public boolean add(String s) {
if (!this.contains(s)) {
try {
// Insert the token.
conn.runPreparedStatement("INSERT INTO session_ids (session_id) VALUES (?)", statement -> {
statement.setString(1, s);
statement.executeUpdate();
@ -112,6 +129,7 @@ public class SQLTokenStorage implements Set<String> {
public boolean remove(Object o) {
if (this.contains(o)) {
try {
// Delete the token.
conn.runPreparedStatement("DELETE FROM session_ids WHERE session_id = ?", statement -> {
statement.setString(1, (String) o);
return CommitBehavior.COMMIT;
@ -145,7 +163,8 @@ public class SQLTokenStorage implements Set<String> {
@Override
public boolean retainAll(@NonNull Collection<?> collection) {
return false; // TODO
throw new UnsupportedOperationException("Not implemented");
}
@Override
@ -161,6 +180,7 @@ public class SQLTokenStorage implements Set<String> {
public void clear() {
try {
conn.runStatement(statement -> {
// Delete all tokens.
statement.executeUpdate("DELETE FROM session_ids");
return CommitBehavior.COMMIT;
});

View file

@ -17,16 +17,37 @@ import rs.chir.db.DBConnection;
import rs.chir.db.MigrationManager;
import rs.chir.db.SimpleMigrator;
/**
* User credentials database.
*/
public class UserCredentialDB implements AuthPasswordVerifier {
/**
* The logger.
*/
private static final Logger LOG = Logger.getLogger(UserCredentialDB.class.getName());
/**
* The password verifier.
*/
private final PasswordVerifier passwordVerifier;
/**
* The database connection.
*/
private final DBConnection conn;
/**
* Creates a new instance of {@link UserCredentialDB}.
* @param conn the database connection.
* @param passwordVerifier the password verifier.
*/
public UserCredentialDB(@NonNull DBConnection conn, @NonNull PasswordVerifier passwordVerifier) {
this.passwordVerifier = passwordVerifier;
this.conn = conn;
}
/**
* Returns a migration manager for the user credentials database.
* @return the migration manager.
*/
@NonNull
@Contract(value = " -> new", pure = true)
public static MigrationManager getMigratonManager() {

View file

@ -24,14 +24,42 @@ import rs.chir.auth.TokenIssuer;
import rs.chir.utils.Pair;
import rs.chir.utils.Snowflake;
/**
* Issuer of Paseto tokens.
*/
public class PasetoIssuer implements TokenIssuer {
/**
* Logger
*/
private static final Logger LOG = Logger.getLogger(PasetoIssuer.class.getName());
/**
* How long the token is valid for
*/
private static final long TOKEN_LIFETIME_IN_SECONDS = 60 * 60 * 24 * 30; // 30 days
/**
* The private key to use for signing
*/
private final PrivateKey privateKey;
/**
* The name of the issuer
*/
private final String issuer;
/**
* The audience of the token
*/
private final String audience;
/**
* The token ID validity table
*/
private final Set<String> tokenIdVerifier;
/**
* Constructor
* @param privateKey The private key to use for signing
* @param issuer The name of the issuer
* @param audience The audience of the token
* @param tokenIdVerifier The token ID validity table
*/
private PasetoIssuer(PrivateKey key, String issuer, String audience, Set<String> tokenIdVerifier) {
this.privateKey = key;
this.issuer = issuer;
@ -39,17 +67,25 @@ public class PasetoIssuer implements TokenIssuer {
this.tokenIdVerifier = tokenIdVerifier;
}
/**
* Creates a PasetoIssue/PasetoVerify pair, reading the keypair from disk if it exists.
* @param audience The audience of the token
* @param tokenIdVerifier The token ID validity table
* @return The pair
*/
@NonNull
public static Pair<PasetoIssuer, PasetoVerifier> create(@NonNull String audience, @NonNull Set<String> tokenIdVerifier) {
KeyPair keyPair;
try {
var file = new File("key");
// Read the keypair from disk
if (file.exists()) {
try (var fis = new FileInputStream(file);
var ois = new ObjectInputStream(fis)) {
keyPair = (KeyPair) ois.readObject();
}
} else {
// It doesnt exist, so create a new keypair
keyPair = Keys.keyPairFor(Version.V2);
try (var fos = new FileOutputStream(file);
var oos = new ObjectOutputStream(fos)) {
@ -72,6 +108,13 @@ public class PasetoIssuer implements TokenIssuer {
return PasetoIssuer.create(keyPair, audience, tokenIdVerifier);
}
/**
* Creates a PasetoIssue/PasetoVerify pair.
* @param kp The keypair to use
* @param audience The audience of the token
* @param tokenIdVerifier The token ID validity table
* @return The pair
*/
@NonNull
public static Pair<PasetoIssuer, PasetoVerifier> create(@NonNull KeyPair kp, @NonNull String audience, @NonNull Set<String> tokenIdVerifier) {
var issuer = new PasetoIssuer(kp.getPrivate(), audience, audience, tokenIdVerifier);

View file

@ -13,11 +13,29 @@ import dev.paseto.jpaseto.PasetoParser;
import dev.paseto.jpaseto.Pasetos;
import rs.chir.auth.TokenVerifier;
/**
* Verifies a paseto token.
*/
public class PasetoVerifier implements TokenVerifier {
/**
* The public key to use for verifying the token.
*/
private final PublicKey publicKey;
/**
* The parser to use for parsing the token.
*/
private final PasetoParser parser;
/**
* The token id table
*/
private final Set<String> tokenIdVerifier;
/**
* Constructs a new {@link PasetoVerifier}.
* @param publicKey the public key to use for verifying the token.
* @param audience the audience to use for verifying the token.
* @param tokenIdVerifier the token id table.
*/
public PasetoVerifier(@NonNull PublicKey publicKey, @NonNull String audience, @NonNull Set<String> tokenIdVerifier) {
this.publicKey = publicKey;
this.parser = Pasetos.parserBuilder()
@ -41,6 +59,10 @@ public class PasetoVerifier implements TokenVerifier {
}
}
/**
* Gets the public key used for verifying the token.
* @return the public key used for verifying the token.
*/
@NonNull
public PublicKey getPublicKey() {
return this.publicKey;

View file

@ -1,7 +1,20 @@
package rs.chir.db;
/**
* The commit behavior.
*/
public enum CommitBehavior {
/**
* Do not commit.
* This is useful for read-only transactions, as well as compound transactions that have not completed yet.
*/
NONE,
/**
* Commit the transaction.
*/
COMMIT,
/**
* Rollback the transaction. This is done automatically when the query lambda throws an exception.
*/
ROLLBACK
}

View file

@ -7,49 +7,91 @@ import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.logging.Logger;
/**
* Database connection class.
*/
public class DBConnection {
/**
* The logger to use.
*/
private static final Logger LOG = Logger.getLogger(DBConnection.class.getName());
/**
* The database connection.
*/
private final Connection connection;
/**
* Constructs a new {@link DBConnection}.
* @param connection the database connection.
* @throws SQLException if connection settings cannot be set.
*/
private DBConnection(Connection connection) throws SQLException {
this.connection = connection;
this.connection.setAutoCommit(false);
}
/**
* Connects to the database.
* @param connectionString the connection string.
* @param migrator the migration manager to use.
* @return a {@link DBConnection} that is connected to the database.
* @throws SQLException if the connection cannot be established.
*/
@NonNull
public static DBConnection connect(@NonNull String url, @NonNull MigrationManager migrator) throws SQLException {
// I think you are no longer supposed to use DriverManager, but it is used here for ease of use.
var connection = DriverManager.getConnection(url);
var dbConnection = new DBConnection(connection);
// migrate the database
(new DBMigrator(dbConnection, migrator)).migrate();
return dbConnection;
}
/**
* Creates and runs a statement
* @param statementCreator the statement creator to use.
* @throws SQLException if the statement cannot be executed
*/
public final void runStatement(@NonNull StatementCreator statementCreator) throws SQLException {
try (var statement = this.connection.createStatement()) {
switch (statementCreator.generateStatement(statement)) {
// commit or rollback if needed
case COMMIT -> this.connection.commit();
case ROLLBACK -> this.connection.rollback();
}
} catch (SQLException ex) {
} catch (Exception ex) {
// on exception, also rollback
this.connection.rollback();
throw ex;
}
}
@SuppressWarnings("JDBCPrepareStatementWithNonConstantString")
/**
* Prepares and runs a statement.
* @param sql The template SQL.
* @param statementCreator the statement creator to use.
* @throws SQLException if the statement cannot be executed.
*/
@SuppressWarnings("JDBCPrepareStatementWithNonConstantString") // False positive
public final void runPreparedStatement(@NonNull String sql, @NonNull PreparedStatementCreator statementCreator) throws SQLException {
try (var preparedStatement = this.connection.prepareStatement(sql)) {
switch (statementCreator.prepareStatement(preparedStatement)) {
// commit or rollback if needed
case COMMIT -> this.connection.commit();
case ROLLBACK -> this.connection.rollback();
}
} catch (SQLException ex) {
// on exception, also rollback
this.connection.rollback();
throw ex;
}
}
/**
* Returns the underlying JDBC connection.
* @return the underlying JDBC connection.
*/
@NonNull
public final Connection getConnection() {
return this.connection;

View file

@ -5,17 +5,38 @@ import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
/**
* Helper class for migrating the database from one version to another.
*/
class DBMigrator {
/**
* The logger to use.
*/
private static final Logger LOG = Logger.getLogger(DBMigrator.class.getName());
/**
* The database to migrate.
*/
private final DBConnection connection;
/**
* The migration manager to use.
*/
private final MigrationManager migrator;
/**
* Constructs a new {@link DBMigrator}.
* @param connection the database to migrate.
* @param migrator the migration manager to use.
*/
DBMigrator(DBConnection connection, MigrationManager migrator) {
this.connection = connection;
this.migrator = migrator;
}
/**
* Ensures that the migration table has been created.
* @throws SQLException if an error occurs.
*/
private void ensureMigrationTableExists() throws SQLException {
this.connection.runStatement(statement -> {
statement.executeUpdate("CREATE TABLE IF NOT EXISTS migrations (version TEXT PRIMARY KEY)");
@ -23,6 +44,11 @@ class DBMigrator {
});
}
/**
* Retrieves the set of migrations that have already been run.
* @return the set of migrations that have already been run.
* @throws SQLException if an error occurs.
*/
private Set<String> getAppliedMigrations() throws SQLException {
var conn = this.connection.getConnection();
try (var statement = conn.createStatement()) {
@ -37,15 +63,22 @@ class DBMigrator {
}
}
/**
* Applies all migrations that haven't been run yet.
* @throws SQLException if an error occurs.
*/
final void migrate() throws SQLException {
LOG.info("Migrating database...");
// Ensure the migration table exists.
this.ensureMigrationTableExists();
// Get currently applied migrations
var neededMigrations = this.migrator.getMigrations();
var appliedMigrations = this.getAppliedMigrations();
for (var migration : neededMigrations) {
// Skip already applied migrations
if (!appliedMigrations.contains(migration.migrationId())) {
LOG.info("Applying migration" + migration.migrationId());
migration.apply(this.connection);

View file

@ -6,15 +6,32 @@ import java.io.IOException;
import java.io.InputStream;
import java.sql.SQLException;
/**
* Represents a single migration.
*/
public interface Migration {
/**
* Returns a unique ID for the migration.
* @return The ID.
*/
@NonNull
String migrationId();
/**
* Returns the SQL statements to execute as an input stream.
* @return The SQL inputstream
*/
@NonNull
InputStream asInputStream();
/**
* Applies the migration to the database.
* @param conn The database to apply the migration to.
* @throws SQLException If an error occurs.
*/
default void apply(@NonNull DBConnection conn) throws SQLException {
conn.runStatement((statement) -> {
// Executes the actual migration statements
try (var is = this.asInputStream()) {
String sql = new String(is.readAllBytes());
//read from a constant source
@ -23,8 +40,9 @@ public interface Migration {
} catch (IOException ex) {
throw new SQLException("Could not read migration file " + this.migrationId(), ex);
}
return CommitBehavior.NONE;
return CommitBehavior.NONE; // Dont commit yet
});
// Update the migration application status to indicate that the migration has been applied
conn.runPreparedStatement("INSERT INTO migrations (version) VALUES (?)", (preparedStatement) -> {
preparedStatement.setString(1, this.migrationId());
preparedStatement.executeUpdate();

View file

@ -4,8 +4,17 @@ import androidx.annotation.NonNull;
import java.util.List;
/**
* The migration manager returns a list of migrations that need to be applied to the database.
*/
@FunctionalInterface
public interface MigrationManager {
/**
* Returns a list of migrations that need to be applied to the database.
* The ordering of the migrations is important.
*
* @return a list of migrations that need to be applied to the database.
*/
@NonNull
List<Migration> getMigrations();
}

View file

@ -6,9 +6,19 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Merges multiple {@link MigrationManager}s into a single one.
*/
public class MigrationManagerJoiner implements MigrationManager {
/**
* The list of migration managers to join.
*/
private final List<MigrationManager> managers;
/**
* Constructs a new {@link MigrationManagerJoiner}.
* @param managers the list of migration managers to join.
*/
public MigrationManagerJoiner(@NonNull List<MigrationManager> managers) {
this.managers = Collections.unmodifiableList(managers);
}

View file

@ -5,8 +5,16 @@ import androidx.annotation.NonNull;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
* prepares and executes a prepared statement
*/
@FunctionalInterface
public interface PreparedStatementCreator {
/**
* Prepares and executes a prepared statement
* @param statement the prepared statement
* @throws SQLException if an error occurs
*/
@NonNull
CommitBehavior prepareStatement(@NonNull PreparedStatement statement) throws SQLException;
}

View file

@ -4,10 +4,24 @@ import androidx.annotation.NonNull;
import java.io.InputStream;
/**
* Class implementing a simple migrator for the database.
*/
public class SimpleMigrator implements Migration {
/**
* The input stream to read the SQL statements from.
*/
private final InputStream is;
/**
* The migration ID
*/
private final String migrationId;
/**
* Constructs a new {@link SimpleMigrator}.
* @param is the input stream to read the SQL statements from.
* @param migrationId the migration ID.
*/
public SimpleMigrator(InputStream is, String migrationId) {
this.is = is;
this.migrationId = migrationId;

View file

@ -5,8 +5,16 @@ import androidx.annotation.NonNull;
import java.sql.SQLException;
import java.sql.Statement;
/**
* The statement creator interface.
*/
@FunctionalInterface
public interface StatementCreator {
/**
* Creates and executes a statement.
* @param statement the statement
* @throws SQLException if an error occurs
*/
@NonNull
CommitBehavior generateStatement(@NonNull Statement statement) throws SQLException;
}

View file

@ -22,15 +22,35 @@ import rs.chir.httpserver.request.RequestMethod;
import rs.chir.httpserver.request.Route;
import rs.chir.httpserver.response.ResponseCode;
/**
* HTTP Server.
*/
public class HTTPServer {
/**
* Logger.
*/
private static final Logger LOG = Logger.getLogger(HTTPServer.class.getName());
/**
* List of request handlers.
*/
final List<RequestHandlerInfo> requestHandlers = new ArrayList<>(10);
/**
* The server.
*/
private final HttpServer server;
/**
* Creates a new server.
* @param addr the address to bind to.
* @throws IOException if the server could not be created.
*/
public HTTPServer(@NonNull InetSocketAddress addr) throws IOException {
this.server = HttpServer.create(addr, 0);
var claims = new AtomicReference<Map<String, Object>>(null);
// The claims for the request is thread local to share it between the authenticator and the request handler.
ThreadLocal<Map<String, Object>> claims = ThreadLocal.withInitial(() -> null);
// Due to the way javas httpserver handles routes, we need to handle everything ourselves. / represents all paths.
this.server.createContext("/", httpExchange -> {
// Parse the query parameters if there are any.
var queryParams = httpExchange.getRequestURI().getQuery();
RequestMeta requestMeta;
if (queryParams != null) {
@ -40,14 +60,17 @@ public class HTTPServer {
}
try {
LOG.info("Received request: " + httpExchange.getRequestURI());
// Try to match a handler
for (var handlerInfo : this.requestHandlers) {
var matcher = handlerInfo.pattern().matcher(httpExchange.getRequestURI().getPath());
if (matcher.matches() && handlerInfo.method() == RequestMethod.fromString(httpExchange.getRequestMethod())) {
// We found a match, call the handler.
handlerInfo.handler().handle(requestMeta, matcher, httpExchange);
httpExchange.getResponseBody().close();
httpExchange.getResponseBody().close(); // close the response body
return;
}
}
// Nothing was found
LOG.warning("No handler found for request: " + httpExchange.getRequestURI());
httpExchange.sendResponseHeaders(ResponseCode.NOT_FOUND, 0);
httpExchange.getResponseBody().write("Not found".getBytes());
@ -62,22 +85,32 @@ public class HTTPServer {
}).setAuthenticator(new Authenticator() {
@Override
public Result authenticate(HttpExchange httpExchange) {
// Find the matching handler
for (var handlerInfo : requestHandlers) {
var matcher = handlerInfo.pattern().matcher(httpExchange.getRequestURI().getPath());
if (matcher.matches() && handlerInfo.method() == RequestMethod.fromString(httpExchange.getRequestMethod())) {
// Found a match, run the authenticator.
var result = handlerInfo.handler().authenticate(matcher, httpExchange);
claims.set(result.getSecond());
return result.getFirst();
}
}
// if there is no handler, we return success just so that we can show 404
return new Success(null);
}
});
}
/**
* Converts a query string to a map.
* @param query the query string.
* @return the map.
*/
@NonNull
private static Map<String, String> queryParams(@NonNull String queryString) {
var params = new java.util.HashMap<String, String>(1);
// Did you know that URI query strings dont have a defined structure?
// The consensus is this and certain sites like bing will break your application for tracking nonsense if you dont follow it.
var params = new java.util.HashMap<String, String>();
for (var param : queryString.split("&")) {
var pair = param.split("=");
params.put(URLDecoder.decode(pair[0], StandardCharsets.UTF_8), URLDecoder.decode(pair[1], StandardCharsets.UTF_8));
@ -85,17 +118,33 @@ public class HTTPServer {
return params;
}
/**
* Adds a new request handler.
* @param pattern the pattern to match.
* @param method the method to match.
* @param handler the handler to call.
*/
public void addHandler(@NonNull Pattern pattern, @NonNull RequestMethod method, @NonNull RequestHandler handler) {
this.requestHandlers.add(new RequestHandlerInfo(pattern, method, handler));
}
/**
* Adds a new request handler for the GET method.
* @param pattern the pattern to match.
* @param handler the handler to call.
*/
public void addHandler(@NonNull Pattern pattern, @NonNull RequestHandler handler) {
this.addHandler(pattern, RequestMethod.GET, handler);
}
/**
* Adds a new request handler with the pattern and method taken from the {@link Route} annotation.
* @param handler the handler to call.
*/
public void addHandler(@NonNull RequestHandler handler) {
// Find all of the annotations on the handler.
for (var annotation : handler.getClass().getAnnotations()) {
if (annotation instanceof Route route) {
if (annotation instanceof Route route) { // We found the route annotation
this.addHandler(Pattern.compile(route.value()), route.method(), handler);
return;
}
@ -103,11 +152,18 @@ public class HTTPServer {
throw new IllegalArgumentException("Handler does not have Route annotation, add one or use addHandler(Pattern, RequestMethod, RequestHandler)");
}
/**
* Sets the executor to use for the server.
* @param executor the executor to use.
*/
public void setExecutor(@NonNull Executor executor) {
this.server.setExecutor(executor);
}
/**
* Starts the server.
*/
public final void start() {
this.server.start();
}
}
}

View file

@ -13,11 +13,29 @@ import java.util.regex.Matcher;
import rs.chir.httpserver.response.ResponseCode;
import rs.chir.utils.Pair;
/**
* Request Handler for the server.
*/
@FunctionalInterface
public interface RequestHandler {
/**
* Handles a request.
*
* @param meta Request metadata such as token claims and query parameters.
* @param matchResult The result of the regex match.
* @param exchange The exchange object.
* @throws IOException If an I/O error occurs.
*/
void handle(@NonNull RequestMeta meta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException;
/**
* Authenticates a request
*
* @param matchResult The result of the regex match.
* @param exchange The exchange object.
* @return A pair of the authenticator result and the token claims.
*/
@NonNull
default Pair<Authenticator.Result, Map<String, Object>> authenticate(@NonNull Matcher matchResult, @NonNull HttpExchange exchange) {
final Logger LOG = Logger.getLogger(RequestHandler.class.getName());

View file

@ -4,5 +4,8 @@ import java.util.regex.Pattern;
import rs.chir.httpserver.request.RequestMethod;
/**
* A record containing information about a request handler.
*/
record RequestHandlerInfo(Pattern pattern, RequestMethod method, RequestHandler handler) {
}

View file

@ -2,6 +2,9 @@ package rs.chir.httpserver;
import java.util.Map;
/**
* A record containing meta information about a request.
*/
public record RequestMeta(
Map<String, String> queryParam,
Map<String, Object> claims

View file

@ -16,9 +16,19 @@ import rs.chir.httpserver.RequestHandler;
import rs.chir.httpserver.response.ResponseCode;
import rs.chir.utils.Pair;
/**
* Authentication verifier that verifies the user by basic auth.
*/
public abstract class BasicAuthVerifier implements RequestHandler {
/**
* The verifier
*/
private final AuthPasswordVerifier passwordVerifier;
/**
* Constructs a new basic auth verifier.
* @param passwordVerifier the password verifier
*/
protected BasicAuthVerifier(@NonNull AuthPasswordVerifier passwordVerifier) {
this.passwordVerifier = passwordVerifier;
}
@ -30,18 +40,19 @@ public abstract class BasicAuthVerifier implements RequestHandler {
if (header == null) {
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
if (!header.startsWith("Basic ")) {
// Basic authentication header is in the form of "Basic <base64 encoded username:password>"
if (!header.startsWith("Basic ")) { // Not basic auth
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
var token = header.substring("Basic ".length());
var decoded = new String(Base64.getDecoder().decode(token));
var split = decoded.split(":", 2);
if (split.length != 2) {
if (split.length != 2) { // Malformed
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
var username = split[0];
var password = split[1];
if (!this.passwordVerifier.verify(username, password)) {
if (!this.passwordVerifier.verify(username, password)) { // Wrong password
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
return new Pair<>(new Authenticator.Success(new HttpPrincipal(username, "")), Map.of("uid", Objects.requireNonNull(this.passwordVerifier.getUserId(username))));

View file

@ -14,9 +14,19 @@ import rs.chir.httpserver.RequestHandler;
import rs.chir.httpserver.response.ResponseCode;
import rs.chir.utils.Pair;
/**
* Authenticator that verifies bearer tokens.
*/
public abstract class BearerTokenVerifier implements RequestHandler {
/**
* The token verifier.
*/
private final TokenVerifier tokenVerifier;
/**
* Constructs a new bearer token verifier.
* @param tokenVerifier the token verifier
*/
protected BearerTokenVerifier(@NonNull TokenVerifier tokenVerifier) {
this.tokenVerifier = tokenVerifier;
}
@ -28,28 +38,29 @@ public abstract class BearerTokenVerifier implements RequestHandler {
if (header == null) {
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
if (!header.startsWith("Bearer ")) {
// The header is in the form "Bearer <token>"
if (!header.startsWith("Bearer ")) { // malformed
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
var token = header.substring("Bearer ".length());
var tokenMetaOpt = this.tokenVerifier.verify(token);
if (tokenMetaOpt.isEmpty()) {
if (tokenMetaOpt.isEmpty()) { // empty token
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
var tokenMeta = tokenMetaOpt.get();
var user = tokenMeta.get("sub");
if (user == null) {
if (user == null) { // no user in token, should not happen
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
if (!(user instanceof String)) {
if (!(user instanceof String)) { // user is not a string, should not happen
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
var realm = tokenMeta.get("iss");
if (realm == null) {
if (realm == null) { // no issuer in token, should not happen
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
if (!(realm instanceof String)) {
if (!(realm instanceof String)) { // issuer is not a string, should not happen
return new Pair<>(new Authenticator.Failure(ResponseCode.UNAUTHORIZED), Map.of());
}
return new Pair<>(new Authenticator.Success(new HttpPrincipal((String) user, (String) realm)), tokenMeta);

View file

@ -11,6 +11,9 @@ import java.util.regex.Matcher;
import rs.chir.httpserver.RequestHandler;
import rs.chir.utils.Pair;
/**
* Authenticator that does not perform any authentication.
*/
public abstract class NoAuth implements RequestHandler {
@NonNull
@Override

View file

@ -2,13 +2,36 @@ package rs.chir.httpserver.request;
import androidx.annotation.NonNull;
/**
* The request method.
*/
public enum RequestMethod {
/**
* The GET request method.
*/
GET,
/**
* The POST request method.
*/
POST,
/**
* The PUT request method.
*/
PUT,
/**
* The DELETE request method.
*/
DELETE,
/**
* The PATCH request method.
*/
PATCH;
/**
* Parses the request method from a string.
* @param method the string to parse
* @return the request method
*/
@NonNull
public static RequestMethod fromString(@NonNull String method) {
return switch (method) {

View file

@ -5,10 +5,21 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Route annotation.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.TYPE_USE})
public @interface Route {
/**
* The regex pattern to match the path against.
* @return the regex pattern to match the path against.
*/
String value();
/**
* The method to use for matching the path.
* @return the method to use for matching the path.
*/
RequestMethod method() default RequestMethod.GET;
}

View file

@ -1,12 +1,36 @@
package rs.chir.httpserver.response;
/**
* The response code.
*/
public enum ResponseCode {
;
/**
* The response code for when a resource was not found.
*/
public static final int NOT_FOUND = 404;
/**
* The response code for a successful request.
*/
public static final int OK = 200;
/**
* The response code for an unauthorized request.
*/
public static final int UNAUTHORIZED = 401;
/**
* The response code for a bad request.
*/
public static final int BAD_REQUEST = 400;
/**
* The response code for when a resource was created.
*/
public static final int CREATED = 201;
/**
* The response code for an internal server error.
*/
public static final int INTERNAL_SERVER_ERROR = 500;
/**
* The response code for when there is no content.
*/
public static final int NO_CONTENT = 204;
}

View file

@ -15,22 +15,44 @@ import java.util.logging.Logger;
import rs.chir.utils.xml.SimpleXML;
/**
* Configuration file.
*/
class ConfigFile {
private static final Logger LOG = Logger.getLogger(ConfigFile.class.getName());
/**
* The bind address.
*/
private final InetSocketAddress bindAddress;
/**
* The database URL
*/
private final String dbUrl;
/**
* Constructor.
* @param fileName The name of the configuration file.
*/
ConfigFile(@NonNull String fileName) throws IOException, SAXException {
this(new FileInputStream(fileName));
}
/**
* Constructor.
* @param inputStream The input stream to read the configuration file from.
*/
private ConfigFile(@NonNull InputStream is) throws IOException, SAXException {
this(SimpleXML.parseDocument(is));
}
/**
* Constructor.
* @param doc The document to read the configuration file from.
*/
private ConfigFile(@NonNull Document doc) {
// Could probably benefit from the serializer here.
var root = doc.getDocumentElement();
if (!"config".equals(root.getTagName())) {
throw new IllegalArgumentException("Missing root element <config>");
@ -75,10 +97,18 @@ class ConfigFile {
this.dbUrl = url.getNodeValue();
}
/**
* Returns the bind address.
* @return the bind address.
*/
public final InetSocketAddress getBindAddress() {
return this.bindAddress;
}
/**
* Returns the database URL.
* @return the database URL.
*/
public final String getDbUrl() {
return this.dbUrl;
}

View file

@ -23,8 +23,6 @@ import rs.chir.db.MigrationManagerJoiner;
import rs.chir.httpserver.HTTPServer;
import rs.chir.invtracker.model.PublicKey;
import rs.chir.invtracker.server.db.MigrationManager;
import rs.chir.invtracker.server.routes.CountObjectsInRectRoute;
import rs.chir.invtracker.server.routes.CountObjectsRoute;
import rs.chir.invtracker.server.routes.CreateObjectRoute;
import rs.chir.invtracker.server.routes.DeleteObjectRoute;
import rs.chir.invtracker.server.routes.GeoRectRoute;
@ -39,11 +37,24 @@ import rs.chir.invtracker.server.routes.UpdateLocationRoute;
import rs.chir.invtracker.server.routes.UpdateObjectRoute;
public final class Server implements Runnable {
/**
* The logger for this class.
*/
private static final Logger LOG = Logger.getLogger(Server.class.getName());
/**
* The configuration for the server.
*/
private final ConfigFile config;
/**
* The executor for the server.
*/
private final Executor executor;
/**
* Creates a new server instance.
* @param configFileName the name of the configuration file
*/
private Server(@NonNull String configFileName) {
try {
this.config = new ConfigFile(configFileName);
@ -68,10 +79,16 @@ public final class Server implements Runnable {
new Thread(new Server(configFileName)).start();
}
/**
* Starts the server.
*/
@Override
public void run() {
// We need this for interoperability with android
Security.insertProviderAt(new BouncyCastleProvider(), 1);
Security.setProperty("crypto.policy", "unlimited");
// Connect to the database
DBConnection dbConnection = null;
try {
@ -87,8 +104,10 @@ public final class Server implements Runnable {
return;
}
// Create the paseto issuer/verifier
var paseto = PasetoIssuer.create("invtracker.chir.rs", new SQLTokenStorage(dbConnection));
// Create the server
HTTPServer server;
try {
server = new HTTPServer(this.config.getBindAddress());
@ -98,6 +117,7 @@ public final class Server implements Runnable {
}
server.setExecutor(this.executor);
// Set up all of the routes
server.addHandler(new GetPublicKey(new PublicKey(paseto.getSecond().getPublicKey())));
server.addHandler(new Login(paseto.getFirst(), new UserCredentialDB(dbConnection, new Argon2Verifier())));
server.addHandler(new GetMediaRoute("media", paseto.getSecond()));
@ -111,8 +131,8 @@ public final class Server implements Runnable {
server.addHandler(new GeoRectRoute(dbConnection, paseto.getSecond()));
server.addHandler(new UpdateLocationRoute(dbConnection, paseto.getSecond()));
// Start the server
server.start();
LOG.info("Server started.");
}
}
}

View file

@ -7,6 +7,9 @@ import java.util.List;
import rs.chir.db.Migration;
import rs.chir.db.SimpleMigrator;
/**
* The migration manager for invtracker
*/
public class MigrationManager implements rs.chir.db.MigrationManager {
@NonNull
@Override

View file

@ -32,17 +32,45 @@ import rs.chir.invtracker.model.TrackedItem;
import rs.chir.utils.LRUCache;
import rs.chir.utils.Snowflake;
/**
* The tracked item table
*/
public class TrackedItemTable implements Map<Long, TrackedItem> {
/**
* The page size to use for API calls.
*/
private static final int PAGE_SIZE = 50;
/**
* A cache of tracked item tables given a user ID.
*/
private static final LRUCache<Long, TrackedItemTable> cache = new LRUCache<>(100);
/**
* The connection to use for accessing the database.
*/
private final DBConnection dbConnection;
/**
* The user ID to use for accessing the database.
*/
private final long userId;
/**
* Creates a new tracked item table connection.
*
* @param dbConnection the connection to use for accessing the database.
* @param userId the user ID to use for accessing the database.
*/
private TrackedItemTable(@NonNull DBConnection dbConnection, long userId) {
this.dbConnection = dbConnection;
this.userId = userId;
}
/**
* Retrieves a tracked item table connection.
*
* @param dbConnection the connection to use for accessing the database.
* @param userId the user ID to use for accessing the database.
* @return the tracked item table connection.
*/
@NonNull
public static TrackedItemTable getInstance(@NonNull DBConnection dbConnection, long userId) {
var cached = cache.get(userId);
@ -55,9 +83,15 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
return result;
}
/**
* Converts a result set to a tracked item.
* @param resultSet the result set to convert.
* @return the tracked item.
*/
@NonNull
@Contract("_ -> new")
private static TrackedItem fromResultSet(@NonNull ResultSet rs) throws SQLException {
// Extract the data for the tracked item
var itemId = rs.getLong("id");
var name = rs.getString("name");
var description = rs.getString("description");
@ -69,6 +103,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
return Optional.empty();
}
});
// And the data for the most recent location
var latitude = OptionalDouble.of(rs.getDouble("latitude"));
if (rs.wasNull()) {
latitude = OptionalDouble.empty();
@ -144,6 +179,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
if ((o instanceof Long id)) {
AtomicReference<TrackedItem> result = new AtomicReference<>(null);
try {
// Select the needed columns for the tracked item and the most recent location
dbConnection.runPreparedStatement("""
SELECT
inventory_item.id,
@ -185,6 +221,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
public TrackedItem put(Long aLong, @NonNull TrackedItem trackedItem) {
var oldTrackedItem = this.get(aLong);
try {
// Insert the tracked item
dbConnection.runPreparedStatement("""
INSERT
INTO inventory_item
@ -205,6 +242,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
return CommitBehavior.NONE;
return CommitBehavior.COMMIT;
});
// If there is a location, insert it too
if (trackedItem.lastKnownLocation().isPresent()) {
var location = trackedItem.lastKnownLocation().get();
dbConnection.runPreparedStatement("""
@ -251,6 +289,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
statement.executeUpdate();
return CommitBehavior.COMMIT;
});
// The positions are cascade-deleted automatically
} catch (SQLException e) {
throw new IllegalStateException("Could not remove tracked item", e);
}
@ -325,6 +364,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
public Cursor<TrackedItem> getCursor(long startId, int limit) {
var result = new ArrayList<TrackedItem>(limit);
try {
// To get more than 1 item, we need a subquery that only returns the location with the highest id for the location
dbConnection.runPreparedStatement("""
SELECT
inventory_item.id,
@ -375,47 +415,11 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
return this.getGeoRectCursor(startId, PAGE_SIZE, southwest, northeast);
}
public int countInArea(@NonNull GeoLocation southwest, @NonNull GeoLocation northeast) {
var result = new AtomicInteger(0);
try {
dbConnection.runPreparedStatement("""
SELECT
COUNT(*)
FROM inventory_item
LEFT JOIN
location_log ON location_log.inventory_item_id = inventory_item.id
AND location_log.id = (
SELECT MAX(id)
FROM location_log
WHERE inventory_item_id = inventory_item.id
LIMIT 1
)
WHERE
location_log.latitude BETWEEN ? AND ?
AND location_log.longitude BETWEEN ? AND ?
""",
statement -> {
statement.setDouble(1, southwest.latitude());
statement.setDouble(2, northeast.latitude());
statement.setDouble(3, southwest.longitude());
statement.setDouble(4, northeast.longitude());
try (var rs = statement.executeQuery()) {
while (rs.next()) {
result.set(rs.getInt(1));
}
}
return CommitBehavior.NONE;
});
} catch (SQLException e) {
throw new IllegalStateException("Could not get tracked item values", e);
}
return result.get();
}
@NonNull
public Cursor<TrackedItem> getGeoRectCursor(long startId, int limit, @NonNull GeoLocation southwest, @NonNull GeoLocation northeast) {
var result = new ArrayList<TrackedItem>(limit);
try {
// Same as above, but we check the bounding box of the location
dbConnection.runPreparedStatement("""
SELECT
inventory_item.id,
@ -439,7 +443,7 @@ public class TrackedItemTable implements Map<Long, TrackedItem> {
inventory_item.id > ?
AND location_log.latitude BETWEEN ? AND ?
AND location_log.longitude BETWEEN ? AND ?
LIMIT ?
LIMIT ?
""",
statement -> {
statement.setLong(1, startId);

View file

@ -1,47 +0,0 @@
package rs.chir.invtracker.server.routes;
import androidx.annotation.NonNull;
import com.sun.net.httpserver.HttpExchange;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import java.io.IOException;
import java.util.regex.Matcher;
import rs.chir.auth.TokenVerifier;
import rs.chir.db.DBConnection;
import rs.chir.httpserver.RequestMeta;
import rs.chir.httpserver.auth.BearerTokenVerifier;
import rs.chir.httpserver.request.RequestMethod;
import rs.chir.httpserver.request.Route;
import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.server.db.TrackedItemTable;
import rs.chir.utils.xml.SimpleXML;
@Route(value = "^/count/geo-rect", method = RequestMethod.POST)
public class CountObjectsInRectRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
public CountObjectsInRectRoute(@NonNull DBConnection dbConnection, @NonNull TokenVerifier tokenVerifier) {
super(tokenVerifier);
this.dbConnection = dbConnection;
}
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
Document requestDocument;
try {
requestDocument = SimpleXML.parseDocument(exchange.getRequestBody());
} catch (SAXException e) {
throw new IOException(e);
}
var rect = SimpleXML.deserialize(requestDocument.getDocumentElement(), GeoRect.class);
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
exchange.sendResponseHeaders(ResponseCode.OK, 0);
exchange.getResponseBody().write(Integer.toString(trackedItemTable.countInArea(rect.southWest(), rect.northEast())).getBytes());
}
}

View file

@ -1,33 +0,0 @@
package rs.chir.invtracker.server.routes;
import androidx.annotation.NonNull;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.regex.Matcher;
import rs.chir.auth.TokenVerifier;
import rs.chir.db.DBConnection;
import rs.chir.httpserver.RequestMeta;
import rs.chir.httpserver.auth.BearerTokenVerifier;
import rs.chir.httpserver.request.Route;
import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.server.db.TrackedItemTable;
@Route("^/count/objects$")
public class CountObjectsRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
public CountObjectsRoute(@NonNull DBConnection dbConnection, @NonNull TokenVerifier tokenVerifier) {
super(tokenVerifier);
this.dbConnection = dbConnection;
}
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
exchange.sendResponseHeaders(ResponseCode.OK, 0);
exchange.getResponseBody().write(Integer.toString(trackedItemTable.size()).getBytes());
}
}

View file

@ -24,10 +24,21 @@ import rs.chir.invtracker.server.db.TrackedItemTable;
import rs.chir.utils.Snowflake;
import rs.chir.utils.xml.SimpleXML;
/**
* The route that creates a new item.
*/
@Route(value = "^/objects$", method = RequestMethod.POST)
public class CreateObjectRoute extends BearerTokenVerifier {
/**
* The database connection
*/
private final DBConnection dbConnection;
/**
* Constructor
* @param dbConnection The database connection
* @param tokenVerifier The token verifier
*/
public CreateObjectRoute(@NonNull DBConnection dbConnection, @NonNull TokenVerifier tokenVerifier) {
super(tokenVerifier);
this.dbConnection = dbConnection;
@ -35,6 +46,7 @@ public class CreateObjectRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// Parse the request body
Document requestDocument;
try {
requestDocument = SimpleXML.parseDocument(exchange.getRequestBody());
@ -42,13 +54,17 @@ public class CreateObjectRoute extends BearerTokenVerifier {
throw new IOException(e);
}
TrackedItem trackedItem = SimpleXML.deserialize(requestDocument.getDocumentElement(), TrackedItem.class);
// The ID in the request is replaced with a new one
trackedItem = new TrackedItem(Snowflake.generate(), trackedItem.name(), trackedItem.description(), trackedItem.picture(), trackedItem.lastKnownLocation());
// Store the item in the database
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
trackedItemTable.put(trackedItem.id(), trackedItem);
exchange.getResponseHeaders().set("Content-Type", "text/xml");
// Link to the new item
exchange.getResponseHeaders().set("Location", "/objects/" + trackedItem.id());
exchange.sendResponseHeaders(ResponseCode.CREATED, 0);
// And return the item as XML
var body = SimpleXML.serialize(trackedItem);
try {
SimpleXML.writeTo(body, exchange.getResponseBody());

View file

@ -16,6 +16,9 @@ import rs.chir.httpserver.request.Route;
import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.server.db.TrackedItemTable;
/**
* Route that deletes an item.
*/
@Route(value = "^/objects/(\\d+)$", method = RequestMethod.DELETE)
public class DeleteObjectRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -28,8 +31,10 @@ public class DeleteObjectRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
// remove the item from the database
var trackedItem = trackedItemTable.remove(Long.parseLong(matchResult.group(1)));
// if the item was not found, return 404
if (trackedItem == null) {
exchange.sendResponseHeaders(ResponseCode.NOT_FOUND, 0);
return;

View file

@ -23,6 +23,9 @@ import rs.chir.invtracker.model.GeoRect;
import rs.chir.invtracker.server.db.TrackedItemTable;
import rs.chir.utils.xml.SimpleXML;
/**
* Route that returns a list of items that are in a given geo rect.
*/
@Route(value = "^/geo-rect$", method = RequestMethod.POST)
public class GeoRectRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -34,6 +37,7 @@ public class GeoRectRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// The request body is a geo rect in XML format.
Document requestDocument;
try {
requestDocument = SimpleXML.parseDocument(exchange.getRequestBody());
@ -42,13 +46,17 @@ public class GeoRectRoute extends BearerTokenVerifier {
}
var rect = SimpleXML.deserialize(requestDocument.getDocumentElement(), GeoRect.class);
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
// The ID to start from is in the query
var queryParams = requestMeta.queryParam();
long startAt = 0;
if (queryParams.containsKey("start")) {
startAt = Long.parseLong(queryParams.get("start"));
}
// Query the database for the items in the rect.
var trackedItems = trackedItemTable.getGeoRectCursor(startAt, rect);
// Build the response.
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.sendResponseHeaders(ResponseCode.OK, 0);
var body = SimpleXML.serialize(trackedItems);

View file

@ -14,7 +14,10 @@ import rs.chir.httpserver.auth.BearerTokenVerifier;
import rs.chir.httpserver.request.Route;
import rs.chir.httpserver.response.ResponseCode;
@Route("^/img/(\\d+).(jpg|png)$")
/**
* Route for serving media files.
*/
@Route("^/img/(\\d+).(jpg|png|webp)$")
public class GetMediaRoute extends BearerTokenVerifier {
private final String mediaPath;
@ -25,11 +28,14 @@ public class GetMediaRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// Get the item id and the file extension
var file_id = matchResult.group(1);
var ext = matchResult.group(2);
var file_path = this.mediaPath + "/" + file_id + "." + ext;
// Generate the response headers
exchange.getResponseHeaders().set("Content-Type", "image/" + ext);
// try reading the file
var file = new java.io.File(file_path);
if (!file.exists()) {
exchange.sendResponseHeaders(ResponseCode.NOT_FOUND, 0);

View file

@ -18,6 +18,9 @@ import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.server.db.TrackedItemTable;
import rs.chir.utils.xml.SimpleXML;
/**
* Route that returns the item with the given id.
*/
@Route("^/objects/(\\d+)$")
public class GetObjectRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -32,11 +35,13 @@ public class GetObjectRoute extends BearerTokenVerifier {
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
var trackedItem = trackedItemTable.get(Long.parseLong(matchResult.group(1)));
// check if item exists
if (trackedItem == null) {
exchange.sendResponseHeaders(ResponseCode.NOT_FOUND, 0);
return;
}
// encode the item as XML
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.sendResponseHeaders(ResponseCode.OK, 0);
var body = SimpleXML.serialize(trackedItem);

View file

@ -16,6 +16,9 @@ import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.model.PublicKey;
import rs.chir.utils.xml.SimpleXML;
/**
* Route that returns public key.
*/
@Route("^/public-key$")
public class GetPublicKey extends NoAuth {
private final PublicKey pubkey;

View file

@ -19,6 +19,9 @@ import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.server.db.LocationLogTable;
import rs.chir.utils.xml.SimpleXML;
/**
* Route for listing locations.
*/
@Route(value = "^/objects/(\\d+)/locations$", method = RequestMethod.GET)
public class ListLocationRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -30,14 +33,17 @@ public class ListLocationRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// The cursor location is passed as a query parameter.
var queryParams = requestMeta.queryParam();
long startAt = 0;
if (queryParams.containsKey("start")) {
startAt = Long.parseLong(queryParams.get("start"));
}
var locationLogTable = LocationLogTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"), Long.parseLong(matchResult.group(1)));
// Get the locations.
var locations = locationLogTable.getCursor(startAt);
// Create the XML response.
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.sendResponseHeaders(ResponseCode.OK, 0);
var body = SimpleXML.serialize(locations);

View file

@ -19,6 +19,9 @@ import rs.chir.httpserver.response.ResponseCode;
import rs.chir.invtracker.server.db.TrackedItemTable;
import rs.chir.utils.xml.SimpleXML;
/**
* Route that lists all items.
*/
@Route(value = "^/objects$", method = RequestMethod.GET)
public class ListObjectRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -30,6 +33,7 @@ public class ListObjectRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// The cursor location is in the query string.
var queryParams = requestMeta.queryParam();
long startAt = 0;
if (queryParams.containsKey("start")) {
@ -38,6 +42,7 @@ public class ListObjectRoute extends BearerTokenVerifier {
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
var trackedItems = trackedItemTable.getCursor(startAt);
// Build the response.
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.sendResponseHeaders(ResponseCode.OK, 0);
var body = SimpleXML.serialize(trackedItems);

View file

@ -31,6 +31,10 @@ public class Login extends BasicAuthVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// The basic auth verifier will have already checked the credentials
// and set the user in the request meta.
//
// Issue a paseto token and return it to the user.
var token = this.issuer.issue(exchange.getPrincipal().getUsername(), Map.of("uid", requestMeta.claims().get("uid")));
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.sendResponseHeaders(ResponseCode.OK, 0);

View file

@ -17,6 +17,9 @@ import rs.chir.httpserver.response.ResponseCode;
import rs.chir.utils.LimitedInputStream;
import rs.chir.utils.Snowflake;
/**
* Route that handles image uploads.
*/
@Route(value = "^/upload$", method = RequestMethod.POST)
public class PutImage extends BearerTokenVerifier {
private static final int MAX_FILE_SIZE = 1024 * 1024 * 10; // 10 MiB
@ -30,9 +33,11 @@ public class PutImage extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
String ext;
// Figure out the file extension
switch (exchange.getRequestHeaders().getFirst("Content-Type")) {
case "image/jpeg" -> ext = "jpg";
case "image/png" -> ext = "png";
case "image/webp" -> ext = "webp";
default -> {
exchange.sendResponseHeaders(ResponseCode.BAD_REQUEST, 0);
return;
@ -40,8 +45,10 @@ public class PutImage extends BearerTokenVerifier {
}
var id = Snowflake.generate();
// Generate a file name
var file_path = this.mediaPath + "/" + id + "." + ext;
var file = new java.io.File(file_path);
// Limit the file size to 10MiB
Files.copy(new LimitedInputStream(exchange.getRequestBody(), MAX_FILE_SIZE), file.toPath());
exchange.getResponseHeaders().set("Location", "/img/" + id + "." + ext);

View file

@ -22,6 +22,9 @@ import rs.chir.invtracker.server.db.LocationLogTable;
import rs.chir.utils.Snowflake;
import rs.chir.utils.xml.SimpleXML;
/**
* Updates the location of an item
*/
@Route(value = "^/objects/(\\d+)/locations$", method = RequestMethod.POST)
public class UpdateLocationRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -33,6 +36,7 @@ public class UpdateLocationRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// The location is in the body of the request
Document requestDocument;
try {
requestDocument = SimpleXML.parseDocument(exchange.getRequestBody());
@ -40,11 +44,14 @@ public class UpdateLocationRoute extends BearerTokenVerifier {
throw new IOException(e);
}
GeoLocation trackedItem = SimpleXML.deserialize(requestDocument.getDocumentElement(), GeoLocation.class);
var objectId = Long.parseLong(matchResult.group(1));
var locationLogTable = LocationLogTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"), objectId);
var id = Snowflake.generate();
// update the location
locationLogTable.put(id, trackedItem);
// return the id of the location
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.getResponseHeaders().set("Location", "/objects/" + objectId + "/locations/" + id);
exchange.sendResponseHeaders(ResponseCode.OK, -1);

View file

@ -23,6 +23,9 @@ import rs.chir.invtracker.model.TrackedItem;
import rs.chir.invtracker.server.db.TrackedItemTable;
import rs.chir.utils.xml.SimpleXML;
/**
* Updates an item.
*/
@Route(value = "^/objects/(\\d+)$", method = RequestMethod.PATCH)
public class UpdateObjectRoute extends BearerTokenVerifier {
private final DBConnection dbConnection;
@ -34,6 +37,7 @@ public class UpdateObjectRoute extends BearerTokenVerifier {
@Override
public void handle(@NonNull RequestMeta requestMeta, @NonNull Matcher matchResult, @NonNull HttpExchange exchange) throws IOException {
// The updated item is sent as a XML document.
Document requestDocument;
try {
requestDocument = SimpleXML.parseDocument(exchange.getRequestBody());
@ -41,10 +45,13 @@ public class UpdateObjectRoute extends BearerTokenVerifier {
throw new IOException(e);
}
TrackedItem trackedItem = SimpleXML.deserialize(requestDocument.getDocumentElement(), TrackedItem.class);
// Make sure that the id of the item is the same as the id of the item in the URL.
trackedItem = new TrackedItem(Long.parseLong(matchResult.group(1)), trackedItem.name(), trackedItem.description(), trackedItem.picture(), trackedItem.lastKnownLocation());
var trackedItemTable = TrackedItemTable.getInstance(this.dbConnection, (int) requestMeta.claims().get("uid"));
// Update the item in the database.
trackedItemTable.put(trackedItem.id(), trackedItem);
// return the updated item.
exchange.getResponseHeaders().set("Content-Type", "text/xml");
exchange.sendResponseHeaders(ResponseCode.OK, 0);
var body = SimpleXML.serialize(trackedItem);