Skip to content

Commit

Permalink
Enable data cap on desktop (#1072)
Browse files Browse the repository at this point in the history
* enable data cap on desktop

* Add datacap package

* Fix TestServeCap

* remove test code

* make ffiFunction optional

* clean-ups
  • Loading branch information
atavism authored May 10, 2024
1 parent 83208b5 commit f6d6e51
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 34 deletions.
18 changes: 14 additions & 4 deletions desktop/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}

Expand Down
161 changes: 161 additions & 0 deletions desktop/datacap/datacap.go
Original file line number Diff line number Diff line change
@@ -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 := &notify.Notification{
Title: title,
Message: msg,
ClickURL: dc.clickURL(),
IconURL: dc.iconURL(),
}
notifier.ShowNotification(note, campaign)
}
70 changes: 70 additions & 0 deletions desktop/datacap/datacap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 2 additions & 1 deletion lib/common/ffi_subscriber.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension BoolParsing on String {

class FfiValueNotifier<T> extends SubscribedNotifier<T?> {
FfiValueNotifier(
Pointer<Utf8> Function() ffiFunction,
Pointer<Utf8> Function()? ffiFunction,
String path,
T? defaultValue,
void Function() removeFromCache, {
Expand All @@ -28,6 +28,7 @@ class FfiValueNotifier<T> extends SubscribedNotifier<T?> {
value = newValue;
});
}
if (ffiFunction == null) return;
if (defaultValue is int) {
value = null;
//value = int.parse(ffiFunction().toDartString()) as T?;
Expand Down
4 changes: 2 additions & 2 deletions lib/common/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ abstract class Model {

ValueListenableBuilder<T?> ffiValueBuilder<T>(
String path,
Pointer<Utf8> Function() ffiFunction, {
Pointer<Utf8> Function()? ffiFunction, {
T? defaultValue,
required ValueWidgetBuilder<T> builder,
bool details = false,
Expand Down Expand Up @@ -95,7 +95,7 @@ abstract class Model {
}

ValueNotifier<T?> ffiValueNotifier<T>(
Pointer<Utf8> Function() ffiFunction,
Pointer<Utf8> Function()? ffiFunction,
String path,
T? defaultValue, {
bool details = false,
Expand Down
10 changes: 8 additions & 2 deletions lib/common/session_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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}"),
);
Expand Down
52 changes: 31 additions & 21 deletions lib/vpn/vpn_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@ class VpnModel extends Model {
});
}

Future<void> handleWebSocketMessage(Map<String, dynamic> 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<String> builder) {
if (isMobile()) {
return subscribedSingleValueBuilder<String>(
Expand All @@ -38,31 +31,48 @@ class VpnModel extends Model {
return ffiValueBuilder<String>(
'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<bool> isVpnConnected() async {
final vpnStatus = await methodChannel.invokeMethod('getVpnStatus');
return vpnStatus == 'connected';
}

Widget bandwidth(ValueWidgetBuilder<Bandwidth> builder) {
return subscribedSingleValueBuilder<Bandwidth>(
'/bandwidth',
if (isMobile()) {
return subscribedSingleValueBuilder<Bandwidth>(
'/bandwidth',
builder: builder,
deserialize: (Uint8List serialized) {
return Bandwidth.fromBuffer(serialized);
},
);
}
final websocket = WebsocketImpl.instance();
return ffiValueBuilder<Bandwidth>(
'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);
},
);
}
}
Loading

0 comments on commit f6d6e51

Please sign in to comment.