Add comments
This commit is contained in:
parent
f3534a8431
commit
cb905eec81
85 changed files with 1650 additions and 194 deletions
14
Doxyfile
14
Doxyfile
|
@ -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
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
# Modules
|
||||
|
||||
- `app`: The android app
|
||||
- `auth`: Authentication code
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,14 +113,16 @@ 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();
|
||||
|
|
|
@ -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 don’t 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 don’t 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);
|
||||
|
|
|
@ -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 isn’t 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 doesn’t 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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 don’t 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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
// 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));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 don’t need to wrap it in an adapter.
|
||||
if (task.isSuccessful()) {
|
||||
return Single.just(Optional.ofNullable(task.getResult()));
|
||||
} else {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 can’t 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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 don’t 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);
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 doesn’t 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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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; // Don’t 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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 java’s 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 don’t have a defined structure?
|
||||
// The consensus is this and certain sites like bing will break your application for tracking nonsense if you don’t 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,10 +152,17 @@ 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();
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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) {
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.");
|
||||
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue