diff --git a/app/assets/cities.sqlite b/app/assets/cities.sqlite new file mode 100644 index 00000000..1e249ed2 Binary files /dev/null and b/app/assets/cities.sqlite differ diff --git a/app/lib/mobile/db_util.dart b/app/lib/mobile/db_util.dart index 773c6d27..e881f4b8 100644 --- a/app/lib/mobile/db_util.dart +++ b/app/lib/mobile/db_util.dart @@ -2,9 +2,11 @@ import 'dart:io' as dart; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:path/path.dart' as path_lib; import 'package:path_provider/path_provider.dart'; -import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart' as sqlite3; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart' as sql; Future> getSqliteConnectionArgs() async { // put the database file, called db.sqlite here, into the documents folder @@ -32,5 +34,27 @@ QueryExecutor openSqliteConnection() { } Future applyWorkaroundToOpenSqlite3OnOldAndroidVersions() { - return sqlite3.applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + return sql.applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); +} + +Future openRawSqliteDbFromAsset( + String assetRelativePath, + String outputFilename, { + bool isReadOnly = false, +}) async { + final dbFolder = await getApplicationDocumentsDirectory(); + final file = dart.File(path_lib.join(dbFolder.path, outputFilename)); + if (!await file.exists()) { + // copy file from assets + final blob = await rootBundle.load("assets/$assetRelativePath"); + final buffer = blob.buffer; + await file.writeAsBytes( + buffer.asUint8List(blob.offsetInBytes, blob.lengthInBytes), + flush: true, + ); + } + return sqlite3.open( + file.path, + mode: isReadOnly ? OpenMode.readOnly : OpenMode.readWriteCreate, + ); } diff --git a/app/lib/reverse_geocoder.dart b/app/lib/reverse_geocoder.dart new file mode 100644 index 00000000..75ef1a8c --- /dev/null +++ b/app/lib/reverse_geocoder.dart @@ -0,0 +1,172 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:kdtree/kdtree.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/math_util.dart' as math_util; +import 'package:nc_photos/mobile/platform.dart' + if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; +import 'package:sqlite3/common.dart'; + +class ReverseGeocoderLocation { + const ReverseGeocoderLocation(this.name, this.latitude, this.longitude, + this.countryCode, this.admin1, this.admin2); + + @override + toString() => "$runtimeType {" + "name: $name, " + "latitude: $latitude, " + "longitude: $longitude, " + "countryCode: $countryCode, " + "admin1: $admin1, " + "admin2: $admin2, " + "}"; + + final String name; + final double latitude; + final double longitude; + final String countryCode; + final String? admin1; + final String? admin2; +} + +class ReverseGeocoder { + Future init() async { + final s = Stopwatch()..start(); + _db = await _openDatabase(); + _searchTree = _buildSearchTree(_db); + _log.info("[init] Elapsed time: ${s.elapsedMilliseconds}ms"); + } + + /// Convert a geographic coordinate (in degree) into a location + Future call( + double latitude, double longitude) async { + _log.info( + "[call] latitude: ${latitude.toStringAsFixed(3)}, longitude: ${longitude.toStringAsFixed(3)}"); + final latitudeInt = (latitude * 10000).round(); + final longitudeInt = (longitude * 10000).round(); + final nearest = _searchTree + .nearest({"t": latitudeInt, "g": longitudeInt}, 1).firstOrNull; + if (nearest == null) { + _log.info("[call] Nearest point not found"); + return null; + } + final nearestLat = nearest[0]["t"]; + final nearestLatF = nearestLat / 10000; + final nearestLng = nearest[0]["g"]; + final nearestLngF = nearestLng / 10000; + _log.info("[call] Nearest point, (lat: $nearestLatF, lng: $nearestLngF)"); + try { + final distance = _distanceInKm( + math_util.degToRad(latitude), + math_util.degToRad(longitude), + math_util.degToRad(nearestLatF), + math_util.degToRad(nearestLngF), + ); + _log.info( + "[call] (lat: ${latitude.toStringAsFixed(3)}, lng: ${longitude.toStringAsFixed(3)}) <-> (lat: $nearestLatF, lng: $nearestLngF) = ${distance.toStringAsFixed(3)}km"); + // a completely arbitrary threshold :) + if (distance > 10) { + _log.info("[call] Nearest point is too far away"); + return null; + } + } catch (e, stackTrace) { + _log.severe("[call] Uncaught exception", e, stackTrace); + } + + final data = _queryPoint(nearestLat, nearestLng); + if (data == null) { + _log.severe( + "[call] Row not found for point: latitude: $nearestLat, longitude: $nearestLng"); + return null; + } + final result = ReverseGeocoderLocation(data.name, data.latitude / 10000, + data.longitude / 10000, data.countryCode, data.admin1, data.admin2); + _log.info("[call] Found: $result"); + return result; + } + + _DatabaseRow? _queryPoint(int latitudeInt, int longitudeInt) { + final result = _db.select( + "SELECT * FROM cities WHERE latitude = ? AND longitude = ? LIMIT 1;", + [latitudeInt, longitudeInt], + ); + if (result.isEmpty) { + return null; + } else { + return _DatabaseRow( + result.first.columnAt(1), + result.first.columnAt(2), + result.first.columnAt(3), + result.first.columnAt(4), + result.first.columnAt(5), + result.first.columnAt(6), + ); + } + } + + late final CommonDatabase _db; + late final KDTree _searchTree; + + static final _log = Logger("reverse_geocoder.ReverseGeocoder"); +} + +extension ReverseGeocoderExtension on ReverseGeocoderLocation { + ImageLocation toImageLocation() { + return ImageLocation( + name: name, + latitude: latitude, + longitude: longitude, + countryCode: countryCode, + admin1: admin1, + admin2: admin2, + ); + } +} + +class _DatabaseRow { + const _DatabaseRow(this.name, this.latitude, this.longitude, this.countryCode, + this.admin1, this.admin2); + + final String name; + final int latitude; + final int longitude; + final String countryCode; + final String? admin1; + final String? admin2; +} + +Future _openDatabase() async { + return platform.openRawSqliteDbFromAsset("cities.sqlite", "cities.sqlite"); +} + +KDTree _buildSearchTree(CommonDatabase db) { + final results = db.select("SELECT latitude, longitude FROM cities;"); + return KDTree( + results.map((e) => {"t": e.columnAt(0), "g": e.columnAt(1)}).toList(), + _kdTreeDistance, + ["t", "g"], + ); +} + +int _kdTreeDistance(Map a, Map b) { + return (math.pow((a["t"] as int) - (b["t"] as int), 2) + + math.pow((a["g"] as int) - (b["g"] as int), 2)) as int; +} + +/// Calculate the distance in KM between two point +/// +/// Both latitude and longitude are expected to be in radian +double _distanceInKm( + double latitude1, double longitude1, double latitude2, double longitude2) { + final dLat = latitude2 - latitude1; + final dLon = longitude2 - longitude1; + final a = math.pow(math.sin(dLat / 2), 2) + + math.cos(latitude1) * + math.cos(latitude2) * + math.pow(math.sin(dLon / 2), 2); + final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + // 6371 = earth radius + return 6371 * c; +} diff --git a/app/pubspec.lock b/app/pubspec.lock index b159c420..a20da55b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -679,6 +679,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.6.0" + kdtree: + dependency: "direct main" + description: + name: kdtree + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" kiwi: dependency: "direct main" description: @@ -1161,7 +1168,7 @@ packages: source: hosted version: "2.2.1+1" sqlite3: - dependency: transitive + dependency: "direct main" description: name: sqlite3 url: "https://pub.dartlang.org" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4b113043..576ffa11 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -76,6 +76,7 @@ dependencies: ref: 1.0.0-nc-photos-2 path: library intl: ^0.17.0 + kdtree: ^0.2.0 kiwi: ^4.0.1 logging: ^1.0.1 memory_info: ^0.0.2 @@ -90,6 +91,7 @@ dependencies: quiver: ^3.1.0 screen_brightness: ^0.2.1 shared_preferences: ^2.0.8 + sqlite3: any sqlite3_flutter_libs: ^0.5.8 synchronized: ^3.0.0 tuple: ^2.0.0