Fix background service, add support for NBT instead of Json

This commit is contained in:
zontreck 2025-05-25 12:56:07 -07:00
parent a686412ec7
commit 4a8d515f4d
6 changed files with 289 additions and 24 deletions

View file

@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

View file

@ -9,7 +9,7 @@ class TTConsts {
static get SESSION_SERVER => static get SESSION_SERVER =>
"https://api.zontreck.com/timetrack/$UPDATE_CHANNEL/timetrack.php"; "https://api.zontreck.com/timetrack/$UPDATE_CHANNEL/timetrack.php";
static const VERSION = "1.0.0-beta.16"; static const VERSION = "1.0.0-beta.17";
static bool UPDATE_AVAILABLE = false; static bool UPDATE_AVAILABLE = false;
static UpdateChannel UPDATE_CHANNEL = UpdateChannel.beta; static UpdateChannel UPDATE_CHANNEL = UpdateChannel.beta;

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui'; import 'dart:ui';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -8,9 +9,19 @@ import 'package:floating_window_android/floating_window_android.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_background/flutter_background.dart'; import 'package:flutter_background/flutter_background.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:libac_dart/nbt/NbtIo.dart';
import 'package:libac_dart/nbt/NbtUtils.dart';
import 'package:libac_dart/nbt/SnbtIo.dart';
import 'package:libac_dart/nbt/Stream.dart'; import 'package:libac_dart/nbt/Stream.dart';
import 'package:libac_dart/nbt/impl/CompoundTag.dart';
import 'package:libac_dart/nbt/impl/DoubleTag.dart';
import 'package:libac_dart/nbt/impl/ListTag.dart';
import 'package:libac_dart/nbt/impl/StringTag.dart';
import 'package:libac_dart/utils/Converter.dart';
import 'package:libac_dart/utils/TimeUtils.dart'; import 'package:libac_dart/utils/TimeUtils.dart';
import 'package:libacflutter/nbt/nbtHelpers.dart';
import 'package:timetrack/consts.dart'; import 'package:timetrack/consts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class SessionData { class SessionData {
static DateTime StartTime = DateTime(0); static DateTime StartTime = DateTime(0);
@ -163,10 +174,14 @@ class SessionData {
"Background notification for keeping TimeTrack running in the background", "Background notification for keeping TimeTrack running in the background",
notificationImportance: AndroidNotificationImportance.normal, notificationImportance: AndroidNotificationImportance.normal,
); );
bool success = await FlutterBackground.initialize( bool success = await FlutterBackground.initialize(
androidConfig: androidConfig, androidConfig: androidConfig,
); );
FlutterBackground.enableBackgroundExecution();
WakelockPlus.enable();
if (!success) { if (!success) {
return false; return false;
} }
@ -214,6 +229,7 @@ class SessionData {
return; return;
} }
positions.add(SmallPosition.fromPosition(pos)); positions.add(SmallPosition.fromPosition(pos));
SessionData.SaveCacheState();
SessionData.Calls.dispatch(); SessionData.Calls.dispatch();
}); });
@ -229,25 +245,165 @@ class SessionData {
EndTime = DateTime.now(); EndTime = DateTime.now();
var saveData = SaveData(); FlutterBackground.disableBackgroundExecution();
print(saveData); WakelockPlus.disable();
var saveData = await _serializeToNBT();
ResetAppSession();
print(SnbtIo.writeToString(saveData));
Uint8List nbtData = await NbtIo.writeToStream(saveData);
Trips = []; Trips = [];
positions = []; positions = [];
Dio dio = Dio(); _upload(nbtData);
Map<String, dynamic> payload = {"cmd": "create", "data": saveData}; }
static Future<void> _upload(List<int> nbtData) async {
Dio dio = Dio();
Map<String, dynamic> payload = {
"cmd": "create",
"data": base64Encoder.encode(nbtData),
};
try {
var reply = await dio.post( var reply = await dio.post(
TTConsts.SESSION_SERVER, TTConsts.SESSION_SERVER,
data: json.encode(payload), data: json.encode(payload),
); );
if (reply.statusCode == null) {
throw Exception("Fatal error while uploading");
}
if (reply.statusCode! != 200) {
throw Exception("Fatal error while uploading");
}
Map<String, dynamic> replyJs = json.decode(reply.data as String); Map<String, dynamic> replyJs = json.decode(reply.data as String);
if (replyJs["status"] == "ok") { if (replyJs["status"] == "ok") {
print("Successful upload"); print("Successful upload");
LastSessionID = replyJs['session'] as String; LastSessionID = replyJs['session'] as String;
Calls.dispatch(); Calls.dispatch();
} }
} catch (E) {
// Retry in 2 seconds
DisplayError =
"Error: Something went wrong during upload. Retry in 5 seconds...";
Calls.dispatch();
Timer(Duration(seconds: 5), () {
_upload(nbtData);
});
}
}
static void ResetAppSession() {
IsOnTheClock = false;
StartTime = DateTime.fromMillisecondsSinceEpoch(0);
Trips = [];
currentDelivery = null;
currentTrip = null;
positions = [];
EndTime = DateTime.fromMillisecondsSinceEpoch(0);
LastSessionID = "";
DisplayError = "";
IsReadOnly = false;
ContainsTripTimes = true;
Calls.dispatch();
}
/// This function attempts to load the saved state from cache.
///
/// This will return true when a session exists; false when no session exists, or is empty.
static Future<bool> LoadSavedCacheState() async {
CompoundTag ct = await NBTHelper.GetNBT(name: "appstate");
// Restore various flags now.
if (ct.isEmpty) {
ResetAppSession();
return false;
}
await _deserialize(ct);
return true;
}
/// Saves the current session based on various factors.
static Future<void> SaveCacheState() async {
CompoundTag ct = IsOnTheClock ? await _serializeToNBT() : CompoundTag();
await NBTHelper.CommitNBT(data: ct, name: "appstate");
}
static Future<void> _deserialize(CompoundTag ct) async {
IsOnTheClock = NbtUtils.readBoolean(ct, "inprog");
StartTime = DateTime.parse(ct.get("start")!.asString());
if (ct.containsKey("end")) {
EndTime = DateTime.parse(ct.get("end")!.asString());
} else {
EndTime = DateTime(0);
}
TotalPay = ct.get("totalPay")!.asDouble();
ListTag poses = ct.get("pos")! as ListTag;
for (var pos in poses.value) {
positions.add(await SmallPosition.fromNBT(pos.asCompoundTag()));
}
ListTag trips = ct.get("trips") as ListTag;
for (var trip in trips.value) {
Trips.add(await Trip.fromNBT(trip.asCompoundTag()));
}
if (ct.containsKey("current_trip")) {
currentTrip = await Trip.fromNBT(ct.get("current_trip")!.asCompoundTag());
}
if (ct.containsKey("current_delivery")) {
currentDelivery = await Delivery.fromNBT(
ct.get("current_delivery")!.asCompoundTag(),
);
}
}
/// This private function will turn all the data into NBT, for both the cache state, and newer usage, for storing it on the server in a more compact format.
static Future<CompoundTag> _serializeToNBT() async {
CompoundTag ct = CompoundTag();
NbtUtils.writeBoolean(ct, "inprog", IsOnTheClock);
// No need to write the contains trip times flag, it is set during deserialization. For inprog sessions, it will be set to true by the system.
ct.put("start", StringTag.valueOf(StartTime.toIso8601String()));
if (EndTime.year < 2000) {
// We have a end time
ct.put("end", StringTag.valueOf(EndTime.toIso8601String()));
}
ListTag posX = ListTag();
for (var pos in positions) {
posX.add(await pos.toNBT());
}
ct.put("pos", posX);
ListTag myTrips = ListTag();
for (var trip in Trips) {
myTrips.add(await trip.toNBT());
}
ct.put("trips", myTrips);
// This format supports saving current trip and current delivery.
if (currentDelivery != null) {
ct.put("current_delivery", await currentDelivery!.toNBT());
}
if (currentTrip != null) {
ct.put("current_trip", await currentTrip!.toNBT());
}
if (TotalPay != null) {
ct.put("totalPay", DoubleTag.valueOf(TotalPay!));
}
return ct;
} }
static Map<String, dynamic> SaveData() { static Map<String, dynamic> SaveData() {
@ -283,7 +439,18 @@ class SessionData {
data: json.encode(payload), data: json.encode(payload),
); );
String cType = reply.headers.value("Content-Type") ?? "application/json";
if (cType == "application/json") {
return LoadData(reply.data as Map<String, dynamic>); return LoadData(reply.data as Map<String, dynamic>);
} else if (cType == "application/nbt") {
Uint8List lst = base64Encoder.decode(reply.data as String);
// Convert this to a CompoundTag
CompoundTag ct = await NbtIo.readFromStream(lst) as CompoundTag;
_deserialize(ct);
return true;
} else
return false;
} catch (E) { } catch (E) {
return false; return false;
} }
@ -389,7 +556,7 @@ class Delivery {
Map<String, dynamic> toJsonMap() { Map<String, dynamic> toJsonMap() {
return { return {
"start": StartTime.toString(), "start": StartTime.toIso8601String(),
"endPos": endLocation?.toMap() ?? "incomplete", "endPos": endLocation?.toMap() ?? "incomplete",
}; };
} }
@ -407,6 +574,28 @@ class Delivery {
return delivery; return delivery;
} }
Future<CompoundTag> toNBT() async {
CompoundTag ct = CompoundTag();
ct.put("start", StringTag.valueOf(StartTime.toIso8601String()));
if (endLocation != null) {
ct.put("endPos", await endLocation!.toNBT());
}
return ct;
}
static Future<Delivery> fromNBT(CompoundTag ct) async {
Delivery delivery = Delivery();
delivery.StartTime = DateTime.parse(ct.get("start")!.asString());
if (ct.containsKey("endPos")) {
delivery.endLocation = await SmallPosition.fromNBT(
ct.get("endPos") as CompoundTag,
);
}
return delivery;
}
} }
class Trip { class Trip {
@ -428,8 +617,8 @@ class Trip {
Map<String, dynamic> toJsonMap() { Map<String, dynamic> toJsonMap() {
Map<String, Object> trip = { Map<String, Object> trip = {
"start": StartTime.toString(), "start": StartTime.toIso8601String(),
"end": EndTime.toString(), "end": EndTime.toIso8601String(),
}; };
List<Map<String, dynamic>> dropOffs = []; List<Map<String, dynamic>> dropOffs = [];
for (var delivery in deliveries) { for (var delivery in deliveries) {
@ -446,8 +635,9 @@ class Trip {
trip.StartTime = DateTime.parse(jsx['start'] as String); trip.StartTime = DateTime.parse(jsx['start'] as String);
if (jsx.containsKey("end")) { if (jsx.containsKey("end")) {
trip.EndTime = DateTime.parse(jsx['end'] as String); trip.EndTime = DateTime.parse(jsx['end'] as String);
} else } else {
SessionData.ContainsTripTimes = false; SessionData.ContainsTripTimes = false;
}
trip.deliveries = []; trip.deliveries = [];
List<dynamic> dropOffs = jsx['deliveries'] as List<dynamic>; List<dynamic> dropOffs = jsx['deliveries'] as List<dynamic>;
@ -457,6 +647,41 @@ class Trip {
return trip; return trip;
} }
Future<CompoundTag> toNBT() async {
CompoundTag ct = CompoundTag();
ct.put("start", StringTag.valueOf(StartTime.toIso8601String()));
if (EndTime.year < 2000) {
ct.put("end", StringTag.valueOf(EndTime.toIso8601String()));
}
ListTag drops = ListTag();
for (var drop in deliveries) {
drops.add(await drop.toNBT());
}
ct.put("deliveries", drops);
return ct;
}
static Future<Trip> fromNBT(CompoundTag tag) async {
Trip trip = Trip();
// Deserialize the Trip
trip.StartTime = DateTime.parse(tag.get("start")!.asString());
if (!tag.containsKey("end")) {
SessionData.ContainsTripTimes = false;
}
ListTag drops = tag.get("deliveries")! as ListTag;
for (var drop in drops.value) {
Delivery del = await Delivery.fromNBT(drop.asCompoundTag());
trip.deliveries.add(del);
}
return trip;
}
} }
class Callbacks { class Callbacks {
@ -497,4 +722,19 @@ class SmallPosition {
longitude: map['longitude'] as double, longitude: map['longitude'] as double,
); );
} }
Future<CompoundTag> toNBT() async {
CompoundTag ct = CompoundTag();
ct.put("latitude", DoubleTag.valueOf(latitude));
ct.put("longitude", DoubleTag.valueOf(longitude));
return ct;
}
static Future<SmallPosition> fromNBT(CompoundTag ct) async {
return SmallPosition(
latitude: ct.get("latitude")?.asDouble() ?? 0,
longitude: ct.get("longitude")?.asDouble() ?? 0,
);
}
} }

View file

@ -5,16 +5,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:timetrack/consts.dart'; import 'package:timetrack/consts.dart';
import 'package:timetrack/data.dart'; import 'package:timetrack/data.dart';
import 'package:timetrack/pages/HomePage.dart';
import 'package:timetrack/pages/MainApp.dart'; import 'package:timetrack/pages/MainApp.dart';
@pragma("vm:entry-point") @pragma("vm:entry-point")
void serviceEntry() { void serviceEntry() {
// This is where both the Background service and the Floating app window would enter the app.
Timer.periodic(Duration(seconds: 5), (timer) async {
// Run the location fetch, add to list, etc, etc.
});
// Run the floater app here. // Run the floater app here.
runApp(MaterialApp(debugShowCheckedModeBanner: false, home: OverlayWidget()));
} }
Future<void> main() async { Future<void> main() async {

View file

@ -30,6 +30,18 @@ class _HomePageState extends State<HomePage> {
void call() { void call() {
setState(() {}); setState(() {});
if (SessionData.DisplayError.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(SessionData.DisplayError),
elevation: 8,
duration: Duration(seconds: 5),
),
);
SessionData.DisplayError = "";
}
} }
@override @override
@ -393,3 +405,17 @@ class _HomePageState extends State<HomePage> {
); );
} }
} }
class OverlayWidget extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _Overlay();
}
}
class _Overlay extends State<OverlayWidget> {
@override
Widget build(BuildContext context) {
return Scaffold();
}
}

View file

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0-beta.16 version: 1.0.0-beta.17
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2
@ -50,6 +50,7 @@ dependencies:
flutter_background: ^1.3.0+1 flutter_background: ^1.3.0+1
floating_window_android: ^1.0.0 floating_window_android: ^1.0.0
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
wakelock_plus: ^1.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: