From f6d6e519d6c24d2d9620d41cc2de963cced007e5 Mon Sep 17 00:00:00 2001 From: atavism Date: Fri, 10 May 2024 07:32:36 -0700 Subject: [PATCH] Enable data cap on desktop (#1072) * enable data cap on desktop * Add datacap package * Fix TestServeCap * remove test code * make ffiFunction optional * clean-ups --- desktop/app/app.go | 18 +++- desktop/datacap/datacap.go | 161 ++++++++++++++++++++++++++++++++ desktop/datacap/datacap_test.go | 70 ++++++++++++++ lib/common/ffi_subscriber.dart | 3 +- lib/common/model.dart | 4 +- lib/common/session_model.dart | 10 +- lib/vpn/vpn_model.dart | 52 ++++++----- lib/vpn/vpn_tab.dart | 8 +- 8 files changed, 292 insertions(+), 34 deletions(-) create mode 100644 desktop/datacap/datacap.go create mode 100644 desktop/datacap/datacap_test.go diff --git a/desktop/app/app.go b/desktop/app/app.go index 72647f8e0..b69ebe30b 100644 --- a/desktop/app/app.go +++ b/desktop/app/app.go @@ -37,6 +37,7 @@ import ( "github.com/getlantern/lantern-client/desktop/analytics" "github.com/getlantern/lantern-client/desktop/autoupdate" + "github.com/getlantern/lantern-client/desktop/datacap" "github.com/getlantern/lantern-client/desktop/features" "github.com/getlantern/lantern-client/desktop/notifier" "github.com/getlantern/lantern-client/desktop/settings" @@ -336,10 +337,19 @@ func (app *App) beforeStart(listenAddr string) { return } - app.AddExitFunc("stopping loconf scanner", LoconfScanner(app.settings, app.configDir, 4*time.Hour, - func() (bool, bool) { return app.IsProUser(context.Background()) }, func() string { - return "/img/lantern_logo.png" - })) + isProUser := func() (bool, bool) { + return app.IsProUser(context.Background()) + } + + if err := datacap.ServeDataCap(app.ws, func() string { + return "/img/lantern_logo.png" + }, func() string { return "" }, isProUser); err != nil { + log.Errorf("Unable to serve bandwidth to UI: %v", err) + } + + app.AddExitFunc("stopping loconf scanner", LoconfScanner(app.settings, app.configDir, 4*time.Hour, isProUser, func() string { + return "/img/lantern_logo.png" + })) app.AddExitFunc("stopping notifier", notifier.NotificationsLoop(app.analyticsSession)) } diff --git a/desktop/datacap/datacap.go b/desktop/datacap/datacap.go new file mode 100644 index 000000000..62a6fc5fb --- /dev/null +++ b/desktop/datacap/datacap.go @@ -0,0 +1,161 @@ +package datacap + +import ( + "strconv" + "strings" + "sync" + "time" + + "github.com/getlantern/flashlight/v7/bandwidth" + "github.com/getlantern/flashlight/v7/common" + "github.com/getlantern/golog" + "github.com/getlantern/i18n" + notify "github.com/getlantern/notifier" + + "github.com/getlantern/lantern-client/desktop/notifier" + "github.com/getlantern/lantern-client/desktop/ws" +) + +var ( + // These just make sure we only sent a single notification at each percentage + // level. + oneFifty = &sync.Once{} + oneEighty = &sync.Once{} + oneFull = &sync.Once{} + + dataCapListeners = make([]func(hitDataCap bool), 0) + dataCapListenersMx sync.RWMutex + log = golog.LoggerFor("lantern-desktop.datacap") + + translationAppName = strings.ToUpper(common.DefaultAppName) + + uncapped = &bandwidth.Quota{ + MiBAllowed: 0, + MiBUsed: 0, + AsOf: time.Now(), + TTLSeconds: 0, + } +) + +type dataCap struct { + iconURL func() string + clickURL func() string + isPro func() (bool, bool) +} + +// ServeDataCap starts serving data cap data to the frontend. +func ServeDataCap(channel ws.UIChannel, iconURL func() string, clickURL func() string, isPro func() (bool, bool)) error { + helloFn := func(write func(interface{})) { + q, _ := bandwidth.GetQuota() + if q == nil { + // On client first connecting, if we don't have a datacap, assume we're uncapped + q = uncapped + } + log.Debugf("Sending current bandwidth quota to new client: %v", q) + write(q) + } + bservice, err := channel.Register("bandwidth", helloFn) + if err != nil { + log.Errorf("Error registering with UI? %v", err) + return err + } + dc := &dataCap{iconURL: iconURL, clickURL: clickURL, isPro: isPro} + go func() { + for quota := range bandwidth.Updates { + dc.processQuota(bservice.Out, quota) + } + }() + return nil +} + +func (dc *dataCap) processQuota(out chan<- interface{}, quota *bandwidth.Quota) { + log.Debugf("Sending update...%+v", quota) + out <- quota + isFull := dc.isFull(quota) + dataCapListenersMx.RLock() + listeners := dataCapListeners + dataCapListenersMx.RUnlock() + for _, l := range listeners { + l(isFull) + } + if isFull { + oneFull.Do(func() { + go dc.notifyCapHit() + }) + } else if dc.isEightyOrMore(quota) { + oneEighty.Do(func() { + go dc.notifyEighty() + }) + } else if dc.isFiftyOrMore(quota) { + oneFifty.Do(func() { + go dc.notifyFifty() + }) + } +} + +// AddDataCapListener adds a listener for any updates to the data cap. +func AddDataCapListener(l func(hitDataCap bool)) { + dataCapListenersMx.Lock() + dataCapListeners = append(dataCapListeners, l) + dataCapListenersMx.Unlock() +} + +func (dc *dataCap) isEightyOrMore(quota *bandwidth.Quota) bool { + return dc.checkPercent(quota, 0.8) +} + +func (dc *dataCap) isFiftyOrMore(quota *bandwidth.Quota) bool { + return dc.checkPercent(quota, 0.5) +} + +func (dc *dataCap) isFull(quota *bandwidth.Quota) bool { + return (quota.MiBUsed > 0 && quota.MiBAllowed <= quota.MiBUsed) +} + +func (dc *dataCap) checkPercent(quota *bandwidth.Quota, percent float64) bool { + return (float64(quota.MiBUsed) / float64(quota.MiBAllowed)) > percent +} + +func (dc *dataCap) notifyEighty() { + dc.notifyPercent(80) +} + +func (dc *dataCap) notifyFifty() { + dc.notifyPercent(50) +} + +func (dc *dataCap) percentFormatted(percent int) string { + return strconv.Itoa(percent) + "%" +} + +func (dc *dataCap) notifyPercent(percent int) { + title := i18n.T("BACKEND_DATA_PERCENT_TITLE", dc.percentFormatted(percent), i18n.T(translationAppName)) + msg := i18n.T("BACKEND_DATA_PERCENT_MESSAGE", dc.percentFormatted(percent), i18n.T(translationAppName)) + + dc.notifyFreeUser(title, msg, "data-cap-"+strconv.Itoa(percent)) +} + +func (dc *dataCap) notifyCapHit() { + title := i18n.T("BACKEND_DATA_TITLE", i18n.T(translationAppName)) + msg := i18n.T("BACKEND_DATA_MESSAGE", i18n.T(translationAppName)) + + dc.notifyFreeUser(title, msg, "data-cap-100") +} + +func (dc *dataCap) notifyFreeUser(title, msg, campaign string) { + if isPro, ok := dc.isPro(); !ok { + log.Debug("user status is unknown, skip showing notification") + return + } else if isPro { + log.Debug("Not showing desktop notification for pro user") + return + } + + note := ¬ify.Notification{ + Title: title, + Message: msg, + ClickURL: dc.clickURL(), + IconURL: dc.iconURL(), + } + notifier.ShowNotification(note, campaign) +} diff --git a/desktop/datacap/datacap_test.go b/desktop/datacap/datacap_test.go new file mode 100644 index 000000000..37fa5e8d8 --- /dev/null +++ b/desktop/datacap/datacap_test.go @@ -0,0 +1,70 @@ +package datacap + +import ( + "fmt" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/getlantern/flashlight/v7/bandwidth" + "github.com/getlantern/lantern-client/desktop/ws" +) + +func TestServeCap(t *testing.T) { + err := ServeDataCap(ws.NewUIChannel(), func() string { + return "" + }, func() string { + return "" + }, func() (bool, bool) { + return false, false + }) + assert.NoError(t, err) + + dc := &dataCap{iconURL: func() string { + return "" + }, clickURL: func() string { + return "" + }, isPro: func() (bool, bool) { + return false, false + }} + + hit := false + listener := func(hitDataCap bool) { + hit = hitDataCap + } + AddDataCapListener(listener) + out := make(chan<- interface{}, 2) + quota := &bandwidth.Quota{MiBAllowed: 10, MiBUsed: 10, AsOf: time.Now()} + dc.processQuota(out, quota) + + assert.True(t, hit) + + quota = &bandwidth.Quota{MiBAllowed: 10, MiBUsed: 1, AsOf: time.Now()} + dc.processQuota(out, quota) +} + +func TestPercents(t *testing.T) { + ns := dataCap{} + + quota := &bandwidth.Quota{ + MiBAllowed: 1000, + MiBUsed: 801, + } + + assert.True(t, ns.isEightyOrMore(quota)) + + quota.MiBUsed = 501 + assert.False(t, ns.isEightyOrMore(quota)) + assert.True(t, ns.isFiftyOrMore(quota)) + + msg := "you have used %s of your data" + expected := "you have used 80% of your data" + assert.Equal(t, expected, percentMsg(msg, 80)) +} + +func percentMsg(msg string, percent int) string { + str := strconv.Itoa(percent) + "%" + return fmt.Sprintf(msg, str) +} diff --git a/lib/common/ffi_subscriber.dart b/lib/common/ffi_subscriber.dart index 912160359..c50743d45 100644 --- a/lib/common/ffi_subscriber.dart +++ b/lib/common/ffi_subscriber.dart @@ -13,7 +13,7 @@ extension BoolParsing on String { class FfiValueNotifier extends SubscribedNotifier { FfiValueNotifier( - Pointer Function() ffiFunction, + Pointer Function()? ffiFunction, String path, T? defaultValue, void Function() removeFromCache, { @@ -28,6 +28,7 @@ class FfiValueNotifier extends SubscribedNotifier { value = newValue; }); } + if (ffiFunction == null) return; if (defaultValue is int) { value = null; //value = int.parse(ffiFunction().toDartString()) as T?; diff --git a/lib/common/model.dart b/lib/common/model.dart index cc8e702cd..504841ae1 100644 --- a/lib/common/model.dart +++ b/lib/common/model.dart @@ -56,7 +56,7 @@ abstract class Model { ValueListenableBuilder ffiValueBuilder( String path, - Pointer Function() ffiFunction, { + Pointer Function()? ffiFunction, { T? defaultValue, required ValueWidgetBuilder builder, bool details = false, @@ -95,7 +95,7 @@ abstract class Model { } ValueNotifier ffiValueNotifier( - Pointer Function() ffiFunction, + Pointer Function()? ffiFunction, String path, T? defaultValue, { bool details = false, diff --git a/lib/common/session_model.dart b/lib/common/session_model.dart index aad3552b2..61989825f 100644 --- a/lib/common/session_model.dart +++ b/lib/common/session_model.dart @@ -84,11 +84,17 @@ class SessionModel extends Model { // listenWebsocket listens for websocket messages from the server. If a message matches the given message type, // the onMessage callback is triggered with the given property value void listenWebsocket(WebsocketImpl? websocket, String messageType, - property, void Function(T?) onMessage) { + String? property, void Function(T?) onMessage) { if (websocket == null) return; websocket.messageStream.listen( (json) { - if (json["type"] == messageType) onMessage(json["message"][property]); + if (json["type"] == messageType) { + if (property != null) { + onMessage(json["message"][property]); + } else { + onMessage(json["message"]); + } + } }, onError: (error) => appLogger.i("websocket error: ${error.description}"), ); diff --git a/lib/vpn/vpn_model.dart b/lib/vpn/vpn_model.dart index 01a9caafd..c5698b42a 100644 --- a/lib/vpn/vpn_model.dart +++ b/lib/vpn/vpn_model.dart @@ -20,13 +20,6 @@ class VpnModel extends Model { }); } - Future handleWebSocketMessage(Map data, Function setValue) async { - if (data["type"] != "vpnstatus") return; - final updated = data["message"]["connected"]; - final isConnected = updated != null && updated.toString() == "true"; - setValue(isConnected ? "connected" : "disconnected"); - } - Widget vpnStatus(ValueWidgetBuilder builder) { if (isMobile()) { return subscribedSingleValueBuilder( @@ -38,31 +31,48 @@ class VpnModel extends Model { return ffiValueBuilder( 'vpnStatus', defaultValue: '', - onChanges: (setValue) { - if (websocket == null) return; - /// Listen for all incoming data - websocket.messageStream.listen( - (json) => handleWebSocketMessage(json, setValue), - onError: (error) => print(error), - ); - }, + onChanges: (setValue) => sessionModel + .listenWebsocket(websocket, "vpnstatus", "connected", (value) { + final isConnected = value != null && value.toString() == "true"; + setValue(isConnected ? "connected" : "disconnected"); + }), ffiVpnStatus, builder: builder, ); } - + Future isVpnConnected() async { final vpnStatus = await methodChannel.invokeMethod('getVpnStatus'); return vpnStatus == 'connected'; } Widget bandwidth(ValueWidgetBuilder builder) { - return subscribedSingleValueBuilder( - '/bandwidth', + if (isMobile()) { + return subscribedSingleValueBuilder( + '/bandwidth', + builder: builder, + deserialize: (Uint8List serialized) { + return Bandwidth.fromBuffer(serialized); + }, + ); + } + final websocket = WebsocketImpl.instance(); + return ffiValueBuilder( + 'bandwidth', + defaultValue: null, + onChanges: (setValue) => + sessionModel.listenWebsocket(websocket, "bandwidth", null, (value) { + if (value != null) { + final Map res = jsonDecode(jsonEncode(value)); + setValue(Bandwidth.create() + ..mergeFromProto3Json({ + 'allowed': res['mibAllowed'], + 'remaining': res['mibUsed'], + })); + } + }), + null, builder: builder, - deserialize: (Uint8List serialized) { - return Bandwidth.fromBuffer(serialized); - }, ); } } diff --git a/lib/vpn/vpn_tab.dart b/lib/vpn/vpn_tab.dart index 1c3bdf261..2f88c5ac7 100644 --- a/lib/vpn/vpn_tab.dart +++ b/lib/vpn/vpn_tab.dart @@ -12,8 +12,8 @@ class VPNTab extends StatelessWidget { @override Widget build(BuildContext context) { - return sessionModel.proUser( - (BuildContext context, bool proUser, Widget? child) { + return sessionModel + .proUser((BuildContext context, bool proUser, Widget? child) { return BaseScreen( title: SvgPicture.asset( proUser ? ImagePaths.pro_logo : ImagePaths.free_logo, @@ -48,8 +48,8 @@ class VPNTab extends StatelessWidget { if (Platform.isAndroid) ...{ const CDivider(height: 32.0), SplitTunnelingWidget(), - if (!proUser) const VPNBandwidth(), - } + }, + if (!proUser) const VPNBandwidth(), ], ), ),