Skip to content

Add Grace Period Contracts check service #1028

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: development
Choose a base branch
from
Open
29 changes: 16 additions & 13 deletions app/lib/apps/notifications/notifications_user_data.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:threebotlogin/helpers/logger.dart';

const String nodeStatusNotificationEnabledKey = 'nodeStatusNotificationEnabled';
const String _contractNotificationsEnabledKey =
'contract_notifications_enabled';

Future<List<String>?> getNotificationSettings() async {
final prefs = await SharedPreferences.getInstance();
Expand All @@ -10,19 +11,21 @@ Future<List<String>?> getNotificationSettings() async {
}

Future<bool> isNodeStatusNotificationEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(nodeStatusNotificationEnabledKey) ?? true;
} catch (e) {
return true;
}
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(nodeStatusNotificationEnabledKey) ?? true;
}

Future<void> setNodeStatusNotificationEnabled(bool value) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(nodeStatusNotificationEnabledKey, value);
} catch (e) {
logger.e('Error saving notification preference: $e');
}
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(nodeStatusNotificationEnabledKey, value);
}

Future<bool> isContractNotificationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_contractNotificationsEnabledKey) ?? true;
}

Future<void> setContractNotificationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_contractNotificationsEnabledKey, enabled);
}
32 changes: 15 additions & 17 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,6 @@ Future<void> main() async {
await NotificationService().initNotification();

BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);

bool initDone = await getInitDone();
String? doubleName = await getDoubleName();

await setGlobalValues();
bool registered = doubleName != null;

runApp(
ProviderScope(
child: MyApp(initDone: initDone, registered: registered),
),
);

BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
Expand All @@ -71,14 +58,25 @@ Future<void> main() async {
),
(String taskId) async {
logger.i('[BackgroundFetch] Task: $taskId');
await checkNodeStatus(taskId);
BackgroundFetch.finish(taskId);
backgroundFetchHeadlessTask(HeadlessTask(taskId, false));
},
(String taskId) async {
logger.i('[BackgroundFetch] Timeout: $taskId');
BackgroundFetch.finish(taskId);
},
);

bool initDone = await getInitDone();
String? doubleName = await getDoubleName();

await setGlobalValues();
bool registered = doubleName != null;

runApp(
ProviderScope(
child: MyApp(initDone: initDone, registered: registered),
),
);
}

Future<void> setGlobalValues() async {
Expand Down Expand Up @@ -129,7 +127,7 @@ class MyApp extends ConsumerWidget {
backgroundColor: kColorScheme.primary,
foregroundColor: kColorScheme.onPrimary,
),
cardTheme: const CardTheme().copyWith(
cardTheme: const CardThemeData().copyWith(
color: kColorScheme.surfaceContainer,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
elevatedButtonTheme: ElevatedButtonThemeData(
Expand All @@ -156,7 +154,7 @@ class MyApp extends ConsumerWidget {
backgroundColor: kDarkColorScheme.primaryContainer,
foregroundColor: kDarkColorScheme.onPrimaryContainer,
),
cardTheme: const CardTheme().copyWith(
cardTheme: const CardThemeData().copyWith(
color: kDarkColorScheme.surfaceContainer,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
elevatedButtonTheme: ElevatedButtonThemeData(
Expand Down
24 changes: 20 additions & 4 deletions app/lib/screens/notifications_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ class NotificationsScreen extends StatefulWidget {
class _NotificationsScreenState extends State<NotificationsScreen> {
bool loading = true;
late bool _nodeStatusNotificationEnabled;
late bool _contractNotificationsEnabled;

@override
void initState() {
super.initState();
_loadNotificationPreference();
_loadNotificationPreferences();
}

void _loadNotificationPreference() async {
final bool enabled = await isNodeStatusNotificationEnabled();
void _loadNotificationPreferences() async {
final bool nodeEnabled = await isNodeStatusNotificationEnabled();
final bool contractEnabled =
await isContractNotificationEnabled();
setState(() {
_nodeStatusNotificationEnabled = enabled;
_nodeStatusNotificationEnabled = nodeEnabled;
_contractNotificationsEnabled = contractEnabled;
loading = false;
});
}
Expand Down Expand Up @@ -69,6 +73,18 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
},
secondary: const Icon(Icons.monitor_heart),
),
SwitchListTile(
title:
const Text('Enable contract grace period notifications'),
value: _contractNotificationsEnabled,
onChanged: (bool newValue) {
setState(() {
_contractNotificationsEnabled = newValue;
});
setContractNotificationEnabled(newValue);
},
secondary: const Icon(Icons.description),
),
],
),
);
Expand Down
110 changes: 88 additions & 22 deletions app/lib/services/background_service.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'package:background_fetch/background_fetch.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gridproxy_client/models/contracts.dart';
import 'package:threebotlogin/apps/notifications/notifications_user_data.dart';
import 'package:threebotlogin/models/farm.dart';
import 'package:threebotlogin/services/contract_check_service.dart';
import 'package:threebotlogin/services/nodes_check_service.dart';
import 'notification_service.dart';
import 'package:threebotlogin/helpers/logger.dart';
Expand All @@ -9,29 +12,91 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async {
final String taskId = task.taskId;
final bool timeout = task.timeout;

if (timeout) {
BackgroundFetch.finish(taskId);
return;
}
final bool notificationsEnabled = await isNodeStatusNotificationEnabled();
final container = ProviderContainer();

logger.i(
'Background Fetch Headless Task: $taskId, Notifications Enabled: $notificationsEnabled');
try {
if (timeout) {
logger.w('[BackgroundFetch] Task timed out: $taskId');
BackgroundFetch.finish(taskId);
return;
}

if (!notificationsEnabled) {
logger.i(
'[BackgroundFetch] Node status notifications are disabled. Finishing task: $taskId');
'[BackgroundFetch] Headless Task: $taskId started. Time: ${DateTime.now()}');

// Run contract and node checks concurrently
await Future.wait([
_checkContractsAndNotify(container, taskId),
_checkNodesAndNotify(taskId),
]);
} catch (e, stack) {
logger.e('[BackgroundFetch] Error during task $taskId: $e',
error: e, stackTrace: stack);
} finally {
container.dispose();
BackgroundFetch.finish(taskId);
return;
logger.i('[BackgroundFetch] Task finished: $taskId');
}
await checkNodeStatus(taskId);
}

Future<void> checkNodeStatus(String taskId) async {
Future<void> _checkContractsAndNotify(
ProviderContainer container, String taskId) async {
try {
final offlineNodes = await NodeCheckService.pingNodesInBackground();
final List<ContractInfo> allContractsInGracePeriod = await container
.read(contractCheckServiceProvider)
.checkContractsState();

if (allContractsInGracePeriod.isNotEmpty) {
final bool contractNotificationsEnabled =
await isContractNotificationEnabled();
logger.i(
'[ContractsCheck] Contracts in grace period: ${allContractsInGracePeriod.length}. Contract Notifications enabled: $contractNotificationsEnabled');

if (contractNotificationsEnabled) {
String notificationBody =
'You have ${allContractsInGracePeriod.length} contract(s) in grace period.';
final String contractIds =
allContractsInGracePeriod.map((c) => c.contract_id).join(', ');
notificationBody += '\nContract IDs: $contractIds';

await NotificationService().showNotification(
id: 'contract_grace_period',
title: 'Contract Grace Period Alert! ⏳',
body: notificationBody,
groupKey: 'contract_alerts',
);
}
}
} catch (e, stack) {
logger.e(
'[ContractsCheck] Error during contracts check for task $taskId: $e',
error: e,
stackTrace: stack);
rethrow;
}
}

Future<void> _checkNodesAndNotify(String taskId) async {
try {
final bool nodeNotificationsEnabled =
await isNodeStatusNotificationEnabled();
logger.i(
'[NodesCheck] Node Notifications Enabled: $nodeNotificationsEnabled for task $taskId');

if (!nodeNotificationsEnabled) {
logger.i(
'[NodesCheck] Node notifications are disabled by user setting. Exiting _checkNodesAndNotify for task $taskId.');
return;
}

if (offlineNodes.isEmpty) return;
final offlineNodes = await NodeCheckService.pingNodesInBackground();
if (offlineNodes.isEmpty) {
logger.i(
'[NodesCheck] No raw offline nodes found from pingNodesInBackground(). Exiting _checkNodesAndNotify for task $taskId.');
return;
}
logger.i(
'[NodesCheck] Found ${offlineNodes.length} raw offline nodes for task $taskId.');

final StringBuffer bodyBuffer = StringBuffer();
final List<Node> nodesToNotify = [];
Expand All @@ -43,17 +108,16 @@ Future<void> checkNodeStatus(String taskId) async {
for (final node in offlineNodes) {
final nodeUpdatedAtMs = node.updatedAt! * 1000;

// Filter out nodes updated more than 7 days ago
if (nodeUpdatedAtMs <= sevenDaysAgoTimestampMs) continue;

final downtime = Duration(milliseconds: nowInMs - nodeUpdatedAtMs);

final checkInterval = _getCheckInterval(downtime);

bool passesIntervalCheck = false;
if (downtime.inMinutes > 0 && checkInterval.inMinutes > 0) {
passesIntervalCheck = downtime.inMinutes % checkInterval.inMinutes < 15;
}

if (passesIntervalCheck) {
nodesToNotify.add(node);
final formattedDowntime = _formatDowntime(downtime);
Expand All @@ -65,17 +129,17 @@ Future<void> checkNodeStatus(String taskId) async {
if (nodesToNotify.isEmpty) return;

await NotificationService().showNotification(
id: nodesToNotify.hashCode,
id: 'offline_nodes_alert',
title: nodesToNotify.length == 1
? 'Node Alert 🚨'
: '${nodesToNotify.length} Nodes Offline 🚨',
body: bodyBuffer.toString().trim(),
groupKey: 'offline_nodes',
);
} catch (e) {
logger.e('Error in checkNodeStatus for task $taskId: $e');
} finally {
BackgroundFetch.finish(taskId);
} catch (e, stack) {
logger.e('[NodesCheck] Error in node check for task $taskId: $e',
error: e, stackTrace: stack);
rethrow;
}
}

Expand All @@ -98,7 +162,9 @@ String _formatDowntime(Duration duration) {
return '${duration.inDays} days';
} else if (duration.inHours > 0) {
return '${duration.inHours} hours';
} else {
} else if (duration.inMinutes > 0) {
return '${duration.inMinutes} minutes';
} else {
return '${duration.inSeconds} seconds';
}
}
42 changes: 42 additions & 0 deletions app/lib/services/contract_check_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gridproxy_client/models/contracts.dart';
import 'package:threebotlogin/helpers/logger.dart';
import 'package:threebotlogin/models/wallet.dart';
import 'package:threebotlogin/providers/wallets_provider.dart';
import 'package:threebotlogin/services/gridproxy_service.dart';
import 'package:threebotlogin/services/tfchain_service.dart';

final contractCheckServiceProvider = Provider<ContractCheckService>((ref) {
return ContractCheckService(ref);
});

class ContractCheckService {
final Ref _ref;

ContractCheckService(this._ref);

Future<List<ContractInfo>> checkContractsState() async {
List<ContractInfo> allContracts = [];

try {
final walletsNotifierInstance = _ref.read(walletsNotifier.notifier);
await walletsNotifierInstance.waitUntilListed();

final List<Wallet> wallets = _ref.read(walletsNotifier);
if (wallets.isEmpty) return [];

for (final w in wallets) {
final twinId = await getTwinId(w.tfchainSecret);
if (twinId != 0) {
List<ContractInfo> contracts =
await getGracePeriodContractsByTwinId(twinId);
allContracts.addAll(contracts);
}
}
return allContracts;
} catch (e) {
logger.e('[ContractCheckService] Error checking contract state: $e');
return [];
}
}
}
14 changes: 14 additions & 0 deletions app/lib/services/gridproxy_service.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:gridproxy_client/gridproxy_client.dart';
import 'package:gridproxy_client/models/contracts.dart';
import 'package:gridproxy_client/models/nodes.dart';
import 'package:threebotlogin/helpers/globals.dart';
import 'package:threebotlogin/main.reflectable.dart';
Expand Down Expand Up @@ -65,3 +66,16 @@ Future<bool> isFarmNameAvailable(String name) async {
throw Exception('Failed to get farms due to $e');
}
}

Future<List<ContractInfo>> getGracePeriodContractsByTwinId(int twinId) async {
try {
initializeReflectable();
final gridproxyUrl = Globals().gridproxyUrl;
GridProxyClient client = GridProxyClient(gridproxyUrl);
final contracts =
await client.contracts.list(ContractInfoQueryParams(twin_id: twinId, state: ContractState.GracePeriod));
return contracts;
} catch (e) {
throw Exception('Failed to get contracts due to $e');
}
}
Loading