diff --git a/assets/locales/en.po b/assets/locales/en.po index 48aee83834..5453241b12 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -551,6 +551,12 @@ msgstr "Block Ads" msgid "only_active" msgstr "Only active when VPN is connected" +msgid "share_my_connection" +msgstr "Share My Connection" + +msgid "share_my_connection_subtitle" +msgstr "Let other Lantern users route through your connection to bypass censorship." + msgid "vpn_connected" msgstr "Lantern is now connected." diff --git a/assets/unbounded/explosion.json b/assets/unbounded/explosion.json new file mode 100644 index 0000000000..1773e7f105 --- /dev/null +++ b/assets/unbounded/explosion.json @@ -0,0 +1 @@ +{"nm":"F","h":502,"w":420,"meta":{"g":"LottieFiles Figma v42"},"layers":[{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[187.06,388.01],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[187.06,388.01],"t":114},{"s":[187.06,388.01],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[12.84],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[12.84],"t":114},{"s":[12.84],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":1},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":114},{"s":[286.35,305.11],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":114},{"s":[-15],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":2},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[57,292.33],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[57,292.33],"t":114},{"s":[57,292.33],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[13.56],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[13.56],"t":114},{"s":[13.56],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":3},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":114},{"s":[89,362.45],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":4},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,349.67],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,349.67],"t":114},{"s":[347,349.67],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":5},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[363,193.71],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[363,193.71],"t":114},{"s":[363,193.71],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-10.18],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-10.18],"t":114},{"s":[-10.18],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":6},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[41,180.93],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[41,180.93],"t":114},{"s":[41,180.93],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-7.58],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-7.58],"t":114},{"s":[-7.58],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":7},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[189.5,216.92],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[189.5,216.92],"t":114},{"s":[189.5,216.92],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":8},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[270.35,139.3],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[270.35,139.3],"t":114},{"s":[270.35,139.3],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-8.33],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-8.33],"t":114},{"s":[-8.33],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":9},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[249,317.89],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[249,317.89],"t":114},{"s":[249,317.89],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[19.7],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[19.7],"t":114},{"s":[19.7],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":10},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,96.56],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,96.56],"t":114},{"s":[89,96.56],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":11},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[272.03,46.69],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[272.03,46.69],"t":114},{"s":[272.03,46.69],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":12},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,71],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,71],"t":114},{"s":[347,71],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":13},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[152.62,62.88],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[152.62,62.88],"t":114},{"s":[152.62,62.88],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.16],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.16],"t":114},{"s":[20.16],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":14},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[108.69,186.58],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[108.69,186.58],"t":114},{"s":[108.69,186.58],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.43],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.43],"t":114},{"s":[20.43],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":15},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[397.01,263.98],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[397.01,263.98],"t":114},{"s":[397.01,263.98],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-9.8],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-9.8],"t":114},{"s":[-9.8],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":16},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":114},{"s":[121,437.23],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":17}],"v":"5.7.0","fr":30,"op":136.53,"ip":0,"assets":[]} \ No newline at end of file diff --git a/assets/unbounded/uv-map-dark.png b/assets/unbounded/uv-map-dark.png new file mode 100644 index 0000000000..afe9a07568 Binary files /dev/null and b/assets/unbounded/uv-map-dark.png differ diff --git a/assets/unbounded/uv-map.png b/assets/unbounded/uv-map.png new file mode 100644 index 0000000000..98953b96df Binary files /dev/null and b/assets/unbounded/uv-map.png differ diff --git a/go.mod b/go.mod index e4790244f2..d54c960fda 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module github.com/getlantern/lantern go 1.26.2 -// replace github.com/getlantern/radiance => ../radiance +// Local while peer connection-stats endpoint is in flight; remove once +// radiance tags a release that includes peer/connstats.go. +replace github.com/getlantern/radiance => ../radiance + +replace github.com/getlantern/lantern-box => ../lantern-box // replace github.com/getlantern/lantern-server-provisioner => ../lantern-server-provisioner diff --git a/lantern-core/core.go b/lantern-core/core.go index 7b2cf30f21..69dcff5575 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -18,9 +18,12 @@ import ( "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/ipc" "github.com/getlantern/radiance/issue" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/servers" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" "github.com/getlantern/lantern/lantern-core/apps" @@ -36,7 +39,19 @@ const ( EventTypeServerLocation EventType = "server-location" EventTypeConfig EventType = "config" EventTypeCountryCode EventType = "country-code" - DefaultLogLevel = "trace" + // EventTypePeerConnection signals a samizdat peer accept/close on the + // local Share My Connection inbound. Message is JSON + // {"state": +1|-1, "source": "ip:port"}; consumers extract the IP for + // geo-lookup or rate-limit attribution. + EventTypePeerConnection EventType = "peer-connection" + // EventTypePeerStatus signals a peer.Client lifecycle phase change + // (mapping_port → registering → verifying → serving on the way up, + // stopping → idle on the way down, error on failure). Message is the + // JSON-marshalled peer.Status struct. The Dart side switches on + // .phase to render progress text and on .error to surface + // diagnostics on the failure path. + EventTypePeerStatus EventType = "peer-status" + DefaultLogLevel = "trace" ) // LanternCore wraps an IPC client and provides the interface expected by the FFI and mobile layers. @@ -153,6 +168,23 @@ type SmartRouting interface { IsSmartRoutingEnabled() bool } +type PeerShare interface { + SetPeerShareEnabled(bool) error + IsPeerShareEnabled() bool + // SetPeerManualPort persists the user's manually-configured router + // port forward (Advanced setting in the Share My Connection UI). + // 0 clears the override, restoring UPnP-discovered port behavior. + SetPeerManualPort(port int) error + GetPeerManualPort() int + // SetUnboundedEnabled is the local opt-in for the broflake / + // Unbounded widget proxy ("Basic mode" in the SmC UI). The + // proxy actually runs only when this is true AND the server-side + // Features[unbounded] flag is on AND the server provides + // UnboundedConfig — see radiance/unbounded.shouldRunUnbounded. + SetUnboundedEnabled(bool) error + IsUnboundedEnabled() bool +} + type VPN interface { ConnectVPN(tag string) error SelectServer(tag string) error @@ -169,6 +201,7 @@ type Core interface { SplitTunnel Ads SmartRouting + PeerShare VPN Client() *ipc.Client } @@ -240,6 +273,8 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv go lc.listenAutoSelectedEvents() go lc.listenConfigEvents() go lc.listenDataCapEvents() + go lc.listenPeerConnectionEvents() + go lc.listenPeerStatusEvents() go lc.fetchUserDataIfNeeded() slog.Debug("LanternCore initialized successfully") @@ -354,6 +389,91 @@ func (lc *LanternCore) listenDataCapEvents() { } } +// listenPeerConnectionEvents forwards inbound accept/close events from +// either of the two donor protocols (samizdat-over-UPnP "Share My +// Connection" and broflake "Unbounded") to the Flutter side via the +// existing FlutterEvent emitter, so the same globe widget can render +// arcs without caring which protocol produced them. Subscription is +// process-lifetime; events.Subscribe silently delivers nothing while +// no peer / widget is active. +// +// The wire format unifies both protocols on a single event type +// (EventTypePeerConnection) with a {state, source} payload. Unbounded +// has a workerIdx in addition to source IP — surfaced as part of the +// JSON in case the Dart side eventually wants to disambiguate same-IP +// reconnects (broflake's WebRTC sessions are short and same-IP churn +// is more common than for SmC's long-lived TCP). +func (lc *LanternCore) listenPeerConnectionEvents() { + // peer.ConnectionEvent: subscribe via the IPC client's SSE stream. + // The events package's globals are process-scoped — events.Emit in + // lanternd (where radiance/peer runs) doesn't reach events.Subscribe + // in Liblantern. The /peer/connection/events SSE endpoint in + // radiance/ipc/server.go bridges the two processes. + go func() { + err := lc.client.PeerConnectionEvents(lc.ctx, func(evt peer.ConnectionEvent) { + jsonBytes, err := json.Marshal(map[string]any{ + "state": evt.State, + "source": evt.Source, + }) + if err != nil { + slog.Error("marshal peer connection event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) + }) + if err != nil && lc.ctx.Err() == nil { + slog.Error("peer-connection event stream exited unexpectedly", "error", err) + } + }() + // unbounded.ConnectionEvent stays on in-process events.Subscribe for + // now. Unbounded runs in the same process as the consumer in mobile + // builds (broflake-as-library); the desktop path doesn't yet have a + // gomobile-bridged Unbounded peer, so the cross-process gap doesn't + // hit here today. Worth revisiting if Unbounded ever moves out of + // process. + events.Subscribe(func(evt unbounded.ConnectionEvent) { + jsonBytes, err := json.Marshal(map[string]any{ + "state": evt.State, + "source": evt.Addr, + "workerIdx": evt.WorkerIdx, + }) + if err != nil { + slog.Error("marshal unbounded connection event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) + }) +} + +// listenPeerStatusEvents forwards peer.Client lifecycle phase changes to +// the Flutter side. radiance's peer module emits one StatusEvent per +// stage during Start (mapping_port → detecting_ip → registering → +// starting_proxy → verifying → serving) and during Stop (stopping → +// idle), plus an "error" terminal event with Status.Error populated on +// failure. The Dart side renders each phase as user-facing progress +// text instead of a single active/inactive flip. +// +// Message body is the JSON-marshalled peer.Status — the struct already +// carries phase, error, active, sharing_since, external_ip, +// external_port, route_id with stable JSON tags. +func (lc *LanternCore) listenPeerStatusEvents() { + // Same cross-process bridging story as listenPeerConnectionEvents: the + // peer.StatusEvent emits live in lanternd, so subscribing in this + // process via events.Subscribe gets us nothing. /peer/status/events + // SSE in radiance/ipc/server.go is the canonical source. + err := lc.client.PeerStatusEvents(lc.ctx, func(evt peer.StatusEvent) { + jsonBytes, err := json.Marshal(evt.Status) + if err != nil { + slog.Error("marshal peer status event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerStatus, string(jsonBytes)) + }) + if err != nil && lc.ctx.Err() == nil { + slog.Error("peer-status event stream exited unexpectedly", "error", err) + } +} + ///////////////// // VPN // ///////////////// @@ -454,6 +574,47 @@ func (lc *LanternCore) IsSmartRoutingEnabled() bool { return b } +func (lc *LanternCore) SetPeerShareEnabled(enabled bool) error { + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.PeerShareEnabledKey: enabled}) + return err +} + +func (lc *LanternCore) IsPeerShareEnabled() bool { + b, _ := lc.settings()[settings.PeerShareEnabledKey].(bool) + return b +} + +func (lc *LanternCore) SetPeerManualPort(port int) error { + if port < 0 || port > 65535 { + return fmt.Errorf("port %d out of range (0-65535)", port) + } + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.PeerManualPortKey: port}) + return err +} + +func (lc *LanternCore) GetPeerManualPort() int { + // koanf typically stores numeric settings as float64 after JSON + // round-trip; handle both float64 and int paths so loads from disk + // and freshly-set values both work. + switch v := lc.settings()[settings.PeerManualPortKey].(type) { + case int: + return v + case float64: + return int(v) + } + return 0 +} + +func (lc *LanternCore) SetUnboundedEnabled(enabled bool) error { + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.UnboundedKey: enabled}) + return err +} + +func (lc *LanternCore) IsUnboundedEnabled() bool { + b, _ := lc.settings()[settings.UnboundedKey].(bool) + return b +} + func (lc *LanternCore) IsTelemetryEnabled() bool { b, _ := lc.settings()[settings.TelemetryKey].(bool) return b diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index d10403cb72..8bf32d90d9 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -1352,6 +1352,86 @@ func isSmartRoutingEnabled() C.int { return 0 } +//export setPeerProxyEnabled +func setPeerProxyEnabled(enabled C.int) *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.SetPeerShareEnabled(enabled != 0); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export isPeerProxyEnabled +func isPeerProxyEnabled() C.int { + c, _ := requireCore() + if c != nil && c.IsPeerShareEnabled() { + return 1 + } + return 0 +} + +// setPeerManualPort persists the manually-configured router port-forward +// for the Share My Connection peer-share feature. 0 clears the override, +// reverting to UPnP discovery on the next peer.Client.Start. +// +//export setPeerManualPort +func setPeerManualPort(port C.int) *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.SetPeerManualPort(int(port)); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export getPeerManualPort +func getPeerManualPort() C.int { + c, _ := requireCore() + if c == nil { + return 0 + } + return C.int(c.GetPeerManualPort()) +} + +// setUnboundedEnabled is the local opt-in for the broflake / Unbounded +// widget proxy ("Basic mode" in the SmC UI). The widget actually runs +// only when this is true AND the server-side Features[unbounded] flag +// is on AND the server provides UnboundedConfig — flipping this to +// true on a network where the server hasn't enabled the feature is a +// no-op until the next /config response opts the user in. +// +//export setUnboundedEnabled +func setUnboundedEnabled(enabled C.int) *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.SetUnboundedEnabled(enabled != 0); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export isUnboundedEnabled +func isUnboundedEnabled() C.int { + c, _ := requireCore() + if c != nil && c.IsUnboundedEnabled() { + return 1 + } + return 0 +} + //export getSplitTunnelState func getSplitTunnelState() *C.char { return runOnGoStack(func() *C.char { diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index 75c4fae397..328f2a35f4 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -203,6 +203,74 @@ func SetSmartRoutingEnabled(enabled bool) error { }) } +func SetPeerShareEnabled(enabled bool) error { + slog.Info("peer-share: SetPeerShareEnabled", "enabled", enabled) + return withCore(func(c lanterncore.Core) error { + return c.SetPeerShareEnabled(enabled) + }) +} + +func IsPeerShareEnabled() bool { + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsPeerShareEnabled(), nil + }) + if err != nil { + return false + } + return ok +} + +// SetPeerManualPort persists the manually-configured router port forward +// the user has configured for the Share My Connection feature. Pass 0 +// to clear and revert to UPnP-discovered port behavior. Surfaced +// through the macOS / iOS / Android MethodChannel handler so platforms +// running radiance inside a network extension (where the main app +// process can't reach the FFI directly) can still drive the setting. +func SetPeerManualPort(port int) error { + slog.Info("peer-share: SetPeerManualPort", "port", port) + return withCore(func(c lanterncore.Core) error { + return c.SetPeerManualPort(port) + }) +} + +// GetPeerManualPort returns the currently-persisted manual port (0 if +// unset). Same MethodChannel rationale as SetPeerManualPort. +func GetPeerManualPort() int { + v, err := withCoreR(func(c lanterncore.Core) (int, error) { + return c.GetPeerManualPort(), nil + }) + if err != nil { + return 0 + } + return v +} + +// SetUnboundedEnabled is the local opt-in for the broflake / Unbounded +// widget proxy ("Basic mode" in the SmC UI). Surfaced through the +// MethodChannel so platforms running radiance inside a network +// extension (macOS, eventually iOS) can drive the setting from the +// main app process. +func SetUnboundedEnabled(enabled bool) error { + slog.Info("unbounded: SetUnboundedEnabled", "enabled", enabled) + return withCore(func(c lanterncore.Core) error { + return c.SetUnboundedEnabled(enabled) + }) +} + +// IsUnboundedEnabled returns the current local opt-in state for +// Unbounded. Note: actual run state also depends on the server +// Features[unbounded] flag and supplied UnboundedConfig — this just +// reports the persisted local toggle. +func IsUnboundedEnabled() bool { + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsUnboundedEnabled(), nil + }) + if err != nil { + return false + } + return ok +} + func IsSmartRoutingEnabled() bool { ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { return c.IsSmartRoutingEnabled(), nil diff --git a/lantern-core/utils/gostack.go b/lantern-core/utils/gostack.go index 99d6b5b660..75515a23e2 100644 --- a/lantern-core/utils/gostack.go +++ b/lantern-core/utils/gostack.go @@ -1,9 +1,12 @@ package utils import ( + "errors" "fmt" "log/slog" "runtime/debug" + "strings" + "unicode/utf8" ) // RunOffCgoStack executes fn on a new goroutine and returns its result. @@ -16,6 +19,17 @@ import ( // // If fn panics, the panic is recovered and a zero value + error are returned // instead of blocking the caller forever. +// +// Returned errors are normalized to a plain *errorString with a guaranteed +// non-empty, valid-UTF-8 message before crossing back into gomobile's +// objc bridge. The bridge wraps non-nil Go errors as a Universeerror whose +// initWithRef calls [NSString initWithBytesNoCopy: ... encoding:UTF8] on the +// raw error bytes; that returns nil for invalid UTF-8 (e.g. a gzipped 404 +// page, or a binary blob from an upstream LB), and the dictionary literal +// `@{NSLocalizedDescriptionKey: nil}` then aborts the app with +// "attempt to insert nil object from objects[0]". Sanitizing here means every +// gomobile-exported function that funnels through RunOffCgoStack is safe by +// construction, regardless of what shape of error its callee returns. func RunOffCgoStack[T any](fn func() (T, error)) (T, error) { type result struct { val T @@ -34,5 +48,19 @@ func RunOffCgoStack[T any](fn func() (T, error)) (T, error) { ch <- result{val: v, err: err} }() r := <-ch - return r.val, r.err + return r.val, sanitizeForGomobile(r.err) +} + +func sanitizeForGomobile(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if !utf8.ValidString(msg) { + msg = strings.ToValidUTF8(msg, "?") + } + if msg == "" { + msg = "unknown error" + } + return errors.New(msg) } diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 3f6eb7a6ee..12fd7acaa8 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -8,6 +8,20 @@ class AppSetting { final bool successfulConnection; final String dataCapThreshold; final bool onboardingCompleted; + // Unbounded preferences. autoEnable: turn the peer share on whenever + // the VPN connects (defaults on per the Figma spec). hideTab: hide + // the Unbounded tab + collapse the tab bar when the user doesn't + // want to see it. welcomeSeen: tracks the first-visit info popup so + // we only show it once. All persisted across launches. + final bool unboundedAutoEnable; + final bool unboundedHidden; + final bool unboundedWelcomeSeen; + // Lifetime running total of peers this device has helped. Survives + // restarts so the "Total people helped to date" stat in the + // Unbounded tab can keep climbing — that's the spec wording in the + // Figma. ShareNotifier seeds totalCount from this on build, and + // writes back each time the count increments. + final int unboundedTotalHelped; const AppSetting({ this.themeMode = 'system', @@ -19,6 +33,10 @@ class AppSetting { this.successfulConnection = false, this.dataCapThreshold = '', this.onboardingCompleted = false, + this.unboundedAutoEnable = true, + this.unboundedHidden = false, + this.unboundedWelcomeSeen = false, + this.unboundedTotalHelped = 0, }); AppSetting copyWith({ @@ -31,6 +49,10 @@ class AppSetting { bool? successfulConnection, String? dataCapThreshold, bool? onboardingCompleted, + bool? unboundedAutoEnable, + bool? unboundedHidden, + bool? unboundedWelcomeSeen, + int? unboundedTotalHelped, }) { return AppSetting( locale: newLocale ?? locale, @@ -42,6 +64,11 @@ class AppSetting { successfulConnection: successfulConnection ?? this.successfulConnection, dataCapThreshold: dataCapThreshold ?? this.dataCapThreshold, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, + unboundedAutoEnable: unboundedAutoEnable ?? this.unboundedAutoEnable, + unboundedHidden: unboundedHidden ?? this.unboundedHidden, + unboundedWelcomeSeen: unboundedWelcomeSeen ?? this.unboundedWelcomeSeen, + unboundedTotalHelped: + unboundedTotalHelped ?? this.unboundedTotalHelped, ); } @@ -55,6 +82,10 @@ class AppSetting { 'successfulConnection': successfulConnection, 'dataCapThreshold': dataCapThreshold, 'onboardingCompleted': onboardingCompleted, + 'unboundedAutoEnable': unboundedAutoEnable, + 'unboundedHidden': unboundedHidden, + 'unboundedWelcomeSeen': unboundedWelcomeSeen, + 'unboundedTotalHelped': unboundedTotalHelped, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -67,5 +98,12 @@ class AppSetting { successfulConnection: json['successfulConnection'] == true, dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), onboardingCompleted: json['onboardingCompleted'] == true, + // Default to true when missing (first-time post-upgrade users + // should get the auto-enable behaviour the spec calls for). + unboundedAutoEnable: json['unboundedAutoEnable'] != false, + unboundedHidden: json['unboundedHidden'] == true, + unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, + unboundedTotalHelped: + (json['unboundedTotalHelped'] as num?)?.toInt() ?? 0, ); } diff --git a/lib/core/models/feature_flags.dart b/lib/core/models/feature_flags.dart index 91052949f7..70787331bb 100644 --- a/lib/core/models/feature_flags.dart +++ b/lib/core/models/feature_flags.dart @@ -3,7 +3,15 @@ enum FeatureFlag { metrics('otel.metrics'), traces('otel.traces'), autoUpdateEnabled('autoUpdateEnabled'), - androidSideloadAutoUpdateEnabled('androidSideloadAutoUpdateEnabled'); + androidSideloadAutoUpdateEnabled('androidSideloadAutoUpdateEnabled'), + // Server-side gate for the entire Unbounded / Share My Connection + // surface. When false (the default for censored regions), the + // Unbounded tab, settings entry, project link, and auto-enable hooks + // all disappear — censored users should never see a "share your + // connection" UI that could draw attention to them on-device. Mirrors + // radiance/unbounded/unbounded.go shouldRunUnbounded, which already + // gates execution on the same Features[UNBOUNDED] flag. + unbounded('unbounded'); final String key; diff --git a/lib/core/models/radiance_settings_state.dart b/lib/core/models/radiance_settings_state.dart index 70323e0a13..28026ff95a 100644 --- a/lib/core/models/radiance_settings_state.dart +++ b/lib/core/models/radiance_settings_state.dart @@ -12,12 +12,14 @@ class RadianceSettingsState { final RoutingMode routingMode; final bool splitTunneling; final bool telemetry; + final bool peerProxy; const RadianceSettingsState({ this.blockAds = false, this.routingMode = RoutingMode.full, this.splitTunneling = false, this.telemetry = false, + this.peerProxy = false, }); RadianceSettingsState copyWith({ @@ -25,12 +27,14 @@ class RadianceSettingsState { RoutingMode? routingMode, bool? splitTunneling, bool? telemetry, + bool? peerProxy, }) { return RadianceSettingsState( blockAds: blockAds ?? this.blockAds, routingMode: routingMode ?? this.routingMode, splitTunneling: splitTunneling ?? this.splitTunneling, telemetry: telemetry ?? this.telemetry, + peerProxy: peerProxy ?? this.peerProxy, ); } @@ -41,9 +45,10 @@ class RadianceSettingsState { blockAds == other.blockAds && routingMode == other.routingMode && splitTunneling == other.splitTunneling && - telemetry == other.telemetry; + telemetry == other.telemetry && + peerProxy == other.peerProxy; @override int get hashCode => - Object.hash(blockAds, routingMode, splitTunneling, telemetry); + Object.hash(blockAds, routingMode, splitTunneling, telemetry, peerProxy); } diff --git a/lib/core/models/unbounded_connection_event.dart b/lib/core/models/unbounded_connection_event.dart new file mode 100644 index 0000000000..bcd040c515 --- /dev/null +++ b/lib/core/models/unbounded_connection_event.dart @@ -0,0 +1,46 @@ +import 'package:flutter_earth_globe/globe_coordinates.dart'; + +/// Represents a consumer connection change from the broflake widget proxy. +class UnboundedConnectionEvent { + final int state; // 1 = connected, -1 = disconnected + final int workerIdx; + final String addr; // IP address + // Geo fields are populated by ShareNotifier after peer lookup. Empty on + // legacy events and on -1 frames (where only workerIdx matters for the + // globe to remove the arc). + final String countryName; + final String countryCode; + final String flagEmoji; + final GlobeCoordinates? coordinates; + // True for synthetic events the notifier emits to seed a newly-mounted + // globe with peers that connected before the screen opened. Lets the UI + // suppress the "new connection from " burst for replays. + final bool isReplay; + + UnboundedConnectionEvent({ + required this.state, + required this.workerIdx, + required this.addr, + this.countryName = '', + this.countryCode = '', + this.flagEmoji = '', + this.coordinates, + this.isReplay = false, + }); + + factory UnboundedConnectionEvent.fromJson(Map json) { + return UnboundedConnectionEvent( + state: json['state'] as int, + workerIdx: json['workerIdx'] as int, + addr: json['addr'] as String? ?? '', + ); + } +} + +/// Tracks live and cumulative connection counts for Unbounded. +class UnboundedStats { + final int activeCount; + final int totalCount; + + const UnboundedStats({this.activeCount = 0, this.totalCount = 0}); +} diff --git a/lib/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart new file mode 100644 index 0000000000..110aa38a8a --- /dev/null +++ b/lib/core/services/geo_lookup_service.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:flutter_earth_globe/globe_coordinates.dart'; +import 'package:http/http.dart' as http; + +/// Result of a peer geo lookup. Country/flag default to empty when the +/// lookup fails; coordinates default to a centre-of-the-globe sentinel. +class PeerGeo { + const PeerGeo({ + required this.coordinates, + required this.countryName, + required this.countryCode, + required this.flagEmoji, + }); + + final GlobeCoordinates coordinates; + final String countryName; + final String countryCode; + final String flagEmoji; + + static const unknown = PeerGeo( + coordinates: GlobeCoordinates(0, 0), + countryName: '', + countryCode: '', + flagEmoji: '', + ); +} + +class GeoLookupService { + static const _selfUrl = 'https://geo.getiantem.org'; + // ipwho.is: HTTPS, no auth, 10k req/month free. Returns country + lat/lon + // + flag emoji in one shot. + static const _peerUrl = 'https://ipwho.is'; + + // ISO country code → approximate centre coordinates. Used as a fallback + // when ipwho.is doesn't return city-level coords. + static const _countryCenters = { + 'AF': (lat: 33.0, lng: 65.0), + 'AL': (lat: 41.0, lng: 20.0), + 'DZ': (lat: 28.0, lng: 3.0), + 'AD': (lat: 42.5, lng: 1.6), + 'AO': (lat: -12.5, lng: 18.5), + 'AR': (lat: -34.0, lng: -64.0), + 'AM': (lat: 40.0, lng: 45.0), + 'AU': (lat: -27.0, lng: 133.0), + 'AT': (lat: 47.33, lng: 13.33), + 'AZ': (lat: 40.5, lng: 47.5), + 'BD': (lat: 24.0, lng: 90.0), + 'BY': (lat: 53.0, lng: 28.0), + 'BE': (lat: 50.83, lng: 4.0), + 'BJ': (lat: 9.5, lng: 2.25), + 'BO': (lat: -17.0, lng: -65.0), + 'BA': (lat: 44.0, lng: 18.0), + 'BR': (lat: -10.0, lng: -55.0), + 'BG': (lat: 43.0, lng: 25.0), + 'KH': (lat: 13.0, lng: 105.0), + 'CM': (lat: 6.0, lng: 12.0), + 'CA': (lat: 60.0, lng: -95.0), + 'CL': (lat: -30.0, lng: -71.0), + 'CN': (lat: 35.0, lng: 105.0), + 'CO': (lat: 4.0, lng: -72.0), + 'CD': (lat: 0.0, lng: 25.0), + 'CR': (lat: 10.0, lng: -84.0), + 'HR': (lat: 45.17, lng: 15.5), + 'CU': (lat: 21.5, lng: -80.0), + 'CZ': (lat: 49.75, lng: 15.5), + 'DK': (lat: 56.0, lng: 10.0), + 'DO': (lat: 19.0, lng: -70.67), + 'EC': (lat: -2.0, lng: -77.5), + 'EG': (lat: 27.0, lng: 30.0), + 'SV': (lat: 13.83, lng: -88.92), + 'EE': (lat: 59.0, lng: 26.0), + 'ET': (lat: 8.0, lng: 38.0), + 'FI': (lat: 64.0, lng: 26.0), + 'FR': (lat: 46.0, lng: 2.0), + 'GE': (lat: 42.0, lng: 43.5), + 'DE': (lat: 51.0, lng: 9.0), + 'GH': (lat: 8.0, lng: -2.0), + 'GR': (lat: 39.0, lng: 22.0), + 'GT': (lat: 15.5, lng: -90.25), + 'HN': (lat: 15.0, lng: -86.5), + 'HK': (lat: 22.25, lng: 114.17), + 'HU': (lat: 47.0, lng: 20.0), + 'IS': (lat: 65.0, lng: -18.0), + 'IN': (lat: 20.0, lng: 77.0), + 'ID': (lat: -5.0, lng: 120.0), + 'IR': (lat: 32.0, lng: 53.0), + 'IQ': (lat: 33.0, lng: 44.0), + 'IE': (lat: 53.0, lng: -8.0), + 'IL': (lat: 31.5, lng: 34.75), + 'IT': (lat: 42.83, lng: 12.83), + 'CI': (lat: 8.0, lng: -5.0), + 'JP': (lat: 36.0, lng: 138.0), + 'JO': (lat: 31.0, lng: 36.0), + 'KZ': (lat: 48.0, lng: 68.0), + 'KE': (lat: 1.0, lng: 38.0), + 'KR': (lat: 37.0, lng: 127.5), + 'KW': (lat: 29.34, lng: 47.66), + 'KG': (lat: 41.0, lng: 75.0), + 'LA': (lat: 18.0, lng: 105.0), + 'LV': (lat: 57.0, lng: 25.0), + 'LB': (lat: 33.83, lng: 35.83), + 'LT': (lat: 56.0, lng: 24.0), + 'MG': (lat: -20.0, lng: 47.0), + 'MY': (lat: 2.5, lng: 112.5), + 'ML': (lat: 17.0, lng: -4.0), + 'MX': (lat: 23.0, lng: -102.0), + 'MD': (lat: 47.0, lng: 29.0), + 'MN': (lat: 46.0, lng: 105.0), + 'MA': (lat: 32.0, lng: -5.0), + 'MZ': (lat: -18.25, lng: 35.0), + 'MM': (lat: 22.0, lng: 98.0), + 'NP': (lat: 28.0, lng: 84.0), + 'NL': (lat: 52.5, lng: 5.75), + 'NZ': (lat: -41.0, lng: 174.0), + 'NI': (lat: 13.0, lng: -85.0), + 'NG': (lat: 10.0, lng: 8.0), + 'NO': (lat: 62.0, lng: 10.0), + 'OM': (lat: 21.0, lng: 57.0), + 'PK': (lat: 30.0, lng: 70.0), + 'PA': (lat: 9.0, lng: -80.0), + 'PY': (lat: -23.0, lng: -58.0), + 'PE': (lat: -10.0, lng: -76.0), + 'PH': (lat: 13.0, lng: 122.0), + 'PL': (lat: 52.0, lng: 20.0), + 'PT': (lat: 39.5, lng: -8.0), + 'QA': (lat: 25.5, lng: 51.25), + 'RO': (lat: 46.0, lng: 25.0), + 'RU': (lat: 60.0, lng: 100.0), + 'SA': (lat: 25.0, lng: 45.0), + 'SN': (lat: 14.0, lng: -14.0), + 'RS': (lat: 44.0, lng: 21.0), + 'SG': (lat: 1.37, lng: 103.8), + 'SK': (lat: 48.67, lng: 19.5), + 'SI': (lat: 46.0, lng: 15.0), + 'ZA': (lat: -29.0, lng: 24.0), + 'ES': (lat: 40.0, lng: -4.0), + 'LK': (lat: 7.0, lng: 81.0), + 'SE': (lat: 62.0, lng: 15.0), + 'CH': (lat: 47.0, lng: 8.0), + 'SY': (lat: 35.0, lng: 38.0), + 'TW': (lat: 23.5, lng: 121.0), + 'TJ': (lat: 39.0, lng: 71.0), + 'TZ': (lat: -6.0, lng: 35.0), + 'TH': (lat: 15.0, lng: 100.0), + 'TN': (lat: 34.0, lng: 9.0), + 'TR': (lat: 39.0, lng: 35.0), + 'TM': (lat: 40.0, lng: 60.0), + 'UA': (lat: 49.0, lng: 32.0), + 'AE': (lat: 24.0, lng: 54.0), + 'GB': (lat: 54.0, lng: -2.0), + 'US': (lat: 38.0, lng: -97.0), + 'UY': (lat: -33.0, lng: -56.0), + 'UZ': (lat: 41.0, lng: 64.0), + 'VE': (lat: 8.0, lng: -66.0), + 'VN': (lat: 16.0, lng: 106.0), + 'YE': (lat: 15.0, lng: 48.0), + 'ZM': (lat: -15.0, lng: 30.0), + 'ZW': (lat: -20.0, lng: 30.0), + }; + + static GlobeCoordinates _isoToCoords(String iso) { + final c = _countryCenters[iso] ?? _countryCenters['US']!; + return GlobeCoordinates(c.lat, c.lng); + } + + /// Looks up the current device's location (no IP argument). + static Future selfLookup() async { + try { + final response = await http + .get(Uri.parse('$_selfUrl/')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final iso = + (data['Country'] as Map?)?['IsoCode'] as String? ?? + 'US'; + return _isoToCoords(iso); + } + } catch (_) {} + return _isoToCoords('US'); + } + + /// Looks up country, flag, and coordinates for a peer [ip] address. + /// Returns [PeerGeo.unknown] on any failure so callers can suppress the + /// arc / banner rather than displaying a wrong country. + static Future peerLookup(String ip) async { + try { + final response = await http + .get(Uri.parse('$_peerUrl/$ip')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) return PeerGeo.unknown; + final data = jsonDecode(response.body) as Map; + if (data['success'] != true) return PeerGeo.unknown; + final iso = (data['country_code'] as String?) ?? ''; + final lat = (data['latitude'] as num?)?.toDouble(); + final lng = (data['longitude'] as num?)?.toDouble(); + final coords = (lat != null && lng != null) + ? GlobeCoordinates(lat, lng) + : _isoToCoords(iso); + final flagObj = data['flag'] as Map?; + return PeerGeo( + coordinates: coords, + countryName: (data['country'] as String?) ?? '', + countryCode: iso, + flagEmoji: (flagObj?['emoji'] as String?) ?? '', + ); + } catch (_) {} + return PeerGeo.unknown; + } +} diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 961deb9022..ed0465d9b7 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -1,96 +1,96 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/utils/pro_utils.dart'; -import 'package:lantern/core/widgets/info_row.dart'; -import 'package:lantern/core/widgets/setting_tile.dart'; import 'package:lantern/features/home/provider/app_event_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/home/vpn_tab.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; -import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; -import 'package:lantern/features/vpn/vpn_status.dart'; -import 'package:lantern/features/vpn/vpn_switch.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; import '../../core/common/common.dart'; -enum _SettingTileType { smartLocation, splitTunneling, smartRouting } - +/// Root tab shell hosting the VPN and Unbounded tabs. Tab strip lives in +/// the AppBar so the chrome (Lantern logo + settings menu + account +/// actions) is shared across tabs and lines up with the Figma spec at +/// figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287. +/// +/// Tab labels carry a small dot indicator that turns green when the +/// matching feature is active (VPN: connected; Unbounded: peer share +/// on) and grey otherwise — also per spec. @RoutePage(name: 'Home') -class Home extends StatefulHookConsumerWidget { +class Home extends HookConsumerWidget { const Home({super.key}); @override - ConsumerState createState() => _HomeState(); -} - -class _HomeState extends ConsumerState { - TextTheme? textTheme; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - /// Kick off the server fetch as soon as Home mounts so the Smart - /// Location tile can reflect the fastest server without waiting for - /// the user to open the server-selection screen. - ref.read(availableServersProvider); - - final appSetting = ref.read(appSettingProvider); - final appSettingNotifier = ref.read(appSettingProvider.notifier); - if (!appSetting.onboardingCompleted) { - appLogger.info( - "User has not completed onboarding, navigating to Onboarding Screen", - ); - appRouter.push(const Onboarding()); - return; - } - - if (PlatformUtils.isMacOS) { - /// Show macOS system extension dialog if needed - appLogger.info( - "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", - ); - if (appSetting.showSplashScreen) { - appLogger.info("Showing System Extension Dialog"); - appRouter.push(const MacOSExtensionDialog()); - //User has seen dialog, do not show again - appLogger.info("Setting showSplashScreen to false"); - appSettingNotifier.setSplashScreen(false); - return; - } - } - }); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final tabController = useTabController(initialLength: 2); final isUserPro = ref.watch(isUserProProvider); - final featureFlag = ref.watch(featureFlagProvider); final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), ); + final unboundedHidden = ref.watch( + appSettingProvider.select((s) => s.unboundedHidden), + ); + final featureFlag = ref.watch(featureFlagProvider); + // Server-side gate for the whole Unbounded UI surface. Censored + // regions get the flag off, so the tab, strip, and any auto-enable + // hook disappear. The user's own "Hide Unbounded tab" toggle still + // wins on top of this for non-censored users who want it hidden. + final unboundedAvailable = featureFlag.getBool(FeatureFlag.unbounded); + final showUnboundedTab = unboundedAvailable && !unboundedHidden; + final vpnStatus = ref.watch(vpnProvider); + final shareActive = ref.watch(shareProvider.select((s) => s.active)); + + // First-frame side effects: kick off server fetch, gate onboarding, + // macOS sysext dialog. Lifted unchanged from the old Home body so + // app-launch behaviour stays the same after the tab refactor. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(availableServersProvider); + final appSetting = ref.read(appSettingProvider); + final appSettingNotifier = ref.read(appSettingProvider.notifier); + if (!appSetting.onboardingCompleted) { + appLogger.info( + "User has not completed onboarding, navigating to Onboarding Screen", + ); + appRouter.push(const Onboarding()); + return; + } + if (PlatformUtils.isMacOS) { + appLogger.info( + "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", + ); + if (appSetting.showSplashScreen) { + appLogger.info("Showing System Extension Dialog"); + appRouter.push(const MacOSExtensionDialog()); + appLogger.info("Setting showSplashScreen to false"); + appSettingNotifier.setSplashScreen(false); + } + } + }); + return null; + }, const []); + + // Telemetry consent dialog — fires once per app session after the + // first successful connection, gated on the metrics + traces + // feature flags. Preserved from the old Home behaviour. useEffect(() { final appSetting = ref.read(appSettingProvider); if (appSetting.successfulConnection) { - appLogger.info( - "User has successfully connected, checking if need to show Help Lantern Dialog or not", - ); if (!appSetting.telemetryDialogDismissed && (featureFlag.getBool(FeatureFlag.metrics) && featureFlag.getBool(FeatureFlag.traces))) { - appLogger.info("Showing Help Lantern Dialog"); WidgetsBinding.instance.addPostFrameCallback((_) { - showHelpLanternDialog(); + _showHelpLanternDialog(context, ref); ref.read(appSettingProvider.notifier).setShowTelemetryDialog(true); }); } @@ -98,21 +98,55 @@ class _HomeState extends ConsumerState { return null; }, [featureFlag]); - textTheme = Theme.of(context).textTheme; ref.read(appEventProvider); + + // Auto-enable Unbounded — gated on the "Auto-enable Unbounded" + // toggle from Unbounded Settings (default ON). Fires from two + // entry points so the spec's subtitle "Turn on automatically when + // Lantern is open" is honoured whether the user connects the VPN + // or not: + // 1. App launch (useEffect below) — once on Home mount. + // 2. VPN connect (ref.listen further down) — on every + // disconnected → connected transition, in case the toggle + // flipped on after launch or the user finally connects. + // Both paths gate on (active || probing) to avoid re-triggering + // while a Start is in flight, and skip the disclosure dialog + // because the user has already opted in via settings. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!unboundedAvailable) return; + final appSetting = ref.read(appSettingProvider); + if (!appSetting.onboardingCompleted) return; + if (!appSetting.unboundedAutoEnable) return; + final share = ref.read(shareProvider); + if (share.active || share.probing) return; + ref.read(shareProvider.notifier).autoStart(ref); + }); + return null; + }, [unboundedAvailable]); + + ref.listen(vpnProvider, (prev, next) { + if (prev == next) return; + if (next != VPNStatus.connected) return; + if (!unboundedAvailable) return; + final autoEnable = + ref.read(appSettingProvider).unboundedAutoEnable; + if (!autoEnable) return; + final share = ref.read(shareProvider); + if (share.active || share.probing) return; + // Defer to avoid mutating provider state inside the listen callback. + Future.microtask( + () => ref.read(shareProvider.notifier).autoStart(ref), + ); + }); + return Scaffold( key: const Key('home.screen'), appBar: AppBar( title: LanternLogo(isPro: isUserPro, color: context.textPrimary), - bottom: PreferredSize( - preferredSize: Size.fromHeight(0), - child: DividerSpace(padding: EdgeInsets.zero), - ), elevation: 5, leading: IconButton( - onPressed: () { - appRouter.push(Setting()); - }, + onPressed: () => appRouter.push(Setting()), icon: const AppImage(path: AppImagePaths.menu), ), actions: [ @@ -125,209 +159,130 @@ class _HomeState extends ConsumerState { final email = localUser!.legacyUserData.email; final isPro = localUser.legacyUserData.isPro; if (isPro && !userSignedIn) { - // this means user has pro account but not signed in await showProAccountFlowDialog( context: context, hasEmail: email.isNotEmpty, ); return; } - appRouter.push(Account()); }, ) else if (!userLoggedIn) AppTextButton( label: 'sign_in'.i18n, - onPressed: () { - appRouter.push(const SignInEmail()); - }, + onPressed: () => appRouter.push(const SignInEmail()), ), ], + // Tab strip collapses when Unbounded is unavailable — either the + // server flag is off (censored region) or the user hid the tab + // in Unbounded Settings. With only one tab left, a strip is just + // noise; body falls back to VpnTab directly. + bottom: !showUnboundedTab + ? null + : TabBar( + controller: tabController, + tabs: [ + _TabLabel( + label: 'VPN', + active: vpnStatus == VPNStatus.connected), + _TabLabel(label: 'Unbounded', active: shareActive), + ], + ), ), - body: SafeArea(child: _buildBody(ref, isUserPro)), + body: !showUnboundedTab + ? const VpnTab() + : TabBarView( + controller: tabController, + children: const [ + VpnTab(), + UnboundedTab(), + ], + ), ); } +} - Widget _buildBody(WidgetRef ref, bool isUserPro) { - final serverLocation = ref.watch(serverLocationProvider); +/// Tab label with the green/grey status dot from the Figma spec. +class _TabLabel extends StatelessWidget { + const _TabLabel({required this.label, required this.active}); - final serverType = serverLocation.serverType.toServerLocationType; + final String label; + final bool active; - return Padding( - padding: EdgeInsets.symmetric(horizontal: defaultSize), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (isUserPro) SizedBox(height: 0) else ProBanner(), - VPNSwitch(), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isUserPro) ...{ - if (serverType == ServerLocationType.privateServer) - InfoRow(text: 'private_server_usage_message'.i18n) - else if (PlatformUtils.isIOS) - const SizedBox.shrink() - else - const DataUsage(), - }, - SizedBox(height: 8), - _buildSetting(ref), - SizedBox(height: 10.h), - ], + @override + Widget build(BuildContext context) { + return Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.green4 : context.textDisabled, + ), ), + const SizedBox(width: 6), + Text(label), ], ), ); } +} - Widget _buildSetting(WidgetRef ref) { - final routingMode = ref.watch( - radianceSettingsProvider.select((s) => s.routingMode), - ); - final isSplitTunnelingOn = ref.watch( - radianceSettingsProvider.select((s) => s.splitTunneling), - ); - - return Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: AppColors.shadowColor, - blurRadius: 32, - offset: Offset(0, 4), - spreadRadius: 0, - ), - ], - ), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - child: Column( - children: [ - VpnStatus(), - DividerSpace(), - LocationSetting(), - if (!PlatformUtils.isIOS) ...{ - DividerSpace(), - SettingTile( - label: 'routing_mode'.i18n, - icon: AppImagePaths.route, - value: routingMode.label(), - actions: [ - IconButton( - onPressed: null, - style: ElevatedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: AppImage(path: AppImagePaths.arrowForward), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - visualDensity: VisualDensity.compact, - ), - ], - onTap: () => onSettingTileTap(_SettingTileType.smartRouting), - ), - }, - if (PlatformUtils.isAndroid || - PlatformUtils.isMacOS || - PlatformUtils.isWindows) ...{ - DividerSpace(), - SettingTile( - label: 'split_tunneling'.i18n, - icon: AppImagePaths.callSpilt, - value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, - actions: [ - IconButton( - onPressed: null, - style: ElevatedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: AppImage(path: AppImagePaths.arrowForward), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - visualDensity: VisualDensity.compact, - ), - ], - onTap: () => onSettingTileTap(_SettingTileType.splitTunneling), - ), - }, - ], +void _showHelpLanternDialog(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + AppDialog.customDialog( + context: context, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + const AppImage(path: AppImagePaths.assessment), + const SizedBox(height: 24), + Text( + 'help_improve_lantern'.i18n, + style: textTheme.headlineSmall!.copyWith(color: context.textPrimary), ), - ), - ); - } - - void onSettingTileTap(_SettingTileType tileType) { - switch (tileType) { - case _SettingTileType.smartLocation: - appRouter.push(const ServerSelection()); - break; - case _SettingTileType.splitTunneling: - appRouter.push(const SplitTunneling()); - break; - case _SettingTileType.smartRouting: - appRouter.push(const SmartRouting()); - } - } - - void showHelpLanternDialog() { - AppDialog.customDialog( - context: context, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 24), - AppImage(path: AppImagePaths.assessment), - SizedBox(height: 24), - Text( - 'help_improve_lantern'.i18n, - style: textTheme!.headlineSmall!.copyWith( - color: context.textPrimary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'share_anonymous_usage_data'.i18n, - style: textTheme!.bodyMedium!.copyWith( - color: context.textSecondary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'data_we_collect'.i18n, - style: AppTextStyles.bodyMediumBold.copyWith( - color: context.textSecondary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'you_can_change_anytime'.i18n, - style: textTheme!.bodyMedium!.copyWith( - color: context.textSecondary, - ), + SizedBox(height: defaultSize), + Text( + 'share_anonymous_usage_data'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), + ), + SizedBox(height: defaultSize), + Text( + 'data_we_collect'.i18n, + style: AppTextStyles.bodyMediumBold.copyWith( + color: context.textSecondary, ), - ], - ), - action: [ - AppTextButton( - label: 'dont_allow'.i18n, - textColor: context.textDisabled, - onPressed: () { - context.pop(); - ref.read(radianceSettingsProvider.notifier).setTelemetry(false); - }, ), - AppTextButton( - label: 'allow'.i18n, - textColor: AppColors.blue6, - onPressed: () { - context.pop(); - ref.read(radianceSettingsProvider.notifier).setTelemetry(true); - }, + SizedBox(height: defaultSize), + Text( + 'you_can_change_anytime'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), ), ], - ); - } + ), + action: [ + AppTextButton( + label: 'dont_allow'.i18n, + textColor: context.textDisabled, + onPressed: () { + context.pop(); + ref.read(radianceSettingsProvider.notifier).setTelemetry(false); + }, + ), + AppTextButton( + label: 'allow'.i18n, + textColor: AppColors.blue6, + onPressed: () { + context.pop(); + ref.read(radianceSettingsProvider.notifier).setTelemetry(true); + }, + ), + ], + ); } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 97af2d8365..ff9f232047 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -105,6 +105,18 @@ class AppSettingNotifier extends _$AppSettingNotifier { if (value) unawaited(_writeInitMarker()); } + void setUnboundedAutoEnable(bool value) => + update(state.copyWith(unboundedAutoEnable: value)); + + void setUnboundedHidden(bool value) => + update(state.copyWith(unboundedHidden: value)); + + void setUnboundedWelcomeSeen(bool value) => + update(state.copyWith(unboundedWelcomeSeen: value)); + + void setUnboundedTotalHelped(int value) => + update(state.copyWith(unboundedTotalHelped: value)); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index ae9cee0f6d..be8900aede 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -29,16 +29,23 @@ class RadianceSettings extends _$RadianceSettings { final routingF = svc.isSmartRoutingEnabled(); final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); + // Peer-proxy probe runs only on platforms with native handlers + // (Windows + Linux via FFI, macOS via MethodChannel — i.e. all desktop). + // On iOS / Android the call would fail with MissingPluginException on + // every settings init. + final peerF = PlatformUtils.isDesktop ? svc.isPeerProxyEnabled() : null; final results = await Future.wait([ blockAdsF, routingF, telemetryF, ?splitF, + ?peerF, ]); if (!ref.mounted) return; const defaults = RadianceSettingsState(); + final peerIdx = 3 + (splitF == null ? 0 : 1); state = RadianceSettingsState( blockAds: results[0].fold((_) => defaults.blockAds, (v) => v), routingMode: results[1].fold( @@ -49,6 +56,9 @@ class RadianceSettings extends _$RadianceSettings { splitTunneling: splitF == null ? defaults.splitTunneling : results[3].fold((_) => defaults.splitTunneling, (v) => v), + peerProxy: peerF == null + ? defaults.peerProxy + : results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), ); } @@ -97,6 +107,16 @@ class RadianceSettings extends _$RadianceSettings { (_) => state = state.copyWith(telemetry: consent), ); } + + Future setPeerProxy(bool value) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.setPeerProxyEnabled(value); + if (!ref.mounted) return; + result.fold( + (err) => appLogger.error('setPeerProxyEnabled failed: ${err.error}'), + (_) => state = state.copyWith(peerProxy: value), + ); + } } /// Fetches whether user logged in via OAuth from radiance. diff --git a/lib/features/home/vpn_tab.dart b/lib/features/home/vpn_tab.dart new file mode 100644 index 0000000000..68302d04e1 --- /dev/null +++ b/lib/features/home/vpn_tab.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/info_row.dart'; +import 'package:lantern/core/widgets/setting_tile.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; +import 'package:lantern/features/vpn/vpn_status.dart'; +import 'package:lantern/features/vpn/vpn_switch.dart'; + +import '../../core/common/common.dart'; + +/// VPN tab body — the connect toggle, data usage, location, routing and +/// split-tunnel rows. Originally the body of the Home screen; lifted out +/// when Home was refactored into a two-tab shell (VPN + Unbounded). No +/// Scaffold or AppBar — the shell provides that chrome. +class VpnTab extends ConsumerWidget { + const VpnTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isUserPro = ref.watch(isUserProProvider); + final serverLocation = ref.watch(serverLocationProvider); + final serverType = serverLocation.serverType.toServerLocationType; + + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: defaultSize), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (isUserPro) const SizedBox(height: 0) else const ProBanner(), + const VPNSwitch(), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isUserPro) ...{ + if (serverType == ServerLocationType.privateServer) + InfoRow(text: 'private_server_usage_message'.i18n) + else if (PlatformUtils.isIOS) + const SizedBox.shrink() + else + const DataUsage(), + }, + const SizedBox(height: 8), + _SettingCard(), + SizedBox(height: 10.h), + ], + ), + ], + ), + ), + ); + } +} + +class _SettingCard extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final routingMode = ref.watch( + radianceSettingsProvider.select((s) => s.routingMode), + ); + final isSplitTunnelingOn = ref.watch( + radianceSettingsProvider.select((s) => s.splitTunneling), + ); + + return Container( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.shadowColor, + blurRadius: 32, + offset: Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: Card( + elevation: 0, + margin: EdgeInsets.zero, + child: Column( + children: [ + const VpnStatus(), + const DividerSpace(), + const LocationSetting(), + if (!PlatformUtils.isIOS) ...{ + const DividerSpace(), + SettingTile( + label: 'routing_mode'.i18n, + icon: AppImagePaths.route, + value: routingMode.label(), + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SmartRouting()), + ), + }, + if (PlatformUtils.isAndroid || + PlatformUtils.isMacOS || + PlatformUtils.isWindows) ...{ + const DividerSpace(), + SettingTile( + label: 'split_tunneling'.i18n, + icon: AppImagePaths.callSpilt, + value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SplitTunneling()), + ), + }, + ], + ), + ), + ); + } +} diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 027eb40279..1b9b097521 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -7,11 +7,14 @@ import 'package:lantern/core/localization/localization_constants.dart'; import 'package:lantern/core/updater/updater.dart'; import 'package:lantern/core/utils/pro_utils.dart'; import 'package:lantern/core/widgets/subscription_tags.dart'; +import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; +import 'package:lantern/features/setting/unbounded_setting.dart'; import '../../core/services/injection_container.dart'; @@ -19,6 +22,7 @@ enum _SettingType { account, signIn, vpnSetting, + unboundedSetting, language, appearance, support, @@ -61,6 +65,11 @@ class _SettingState extends ConsumerState final email = ref.watch(userEmailProvider); final appSetting = ref.watch(appSettingProvider); + // Server-side gate. Censored regions get Features[unbounded]=false, + // and every Unbounded-flavoured row in this menu (the settings sub- + // page link AND the project promo card at the bottom) disappears. + final unboundedAvailable = + ref.watch(featureFlagProvider).getBool(FeatureFlag.unbounded); final hasProSession = (user?.legacyUserData.isPro ?? false) && @@ -142,6 +151,15 @@ class _SettingState extends ConsumerState icon: AppImagePaths.glob, onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), + if (unboundedAvailable) ...[ + DividerSpace(), + AppTile( + label: 'Unbounded Settings', + icon: AppImagePaths.share, + onPressed: () => + settingMenuTap(_SettingType.unboundedSetting), + ), + ], DividerSpace(), AppTile( label: 'language'.i18n, @@ -233,36 +251,38 @@ class _SettingState extends ConsumerState ), ), }, - const SizedBox(height: defaultSize), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - 'lantern_projects'.i18n, - style: textTheme.labelLarge!.copyWith( - color: context.textSecondary, + if (unboundedAvailable) ...[ + const SizedBox(height: defaultSize), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + 'lantern_projects'.i18n, + style: textTheme.labelLarge!.copyWith( + color: context.textSecondary, + ), ), ), - ), - const SizedBox(height: 4), - Card( - child: AppTile( - minHeight: 72, - icon: AppImagePaths.lanternLogoRounded, - iconUseThemeColor: false, - trailing: AppImage(path: AppImagePaths.outsideBrowser), - label: 'unbounded'.i18n, - subtitle: Text( - 'help_fight_global_internet_censorship'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, + const SizedBox(height: 4), + Card( + child: AppTile( + minHeight: 72, + icon: AppImagePaths.lanternLogoRounded, + iconUseThemeColor: false, + trailing: AppImage(path: AppImagePaths.outsideBrowser), + label: 'unbounded'.i18n, + subtitle: Text( + 'help_fight_global_internet_censorship'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + ), ), + onPressed: () { + UrlUtils.openUrl(AppUrls.unbounded); + }, ), - onPressed: () { - UrlUtils.openUrl(AppUrls.unbounded); - }, ), - ), - SizedBox(height: defaultSize), + SizedBox(height: defaultSize), + ], ], ), ); @@ -317,6 +337,11 @@ class _SettingState extends ConsumerState case _SettingType.vpnSetting: appRouter.push(VPNSetting()); break; + case _SettingType.unboundedSetting: + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UnboundedSetting()), + ); + break; case _SettingType.browserUnbounded: // TODO: Handle this case. throw UnimplementedError(); diff --git a/lib/features/setting/unbounded_setting.dart b/lib/features/setting/unbounded_setting.dart new file mode 100644 index 0000000000..559b85d8e3 --- /dev/null +++ b/lib/features/setting/unbounded_setting.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; + +import '../../core/common/common.dart'; + +/// Unbounded Settings sheet, reached from the main Settings menu. Two +/// toggles per the Figma spec +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287): +/// +/// 1. Auto-enable Unbounded — turn Unbounded on automatically when +/// Lantern (VPN) is connected. The actual auto-enable wiring lives +/// in the Home shell (or a VPN-status listener) and reads this flag. +/// 2. Hide Unbounded — collapse the Unbounded tab in the Home shell +/// when the user doesn't want to see it. With only the VPN tab +/// left, Home hides the tab strip entirely. +class UnboundedSetting extends ConsumerWidget { + const UnboundedSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final autoEnable = ref.watch( + appSettingProvider.select((s) => s.unboundedAutoEnable), + ); + final hidden = ref.watch( + appSettingProvider.select((s) => s.unboundedHidden), + ); + final notifier = ref.read(appSettingProvider.notifier); + final textTheme = Theme.of(context).textTheme; + + return BaseScreen( + title: 'Unbounded Settings', + body: ListView( + children: [ + const SizedBox(height: 8), + AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + AppTile( + label: 'Auto-enable Unbounded', + subtitle: Text( + 'Turn on automatically when Lantern is open', + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: AppImagePaths.share, + trailing: SwitchButton( + value: autoEnable, + onChanged: notifier.setUnboundedAutoEnable, + ), + onPressed: () => + notifier.setUnboundedAutoEnable(!autoEnable), + ), + DividerSpace(), + AppTile( + label: 'Hide Unbounded', + subtitle: Text( + 'Removes Unbounded from the top of this screen', + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: const Icon(Icons.visibility_off_outlined), + trailing: SwitchButton( + value: hidden, + onChanged: notifier.setUnboundedHidden, + ), + onPressed: () => notifier.setUnboundedHidden(!hidden), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 8c108473a9..07cc0cf7e0 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -35,7 +35,6 @@ class VPNSetting extends HookConsumerWidget { final telemetryConsent = ref.watch( radianceSettingsProvider.select((s) => s.telemetry), ); - return ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, @@ -117,6 +116,10 @@ class VPNSetting extends HookConsumerWidget { }, ), ), + // The "Share My Connection" entry that used to push a SmC + // screen from here moved to a top-level Unbounded tab in the + // Home shell — see lib/features/home/home.dart. Toggling peer + // share now happens inside that tab. SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart new file mode 100644 index 0000000000..3a175dc52a --- /dev/null +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -0,0 +1,1591 @@ +// Share My Connection — unified screen for both Unbounded and the +// samizdat-over-UPnP "Share My Connection" modes: +// - Toggle ON triggers a (mocked) UPnP probe. +// - If UPnP works AND the user accepts the SmC disclosure, run SmC mode +// (calls into radiance via the existing radianceSettingsProvider +// setPeerProxy path). +// - Otherwise fall back to Unbounded mode (UI-only for now; broflake +// wire-up follows once radiance#336 lands). +// - Globe animates connection arcs from peer-connection FlutterEvents +// streamed up from radiance. +// +// UPnP probe is still mocked (a coin-flip) until the FFI binding lands; +// SmC mode is real — flipping the toggle starts the radiance peer +// module on this branch. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_earth_globe/flutter_earth_globe.dart'; +import 'package:flutter_earth_globe/flutter_earth_globe_controller.dart'; +import 'package:flutter_earth_globe/globe_coordinates.dart'; +import 'package:flutter_earth_globe/point.dart'; +import 'package:flutter_earth_globe/point_connection.dart'; +import 'package:flutter_earth_globe/point_connection_style.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lottie/lottie.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/unbounded_connection_event.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/core/services/geo_lookup_service.dart'; +import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/lantern/lantern_service_notifier.dart'; + +// ─── State ─────────────────────────────────────────────────────────────────── + +/// Which underlying protocol the user is contributing through. +/// +/// off — toggle is off / probe in flight +/// unbounded — broflake / WebRTC widget proxy (works on any network) +/// smc — samizdat-over-UPnP "Share My Connection" (higher capability, +/// higher risk; gated on a one-time disclosure) +enum ShareMode { off, unbounded, smc } + +/// Lifecycle phase for SmC mode, sourced from radiance peer.Status.Phase +/// via the `peer-status` FlutterEvent. Stable strings — must stay in +/// sync with radiance/peer/peer.go's Phase constants. +/// +/// idle — nothing running +/// mappingPort — UPnP / manual port mapping in flight +/// detectingIp — public-IP detection +/// registering — POST /v1/peer/register against lantern-cloud +/// startingProxy — libbox samizdat inbound coming up +/// verifying — POST /v1/peer/verify, lantern-cloud is dialing back +/// serving — peer is live and assignable to censored clients +/// stopping — teardown in progress +/// error — Start failed; SharePhase.errorMessage holds the cause +enum SharePhase { + idle, + mappingPort, + detectingIp, + registering, + startingProxy, + verifying, + serving, + stopping, + error; + + static SharePhase fromWire(String? s) => switch (s) { + 'mapping_port' => SharePhase.mappingPort, + 'detecting_ip' => SharePhase.detectingIp, + 'registering' => SharePhase.registering, + 'starting_proxy' => SharePhase.startingProxy, + 'verifying' => SharePhase.verifying, + 'serving' => SharePhase.serving, + 'stopping' => SharePhase.stopping, + 'error' => SharePhase.error, + _ => SharePhase.idle, + }; +} + +class ShareState { + final bool active; + final bool probing; + final ShareMode mode; + final int activeCount; + final int totalCount; + // SmC-only: granular Start/Stop phase from radiance peer.Status. For + // Unbounded mode this stays SharePhase.idle (no equivalent staged + // lifecycle on the broflake side yet). + final SharePhase phase; + final String? errorMessage; + + const ShareState({ + this.active = false, + this.probing = false, + this.mode = ShareMode.off, + this.activeCount = 0, + this.totalCount = 0, + this.phase = SharePhase.idle, + this.errorMessage, + }); + + ShareState copyWith({ + bool? active, + bool? probing, + ShareMode? mode, + int? activeCount, + int? totalCount, + SharePhase? phase, + String? errorMessage, + }) => + ShareState( + active: active ?? this.active, + probing: probing ?? this.probing, + mode: mode ?? this.mode, + activeCount: activeCount ?? this.activeCount, + totalCount: totalCount ?? this.totalCount, + phase: phase ?? this.phase, + errorMessage: errorMessage ?? this.errorMessage, + ); +} + +// ─── Notifier (mock-backed) ────────────────────────────────────────────────── + +class _PeerArc { + _PeerArc(this.workerIdx) : streamCount = 1; + final int workerIdx; + int streamCount; + // Geo is resolved async after the first +1 lands. Until then the peer is + // tracked but no arc is emitted — avoids a flash of "unknown" arcs. + PeerGeo? geo; +} + +class ShareNotifier extends Notifier { + // Persisted in real impl; in-process for the prototype so the disclosure + // re-fires on app restart and is easy to demo. + bool _smcAck = false; + + StreamSubscription? _appEventSub; + int _workerSeq = 0; + // Per-peer arc + active-stream count. samizdat multiplexes many H2 streams + // over one TCP conn, all sharing the same RemoteAddr — ref-count so the arc + // persists until the peer's LAST stream closes, not its first. + final Map _peerArcs = {}; + + final _eventController = + StreamController.broadcast(); + Stream get connectionEvents => + _eventController.stream; + + @override + ShareState build() { + ref.onDispose(() { + _stopEventSubscription(); + _eventController.close(); + }); + // Seed totalCount from the persisted lifetime running total so the + // "Total people helped to date" stat survives app restarts. New + // arrivals (line further down) increment both ShareState.totalCount + // and the persisted value via setUnboundedTotalHelped. + final persistedTotal = + ref.read(appSettingProvider).unboundedTotalHelped; + return ShareState(totalCount: persistedTotal); + } + + /// Toggle entry point. Caller passes its BuildContext so we can show the + /// disclosure modal inline, and a WidgetRef so we can drive the radiance + /// peer-share toggle. + /// + /// Resolution order on enable: + /// 1. If the user has set a manual port in Advanced settings, that + /// is an explicit opt-in — go straight to SmC mode. No UPnP + /// probe, no disclosure (user already crossed that line by + /// configuring the port forward on their router). + /// 2. Otherwise probe UPnP. If UPnP works AND the user accepts + /// the SmC disclosure, run SmC. Decline → Unbounded. + /// 3. UPnP unavailable → Unbounded fallback. + Future toggle(BuildContext context, WidgetRef widgetRef) async { + if (state.active || state.probing) { + await _stop(widgetRef); + return; + } + + state = state.copyWith(probing: true); + + // Manual port forward bypasses both the UPnP probe and the SmC + // disclosure dialog. Configuring a port in Advanced is an explicit + // user-driven SmC opt-in — they wouldn't have set it up if they + // weren't sure they wanted to share via the residential-IP path. + final manualPortRes = + await widgetRef.read(lanternServiceProvider).getPeerManualPort(); + final manualPort = manualPortRes.fold((_) => 0, (p) => p); + if (manualPort > 0) { + await _start(widgetRef, ShareMode.smc); + return; + } + + // MOCK: real UPnP probe via FFI is not yet wired; coin-flip the + // result so the demo exercises both paths across runs without a + // manual port set. + await Future.delayed(const Duration(milliseconds: 1500)); + final upnpAvailable = Random().nextBool(); + if (!upnpAvailable) { + await _start(widgetRef, ShareMode.unbounded); + return; + } + + if (_smcAck) { + await _start(widgetRef, ShareMode.smc); + return; + } + + if (!context.mounted) { + state = state.copyWith(probing: false); + return; + } + + final accepted = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const SmcDisclosureDialog(), + ); + + if (accepted == null) { + // User dismissed without choosing — leave off. + state = state.copyWith(probing: false); + return; + } + if (accepted) { + _smcAck = true; + await _start(widgetRef, ShareMode.smc); + } else { + await _start(widgetRef, ShareMode.unbounded); + } + } + + /// Programmatic entry point used by the Home shell's auto-enable + /// listener (VPN-connected → Unbounded on). Mirrors `toggle()` but + /// skips the disclosure dialog because the user has already opted + /// in via the Unbounded Settings sheet. No-ops if already active or + /// in flight. + Future autoStart(WidgetRef widgetRef) async { + if (state.active || state.probing) return; + state = state.copyWith(probing: true); + final manualPortRes = + await widgetRef.read(lanternServiceProvider).getPeerManualPort(); + final manualPort = manualPortRes.fold((_) => 0, (p) => p); + if (manualPort > 0) { + await _start(widgetRef, ShareMode.smc); + return; + } + // MOCK UPnP probe — same as toggle(), pending real FFI. + await Future.delayed(const Duration(milliseconds: 1500)); + final upnpAvailable = Random().nextBool(); + await _start( + widgetRef, + upnpAvailable ? ShareMode.smc : ShareMode.unbounded, + ); + } + + Future _start(WidgetRef widgetRef, ShareMode mode) async { + state = ShareState( + active: true, + probing: false, + mode: mode, + activeCount: 0, + // Preserve the running total across off→on cycles so toggling + // doesn't reset the user's lifetime count. + totalCount: state.totalCount, + ); + _startEventSubscription(widgetRef); + switch (mode) { + case ShareMode.smc: + // Flip the radiance peer-proxy setting; LocalBackend.PatchSettings + // routes that into peer.Client.Start, which spins up the UPnP map + // (or honours PeerManualPortKey), registers with lantern-cloud, + // runs the samizdat inbound, and (via the lantern-box peerconn + // listener) emits ConnectionEvents that ride the radiance event + // bus → core.go listenPeerConnectionEvents → FlutterEvent → our + // Dart subscription. + await widgetRef + .read(radianceSettingsProvider.notifier) + .setPeerProxy(true); + break; + case ShareMode.unbounded: + // Unbounded is the broflake / WebRTC widget-proxy mode. Local + // opt-in only — actual run state also depends on the server's + // Features[unbounded] flag and supplied UnboundedConfig (see + // radiance/unbounded/unbounded.go shouldRunUnbounded). When + // running, broflake's OnConnectionChange callback emits + // unbounded.ConnectionEvent → forwarded by lantern-core as the + // same EventTypePeerConnection FlutterEvent the SmC path uses, + // so this Dart subscription consumes both protocols uniformly. + await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(true); + break; + case ShareMode.off: + break; + } + } + + Future _stop(WidgetRef widgetRef) async { + _stopEventSubscription(); + final priorMode = state.mode; + // Preserve totalCount across toggle-off (same reason as _start — + // user's lifetime count shouldn't reset on a toggle cycle). + state = ShareState(totalCount: state.totalCount); + switch (priorMode) { + case ShareMode.smc: + await widgetRef + .read(radianceSettingsProvider.notifier) + .setPeerProxy(false); + break; + case ShareMode.unbounded: + await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(false); + break; + case ShareMode.off: + break; + } + } + + // ── Live connection event source ─────────────────────────────────────────── + // Subscribes to the existing FFI app-event stream (the same one + // AppEventNotifier uses for config / server-location / data-cap events) + // and filters for type=='peer-connection'. Each event's message is + // {state: +1|-1, source: "ip:port"} originally emitted from the + // lantern-box samizdat inbound via the peerconn listener registry, then + // rebroadcast by lantern-core/core.go listenPeerConnectionEvents. + // + // No local sockets, no fixed ports — the bridge rides on Dart api_dl, + // which is the same channel server-location updates and data-cap events + // already use. + + void _startEventSubscription(WidgetRef widgetRef) { + _peerArcs.clear(); + _appEventSub = widgetRef + .read(lanternServiceProvider) + .watchAppEvents() + .listen((event) { + if (event.eventType == 'peer-status') { + _handlePeerStatus(event.message, widgetRef); + return; + } + if (event.eventType != 'peer-connection') return; + try { + final payload = jsonDecode(event.message) as Map; + final eventState = (payload['state'] as num?)?.toInt() ?? 0; + final source = (payload['source'] as String?) ?? ''; + // Globe only cares about the IP — strip ":port". + final ip = source.split(':').first; + if (ip.isEmpty) return; + + if (eventState == 1) { + final existing = _peerArcs[ip]; + if (existing != null) { + existing.streamCount++; + return; + } + final widx = _workerSeq++; + final arc = _PeerArc(widx); + _peerArcs[ip] = arc; + final newTotal = state.totalCount + 1; + state = state.copyWith( + activeCount: state.activeCount + 1, + totalCount: newTotal, + ); + // Persist so the "Total people helped to date" stat + // survives restarts. Write happens per-arrival, but arrivals + // are bursty rather than continuous so SharedPreferences I/O + // pressure is fine. + ref + .read(appSettingProvider.notifier) + .setUnboundedTotalHelped(newTotal); + // Resolve country async. Emit the +1 only after lookup so the + // globe can render the arc at the right coords and the UI can + // surface the country name in the connection banner. + unawaited(_resolveAndEmit(ip, arc)); + } else if (eventState == -1) { + final entry = _peerArcs[ip]; + if (entry == null) return; + entry.streamCount--; + if (entry.streamCount > 0) return; + _peerArcs.remove(ip); + // Only emit -1 if we already emitted a +1 for this peer (i.e. + // the geo lookup completed). Otherwise the globe never saw it + // and a -1 with no preceding +1 would just be noise. + if (entry.geo != null) { + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: entry.workerIdx, + addr: '', + )); + } + state = state.copyWith( + activeCount: max(0, state.activeCount - 1), + ); + } + } catch (e) { + // Malformed event — log via dev print to avoid bringing in the + // appLogger here. Real impl can switch to slog. + debugPrint('share-my-connection: bad peer-connection event: $e'); + } + }); + } + + Future _resolveAndEmit(String ip, _PeerArc arc) async { + PeerGeo geo; + try { + geo = await GeoLookupService.peerLookup(ip); + } catch (_) { + geo = PeerGeo.unknown; + } + // Peer may have disconnected before the lookup returned. The map + // entry's identity (workerIdx) is the cheapest check. + final current = _peerArcs[ip]; + if (current == null || current.workerIdx != arc.workerIdx) return; + // Skip arcs we couldn't geo-locate. The peer is still counted in + // activeCount, but we don't draw a wrong-country arc. + if (geo.countryCode.isEmpty) return; + arc.geo = geo; + _eventController.add(UnboundedConnectionEvent( + state: 1, + workerIdx: arc.workerIdx, + addr: ip, + countryName: geo.countryName, + countryCode: geo.countryCode, + flagEmoji: geo.flagEmoji, + coordinates: geo.coordinates, + )); + } + + /// Replays a synthetic +1 for every currently-active peer that has a + /// resolved geo. Callers (e.g. the globe widget when it mounts after + /// the user navigates into the screen) get a one-shot seed of the + /// current world state so they don't render an empty globe despite + /// active connections. Replayed events have isReplay=true so the UI + /// can suppress the "new connection" burst. + void replayCurrentPeers() { + for (final entry in _peerArcs.entries) { + final arc = entry.value; + final geo = arc.geo; + if (geo == null) continue; + _eventController.add(UnboundedConnectionEvent( + state: 1, + workerIdx: arc.workerIdx, + addr: entry.key, + countryName: geo.countryName, + countryCode: geo.countryCode, + flagEmoji: geo.flagEmoji, + coordinates: geo.coordinates, + isReplay: true, + )); + } + } + + void _stopEventSubscription() { + // Synthesize -1 for every active peer BEFORE killing the source + // stream. peer.Client.Stop on the Go side suppresses the box.Close + // disconnect cascade (correct — avoids a flood of post-Stop noise), + // so without this loop the globe would never see -1's for peers + // that were live at toggle-time. Their arcs would orphan and rotate + // with the globe indefinitely. With this loop, the globe sees real + // -1's and runs them through the normal linger-then-remove path. + for (final arc in _peerArcs.values) { + if (arc.geo == null) continue; + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: arc.workerIdx, + addr: '', + )); + } + _appEventSub?.cancel(); + _appEventSub = null; + _peerArcs.clear(); + _workerSeq = 0; + } + + // Parses a `peer-status` FlutterEvent and folds the new phase / error + // into ShareState. Payload is the JSON-marshalled radiance peer.Status + // (see lantern-core/core.go EventTypePeerStatus). Phase strings come + // from radiance/peer/peer.go's Phase constants; we map them through + // SharePhase.fromWire so an unknown future phase falls back to idle + // instead of crashing the consumer. + // + // SmC Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx, + // samizdat verify timeout, …) arrive as phase=error. Treat any such + // failure as a signal to switch transparently to Unbounded mode — + // the user's intent ("I want to share") is honoured via broflake + // regardless of SmC's outcome, and raw protocol error text never + // reaches the status card. + void _handlePeerStatus(String message, WidgetRef widgetRef) { + try { + final payload = jsonDecode(message) as Map; + final phase = SharePhase.fromWire(payload['phase'] as String?); + final errMsg = payload['error'] as String?; + if (phase == SharePhase.error && state.mode == ShareMode.smc) { + appLogger.info( + 'SmC start failed, falling back to Unbounded: ${errMsg ?? ""}', + ); + unawaited(_fallbackToUnbounded(widgetRef)); + return; + } + state = state.copyWith( + phase: phase, + errorMessage: (errMsg == null || errMsg.isEmpty) ? null : errMsg, + ); + } catch (e) { + debugPrint('share-my-connection: bad peer-status event: $e'); + } + } + + // Seamlessly switches an in-flight SmC session to Unbounded. Called when + // the radiance peer client reports phase=error — the SmC Start has + // already failed and radiance has rolled the PeerShareEnabledKey + // setting back to false, so all we owe is to flip our local state to + // Unbounded and enable broflake. + // + // Constructs ShareState directly (rather than copyWith) so errorMessage + // gets cleared — copyWith's `?? this.errorMessage` keeps the previous + // SmC failure string around otherwise. + Future _fallbackToUnbounded(WidgetRef widgetRef) async { + state = ShareState( + active: true, + probing: false, + mode: ShareMode.unbounded, + activeCount: 0, + totalCount: state.totalCount, + phase: SharePhase.idle, + ); + final result = await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(true); + result.fold( + (err) => appLogger.error( + 'SmC→Unbounded fallback: setUnboundedEnabled failed: ${err.error}', + ), + (_) => {}, + ); + } +} + +final shareProvider = + NotifierProvider(ShareNotifier.new); + +// ─── Tab body ──────────────────────────────────────────────────────────────── + +/// Unbounded tab content, rendered inside the Home tab shell (see +/// home.dart). Hosts the description text, globe + arrival toast, the +/// status card with the toggle, and the advanced section. No Scaffold +/// or AppBar — the shell provides the chrome and the tab strip. +class UnboundedTab extends HookConsumerWidget { + const UnboundedTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(shareProvider); + final notifier = ref.read(shareProvider.notifier); + final textTheme = Theme.of(context).textTheme; + + // First-visit welcome popup. Fires once per device (persisted via + // appSettingProvider.unboundedWelcomeSeen) when the user first lands + // on the Unbounded tab. Re-openable via the info-bubble icon in the + // header. + useEffect(() { + final seen = ref.read(appSettingProvider).unboundedWelcomeSeen; + if (!seen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + showUnboundedWelcomeDialog(context, ref); + }); + } + return null; + }, const []); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'Help others bypass censorship by securely sharing your ' + 'connection.', + style: textTheme.bodyMedium, + ), + ), + // Info bubble — re-opens the welcome popup. Mirrors the + // Figma spec, which calls out the info-bubble as the + // way back into the explanatory dialog. + IconButton( + icon: const Icon(Icons.info_outline, size: 20), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'About Unbounded', + onPressed: () => showUnboundedWelcomeDialog(context, ref), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + flex: 3, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: _GlobeView()), + // Floating arrival toast — centered horizontally + // under the globe per unbounded.lantern.io + // (frame-020 of unbounded-russia.mp4 shows the pill + // sitting roughly under the globe's centre, not at + // a corner). The Lottie heart-spray lives INSIDE the + // pill via Stack(Clip.none) + negative offsets, so + // hearts originate from the pill's static heart and + // overflow upward/leftward into the globe area. + const Positioned( + left: 0, + right: 0, + bottom: 8, + child: Center(child: _ArrivalToast()), + ), + ], + ), + ), + const SizedBox(height: 8), + _StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)), + const SizedBox(height: 12), + const _AdvancedCard(), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +// ─── Status card ───────────────────────────────────────────────────────────── + +class _StatusCard extends StatelessWidget { + final ShareState state; + final VoidCallback onToggle; + + const _StatusCard({required this.state, required this.onToggle}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + // Status text source-of-truth, in priority order: + // 1. Off and not probing → "Off" + // 2. Probing UPnP locally → "Probing your network…" + // 3. SmC mode → granular phase from radiance peer.Status. The + // backend emits one phase per stage during Start so the user + // sees real progress instead of "Active" for several seconds. + // 4. Unbounded mode → static "Active — Unbounded" (no equivalent + // staged lifecycle on the broflake side yet). + final modeLabel = switch ((state.mode, state.phase)) { + (ShareMode.off, _) => + state.probing ? 'Probing your network…' : 'Off', + (ShareMode.unbounded, _) => + 'Active — sharing via Unbounded (WebRTC)', + (ShareMode.smc, SharePhase.mappingPort) => + 'Opening port on your router…', + (ShareMode.smc, SharePhase.detectingIp) => + 'Detecting your public IP…', + (ShareMode.smc, SharePhase.registering) => + 'Registering with Lantern…', + (ShareMode.smc, SharePhase.startingProxy) => + 'Starting local proxy…', + (ShareMode.smc, SharePhase.verifying) => + 'Verifying connectivity…', + (ShareMode.smc, SharePhase.serving) => + 'Sharing — ready to serve users in censored regions', + (ShareMode.smc, SharePhase.stopping) => 'Stopping…', + (ShareMode.smc, SharePhase.error) => + state.errorMessage != null + ? "Couldn't share: ${state.errorMessage}" + : "Couldn't share — try toggling again", + // SmC active but no phase yet (e.g. very first frame after toggle + // before the backend's first event arrives) — fall back to the + // legacy active label so the UI isn't blank. + (ShareMode.smc, SharePhase.idle) => + 'Active — sharing via Share My Connection (residential proxy)', + }; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black12), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Status', + style: textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + modeLabel, + style: textTheme.bodyMedium?.copyWith( + color: state.active + ? AppColors.blue4 + : Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + // Match the rest of the app's toggles (vpn_setting.dart etc.). + // SwitchButton has no built-in disabled state, so during the + // probe we render the switch but absorb the tap so the user + // doesn't double-fire toggle(). + SwitchButton( + value: state.active || state.probing, + onChanged: (value) { + if (state.probing) return; + onToggle(); + }, + ), + ], + ), + if (state.active) ...[ + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + Stack( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat( + label: 'People helping right now', + value: '${state.activeCount}'), + _Stat( + label: 'Total people helped to date', + value: '${state.totalCount}'), + ], + ), + Positioned( + top: 0, + right: 0, + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + waitDuration: const Duration(milliseconds: 200), + showDuration: const Duration(seconds: 8), + preferBelow: false, + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + textStyle: const TextStyle(color: Colors.white, fontSize: 12), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + message: + 'Most connections are short liveness probes — Lantern ' + 'clients periodically check that this peer is reachable ' + 'before sending real traffic. A quick burst from many ' + 'locations is normal; an arc that lingers represents an ' + 'actual user session.', + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).hintColor, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _Stat extends StatelessWidget { + final String label; + final String value; + const _Stat({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + Text(value, style: textTheme.headlineSmall), + Text(label, style: textTheme.labelSmall), + ], + ); + } +} + +// ─── Globe ─────────────────────────────────────────────────────────────────── + +class _GlobeView extends ConsumerStatefulWidget { + @override + ConsumerState<_GlobeView> createState() => _GlobeViewState(); +} + +class _GlobeViewState extends ConsumerState<_GlobeView> { + static final _arcColor = AppColors.blue4.withValues(alpha: 0.75); + static final _originPointColor = AppColors.blue4.withValues(alpha: 0.15); + static final _peerPointColor = AppColors.yellow3.withValues(alpha: 0.15); + static const _atmosphereDark = AppColors.blue4; + static const _atmosphereLight = AppColors.blue6; + + final FlutterEarthGlobeController _globeController = + FlutterEarthGlobeController( + isRotating: true, + rotationSpeed: 0.04, + zoom: 0, + isZoomEnabled: false, + showAtmosphere: true, + atmosphereColor: _atmosphereDark, + atmosphereOpacity: 0.2, + atmosphereBlur: 20, + ); + + StreamSubscription? _eventSub; + GlobeCoordinates? _originCoords; + // Pending arc removals: peer goes idle → we don't yank the arc + // immediately so brief URL-test probes (which dominate samizdat-peer + // traffic) still register visually. Timer is cancelled if the same + // workerIdx +1's again before it fires. + final Map _pendingRemovals = {}; + static const _arcLinger = Duration(seconds: 5); + + @override + void initState() { + super.initState(); + _globeController.onLoaded = () { + if (!mounted) return; + _applyTheme(); + }; + _initOrigin(); + // Subscribe BEFORE the replay call so we don't miss any concurrent + // +1 events. The broadcast stream delivers synchronously when added, + // but the replay events come from inside the same notifier so order + // is preserved. + _eventSub = ref + .read(shareProvider.notifier) + .connectionEvents + .listen(_handleEvent); + ref.read(shareProvider.notifier).replayCurrentPeers(); + } + + @override + void dispose() { + _eventSub?.cancel(); + for (final t in _pendingRemovals.values) { + t.cancel(); + } + _pendingRemovals.clear(); + _globeController.dispose(); + super.dispose(); + } + + void _applyTheme() { + final isDark = Theme.of(context).brightness == Brightness.dark; + _globeController.loadSurface(AssetImage( + isDark + ? 'assets/unbounded/uv-map-dark.png' + : 'assets/unbounded/uv-map.png', + )); + _globeController.atmosphereColor = + isDark ? _atmosphereDark : _atmosphereLight; + } + + Future _initOrigin() async { + final coords = await GeoLookupService.selfLookup(); + if (!mounted) return; + _originCoords = coords; + _globeController.addPoint(Point( + id: 'origin', + coordinates: coords, + style: PointStyle(color: _originPointColor, size: 8), + )); + } + + void _handleEvent(UnboundedConnectionEvent event) { + if (event.state == 1 && event.coordinates != null) { + // Cancel any lingering removal — same workerIdx is back. + _pendingRemovals.remove(event.workerIdx)?.cancel(); + _addPeer(event); + } else if (event.state == -1) { + // Linger the arc so brief connections still register visually. + _pendingRemovals[event.workerIdx]?.cancel(); + _pendingRemovals[event.workerIdx] = Timer(_arcLinger, () { + _pendingRemovals.remove(event.workerIdx); + if (!mounted) return; + _removePeer(event.workerIdx); + }); + } + } + + // Jitter coords by a workerIdx-derived offset so multiple peers from + // the same country don't draw arcs on top of each other. Hash-based so + // the same widx always lands in the same slot — no jitter drift on + // replay. + GlobeCoordinates _jittered(GlobeCoordinates base, int widx) { + final hash = widx * 2654435761; // Knuth multiplicative hash + final dLat = ((hash >> 4) & 0xff) / 255.0 * 4.0 - 2.0; // [-2, +2]° + final dLng = ((hash >> 12) & 0xff) / 255.0 * 4.0 - 2.0; + return GlobeCoordinates(base.latitude + dLat, base.longitude + dLng); + } + + void _addPeer(UnboundedConnectionEvent event) { + if (!mounted) return; + final coords = _jittered(event.coordinates!, event.workerIdx); + // Arc direction is censored user → uncensored peer (us). The dash + // animation flows from start to end, so the visual "travel" reads + // as traffic arriving at our peer to escape censorship. + _globeController.addPointConnection(PointConnection( + id: 'conn_${event.workerIdx}', + start: coords, + end: _originCoords ?? const GlobeCoordinates(0, 0), + curveScale: .6, + style: PointConnectionStyle( + color: _arcColor, + lineWidth: 3, + type: PointConnectionType.solid, + dashAnimateTime: 1000, + dashSize: 13, + spacing: 15, + dotSize: 10, + animateOnAdd: true, + ), + )); + _globeController.addPoint(Point( + id: 'peer_${event.workerIdx}', + coordinates: coords, + style: PointStyle(color: _peerPointColor, size: 6), + )); + } + + void _removePeer(int workerIdx) { + _globeController.removePointConnection('conn_$workerIdx'); + _globeController.removePoint('peer_$workerIdx'); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // FlutterEarthGlobe positions the sphere relative to MediaQuery.size + // (i.e. the full screen). Without overriding it the globe ends up + // off-screen when it lives in a non-fullscreen layout slot. The + // MediaQuery override + Positioned.fill keeps the sphere centred + // within this widget's box; ClipRect keeps arcs from painting + // outside the box when they curve high. + final widgetSize = Size(constraints.maxWidth, constraints.maxHeight); + final radius = + min(constraints.maxWidth, constraints.maxHeight) / 2 * 0.7; + return ClipRect( + child: MediaQuery( + data: MediaQueryData(size: widgetSize), + child: Stack( + children: [ + Positioned.fill( + child: FlutterEarthGlobe( + controller: _globeController, + radius: radius, + alignment: const Alignment(0.0, -0.1), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +// ─── Arrival toast ─────────────────────────────────────────────────────────── + +/// Floating notification overlay shown under the globe. Mirrors the +/// unbounded.lantern.io notification pattern: heart-burst on the left, +/// `Helping a new person in ` text on the right while a peer +/// is connecting. When no peer has arrived recently, falls back to +/// `Waiting for connections...` (no heart) per the Figma spec. Slides +/// up + fades in, auto-hides connection arrivals after ~3.5s. +class _ArrivalToast extends ConsumerStatefulWidget { + const _ArrivalToast(); + + @override + ConsumerState<_ArrivalToast> createState() => _ArrivalToastState(); +} + +class _ArrivalToastState extends ConsumerState<_ArrivalToast> { + StreamSubscription? _sub; + Timer? _hideTimer; + UnboundedConnectionEvent? _current; + + @override + void initState() { + super.initState(); + _sub = ref + .read(shareProvider.notifier) + .connectionEvents + .listen(_onEvent); + } + + void _onEvent(UnboundedConnectionEvent event) { + if (event.state != 1 || event.isReplay) return; + if (event.countryName.isEmpty) return; + if (!mounted) return; + _hideTimer?.cancel(); + setState(() => _current = event); + _hideTimer = Timer(const Duration(milliseconds: 3500), () { + if (!mounted) return; + setState(() => _current = null); + }); + } + + @override + void dispose() { + _sub?.cancel(); + _hideTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final event = _current; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.4), end: Offset.zero) + .animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)), + child: child, + ), + ), + child: event == null + // Idle state — the spec wants a "Waiting for connections..." + // pill rather than empty space, so the user knows the screen + // is live and just nothing has arrived yet. + ? const _WaitingCard(key: ValueKey('arrival-waiting')) + : _ArrivalCard( + // ValueKey forces AnimatedSwitcher to swap children when a + // new arrival lands while the previous toast is still up, + // so the Lottie restarts cleanly. + key: ValueKey('arrival-${event.workerIdx}'), + countryName: event.countryName, + flagEmoji: event.flagEmoji, + ), + ); + } +} + +class _ArrivalCard extends StatelessWidget { + const _ArrivalCard({ + super.key, + required this.countryName, + required this.flagEmoji, + }); + + final String countryName; + final String flagEmoji; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.fromLTRB(10, 8, 16, 8), + // clipBehavior:none lets the absolutely-positioned Lottie burst + // (inside the heart slot below) overflow the pill's rounded + // bounds and spray upward across the globe. + clipBehavior: Clip.none, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Colors.black12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Heart slot with the static heart icon + a Lottie burst + // that overflows upward and rightward into the globe area. + // Layout mirrors unbounded's CSS one-for-one: heart in a + // 22×19 slot, Lottie absolute-positioned at bottom:-55, + // left:-105, width:420 (scaled to the slot's natural + // bottom/left = pill heart's bottom/left). + SizedBox( + width: 22, + height: 19, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: const [ + // Lottie spreads upward + rightward from the heart. + // The size matches explosion.json's native 420×502 + // canvas — unbounded.lantern.io's CSS uses width:420 + // with height:auto for the same effect. Forcing the + // height to 420 (as we did before) scaled the + // animation down by ~83% via BoxFit.contain and lost + // ~82px of upward spread, leaving the hearts visibly + // smaller and clustered just above the pill instead + // of fanning out across the globe. + Positioned( + bottom: -55, + left: -105, + width: 420, + height: 502, + child: _ArrivalLottie(), + ), + CustomPaint(painter: _HeartPainter()), + ], + ), + ), + const SizedBox(width: 14), + // unbounded.lantern.io renders just `heart + text`, no flag + // emoji — matching that exactly so the pill width stays in + // bounds and the layout reads cleanly. flagEmoji is still + // carried on the event for future use (e.g. label above + // the peer's arc on the globe). + Text( + 'Helping a new person in $countryName', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} + +/// Plays explosion.json once per build. Stateful so each _ArrivalCard +/// instance (keyed on workerIdx) gets its own clean Lottie playback. +class _ArrivalLottie extends StatefulWidget { + const _ArrivalLottie(); + + @override + State<_ArrivalLottie> createState() => _ArrivalLottieState(); +} + +class _ArrivalLottieState extends State<_ArrivalLottie> + with TickerProviderStateMixin { + AnimationController? _ctrl; + + @override + void dispose() { + _ctrl?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Lottie.asset( + 'assets/unbounded/explosion.json', + repeat: false, + fit: BoxFit.contain, + onLoaded: (composition) { + _ctrl = AnimationController( + vsync: this, + duration: composition.duration, + )..forward(); + setState(() {}); + }, + controller: _ctrl, + ); + } +} + +/// Idle-state companion to _ArrivalCard. Same pill chrome, no heart, +/// `Waiting for connections...` text. Shown whenever the toast switch +/// has no current arrival to display. +class _WaitingCard extends StatelessWidget { + const _WaitingCard({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Colors.black12), + ), + child: Text( + 'Waiting for connections...', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} + +// ─── Lottie burst layer ────────────────────────────────────────────────────── + +/// Pink heart from `getlantern/unbounded` — exact SVG path coords +/// (viewBox 0 0 32 27, fill #FF5A79). +class _HeartPainter extends CustomPainter { + const _HeartPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = const Color(0xFFFF5A79); + final path = Path() + ..moveTo(31.5035, 5.87209) + ..cubicTo(28.0938, -3.18494, 17.0123, 0.864084, 16, 5.3926) + ..cubicTo(14.6148, 0.597701, 3.79965, -2.97183, 0.496497, 5.87209) + ..cubicTo(-3.17959, 15.7283, 14.7214, 24.5722, 16, 26.0107) + ..cubicTo(17.2786, 24.8386, 35.1796, 15.5684, 31.5035, 5.87209) + ..close(); + // Scale path from native 32x27 to the canvas size. + final scaled = path.transform(Matrix4.diagonal3Values( + size.width / 32.0, + size.height / 27.0, + 1.0, + ).storage); + canvas.drawPath(scaled, paint); + } + + @override + bool shouldRepaint(_HeartPainter oldDelegate) => false; +} + +// ─── Advanced section ──────────────────────────────────────────────────────── + +/// _AdvancedCard exposes power-user knobs that don't belong in the +/// always-visible status card. Today: manual port forward (for users on +/// networks where UPnP doesn't work, who've configured a router-side +/// port forward by hand). +/// +/// Persisted via the existing FFI setPeerManualPort path; takes effect +/// on the next peer.Client.Start (i.e. next time the toggle is flipped +/// on after editing the field). +class _AdvancedCard extends HookConsumerWidget { + const _AdvancedCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black12), + ), + child: Theme( + // Strip the divider lines ExpansionTile draws by default — the + // container border already gives the section its own outline. + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + title: Text('Advanced', style: textTheme.labelLarge), + subtitle: Text( + 'For users whose router doesn\'t support UPnP', + style: textTheme.labelSmall, + ), + children: const [_ManualPortField()], + ), + ), + ); + } +} + +class _ManualPortField extends HookConsumerWidget { + const _ManualPortField(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + final controller = useTextEditingController(); + final loaded = useState(false); + final saving = useState(false); + final lastSaved = useState(null); + + // Load the persisted port once. We deliberately don't watch a + // provider here — the value rarely changes and a one-shot read + // matches the rest of the radianceSettingsProvider's eager-load + // pattern. + useEffect(() { + Future.microtask(() async { + final result = + await ref.read(lanternServiceProvider).getPeerManualPort(); + result.fold((_) => null, (port) { + if (port > 0) controller.text = port.toString(); + lastSaved.value = port; + }); + loaded.value = true; + }); + return null; + }, const []); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Manual port forward', + style: textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + 'If your router doesn\'t support UPnP, configure a port forward ' + 'on your router and enter the port number here. Lantern will use ' + 'it as the external port instead of probing UPnP. Leave blank to ' + 'use UPnP (default).', + style: textTheme.bodySmall, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: false, + ), + decoration: InputDecoration( + labelText: 'Port', + hintText: 'e.g. 5698', + border: const OutlineInputBorder(), + isDense: true, + enabled: loaded.value && !saving.value, + ), + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: (loaded.value && !saving.value) + ? () => _save(ref, context, controller, saving, lastSaved) + : null, + child: saving.value + ? const SizedBox( + height: 16, width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + if (lastSaved.value != null && lastSaved.value! > 0) ...[ + const SizedBox(height: 8), + Text( + 'Currently set to port ${lastSaved.value}. Toggle Share My ' + 'Connection off and back on for the change to take effect.', + style: textTheme.bodySmall?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ], + ], + ); + } + + Future _save( + WidgetRef ref, + BuildContext context, + TextEditingController controller, + ValueNotifier saving, + ValueNotifier lastSaved, + ) async { + saving.value = true; + try { + final raw = controller.text.trim(); + int port = 0; + if (raw.isNotEmpty) { + port = int.tryParse(raw) ?? -1; + if (port < 1 || port > 65535) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Port must be between 1 and 65535')), + ); + } + return; + } + } + final result = + await ref.read(lanternServiceProvider).setPeerManualPort(port); + result.fold( + (err) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err.localizedErrorMessage)), + ); + } + }, + (_) { + lastSaved.value = port; + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(port == 0 + ? 'Manual port cleared — using UPnP' + : 'Manual port set to $port'), + ), + ); + } + }, + ); + } finally { + saving.value = false; + } + } +} + +// ─── Disclosure dialog ─────────────────────────────────────────────────────── + +class SmcDisclosureDialog extends StatelessWidget { + const SmcDisclosureDialog({super.key}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return AlertDialog( + title: const Text('Use full Share My Connection?'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Your network supports the higher-bandwidth, more ' + 'block-resistant mode. In this mode, your home internet ' + 'connection routes traffic for users in censored countries.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'Lantern blocks abuse destinations, rotates credentials, ' + 'and operates as a "mere conduit" under DMCA § 512(a) — ' + 'but your IP address will appear in the destination\'s ' + 'logs while you\'re sharing.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'You can still help by selecting "Basic mode" instead, ' + 'which uses ephemeral WebRTC connections that are not ' + 'tied to your IP in the same way.', + style: textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Basic mode (Unbounded)'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Full mode (SmC)'), + ), + ], + ); + } +} + +// ─── Welcome dialog ────────────────────────────────────────────────────────── + +/// Shows the first-visit Unbounded welcome popup per Figma +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287). +/// Idempotent: dismissing the dialog (either button OR scrim tap) +/// flips appSettingProvider.unboundedWelcomeSeen → true so the dialog +/// only fires on the first visit. The info-bubble icon in the +/// Unbounded tab header calls this same function to re-open it later. +void showUnboundedWelcomeDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => const _UnboundedWelcomeDialog(), + ).whenComplete(() { + ref.read(appSettingProvider.notifier).setUnboundedWelcomeSeen(true); + }); +} + +class _UnboundedWelcomeDialog extends StatelessWidget { + const _UnboundedWelcomeDialog(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Heart logo, matching the Figma's heart-Lantern motif. + const Center( + child: SizedBox( + width: 40, + height: 34, + child: CustomPaint(painter: _HeartPainter()), + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'Welcome to Unbounded', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Text( + "When you enable Unbounded, your device becomes part of a " + "network of 'digital bridges' to the open internet. " + "Censored users connect to these bridges, allowing them " + "to bypass government-imposed restrictors and access the " + "information they need.", + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'This collective effort makes censorship harder to ' + 'enforce, expanding access to the open internet.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'You can remove Unbounded from the interface anytime in ' + 'Settings.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + // TODO: deep-link to the Unbounded explainer page + // once the URL is wired (AppUrls.unbounded?). + }, + icon: const Icon(Icons.open_in_new, size: 14), + label: const Text('Learn more'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 7dde6b538b..bc7c3abf99 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -79,6 +79,25 @@ abstract class LanternCoreService { Future> isBlockAdsEnabled(); + Future> setPeerProxyEnabled(bool enabled); + + Future> isPeerProxyEnabled(); + + /// Persists the manually-configured router port forward used as the + /// peer-share external port when set. Pass 0 to clear the override + /// and revert to UPnP-discovered port behavior. + Future> setPeerManualPort(int port); + + /// Returns the persisted manual port (0 if unset). + Future> getPeerManualPort(); + + /// Local opt-in for the broflake / Unbounded widget proxy ("Basic + /// mode" in the Share My Connection UI). Actual run state also + /// depends on server feature-flag and config availability. + Future> setUnboundedEnabled(bool enabled); + + Future> isUnboundedEnabled(); + Future> isSmartRoutingEnabled(); Future> isTelemetryEnabled(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index dec4dec5ca..d59c892c47 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1550,6 +1550,90 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> setPeerProxyEnabled(bool enabled) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setPeerProxyEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isPeerProxyEnabled() async { + try { + final res = _ffiService.isPeerProxyEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> setPeerManualPort(int port) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setPeerManualPort(port) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerManualPort error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> getPeerManualPort() async { + try { + final res = _ffiService.getPeerManualPort(); + return right(res); + } catch (e, st) { + appLogger.error('getPeerManualPort error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> setUnboundedEnabled(bool enabled) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setUnboundedEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setUnboundedEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isUnboundedEnabled() async { + try { + final res = _ffiService.isUnboundedEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isUnboundedEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 14cc3944cb..89265acdcc 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -6275,6 +6275,66 @@ class LanternBindings { late final _isBlockAdsEnabled = _isBlockAdsEnabledPtr .asFunction(); + ffi.Pointer setPeerProxyEnabled(int enabled) { + return _setPeerProxyEnabled(enabled); + } + + late final _setPeerProxyEnabledPtr = + _lookup Function(ffi.Int)>>( + 'setPeerProxyEnabled', + ); + late final _setPeerProxyEnabled = _setPeerProxyEnabledPtr + .asFunction Function(int)>(); + + int isPeerProxyEnabled() { + return _isPeerProxyEnabled(); + } + + late final _isPeerProxyEnabledPtr = + _lookup>('isPeerProxyEnabled'); + late final _isPeerProxyEnabled = _isPeerProxyEnabledPtr + .asFunction(); + + ffi.Pointer setPeerManualPort(int port) { + return _setPeerManualPort(port); + } + + late final _setPeerManualPortPtr = + _lookup Function(ffi.Int)>>( + 'setPeerManualPort', + ); + late final _setPeerManualPort = _setPeerManualPortPtr + .asFunction Function(int)>(); + + int getPeerManualPort() { + return _getPeerManualPort(); + } + + late final _getPeerManualPortPtr = + _lookup>('getPeerManualPort'); + late final _getPeerManualPort = _getPeerManualPortPtr + .asFunction(); + + ffi.Pointer setUnboundedEnabled(int enabled) { + return _setUnboundedEnabled(enabled); + } + + late final _setUnboundedEnabledPtr = + _lookup Function(ffi.Int)>>( + 'setUnboundedEnabled', + ); + late final _setUnboundedEnabled = _setUnboundedEnabledPtr + .asFunction Function(int)>(); + + int isUnboundedEnabled() { + return _isUnboundedEnabled(); + } + + late final _isUnboundedEnabledPtr = + _lookup>('isUnboundedEnabled'); + late final _isUnboundedEnabled = _isUnboundedEnabledPtr + .asFunction(); + ffi.Pointer setSmartRoutingEnabled(int enabled) { return _setSmartRoutingEnabled(enabled); } diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 734c827544..730d6ead68 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,6 +290,87 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> setPeerProxyEnabled(bool enabled) async { + try { + await _methodChannel.invokeMethod('setPeerProxyEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isPeerProxyEnabled() async { + try { + final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + // Manual port forward setting — wired through MethodChannel to the + // platform-specific handler (Swift on macOS calls Mobile.SetPeerManualPort, + // similar pattern needed for iOS / Android when the user-facing toggle + // ships there). + @override + Future> setPeerManualPort(int port) async { + try { + await _methodChannel.invokeMethod('setPeerManualPort', { + 'port': port, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerManualPort failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> getPeerManualPort() async { + try { + final res = await _methodChannel.invokeMethod('getPeerManualPort'); + return right(res ?? 0); + } catch (e, st) { + appLogger.error('getPeerManualPort failed', e, st); + return Left(e.toFailure()); + } + } + + // Unbounded toggle wired through MethodChannel to the platform-specific + // handler (Swift on macOS calls Mobile.SetUnboundedEnabled). Mobile + // platforms (iOS / Android) don't implement these handlers yet — they + // will throw MissingPluginException, which `e.toFailure()` translates + // into a localized error so the Advanced UI degrades cleanly. + @override + Future> setUnboundedEnabled(bool enabled) async { + try { + await _methodChannel.invokeMethod('setUnboundedEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setUnboundedEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isUnboundedEnabled() async { + try { + final res = await _methodChannel.invokeMethod('isUnboundedEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isUnboundedEnabled failed', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 2f3ae8fa48..390109b54e 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -794,6 +794,54 @@ class LanternService implements LanternCoreService { return _platformService.setBlockAdsEnabled(enabled); } + @override + Future> isPeerProxyEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isPeerProxyEnabled(); + } + return _platformService.isPeerProxyEnabled(); + } + + @override + Future> setPeerProxyEnabled(bool enabled) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setPeerProxyEnabled(enabled); + } + return _platformService.setPeerProxyEnabled(enabled); + } + + @override + Future> setPeerManualPort(int port) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setPeerManualPort(port); + } + return _platformService.setPeerManualPort(port); + } + + @override + Future> getPeerManualPort() { + if (PlatformUtils.isFFISupported) { + return _ffiService.getPeerManualPort(); + } + return _platformService.getPeerManualPort(); + } + + @override + Future> setUnboundedEnabled(bool enabled) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setUnboundedEnabled(enabled); + } + return _platformService.setUnboundedEnabled(enabled); + } + + @override + Future> isUnboundedEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isUnboundedEnabled(); + } + return _platformService.isUnboundedEnabled(); + } + @override Future> isSmartRoutingEnabled() { if (PlatformUtils.isFFISupported) { diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index d41e93e30d..7c3600d938 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -245,6 +245,36 @@ class MethodHandler { let enabled = data?["enabled"] as? Bool ?? false self.setBlockAdsEnabled(result: result, enabled: enabled) + case "isPeerProxyEnabled": + Task { + await MainActor.run { result(MobileIsPeerShareEnabled()) } + } + + case "setPeerProxyEnabled": + let data = call.arguments as? [String: Any] + let enabled = data?["enabled"] as? Bool ?? false + self.setPeerProxyEnabled(result: result, enabled: enabled) + + case "setPeerManualPort": + let data = call.arguments as? [String: Any] + let port = data?["port"] as? Int ?? 0 + self.setPeerManualPort(result: result, port: port) + + case "getPeerManualPort": + Task { + await MainActor.run { result(Int(MobileGetPeerManualPort())) } + } + + case "setUnboundedEnabled": + let data = call.arguments as? [String: Any] + let enabled = data?["enabled"] as? Bool ?? false + self.setUnboundedEnabled(result: result, enabled: enabled) + + case "isUnboundedEnabled": + Task { + await MainActor.run { result(MobileIsUnboundedEnabled()) } + } + case "updateTelemetryEvents": guard let consent: Bool = self.decodeValue(from: call.arguments, result: result) else { return @@ -1152,6 +1182,48 @@ class MethodHandler { } } + func setPeerProxyEnabled(result: @escaping FlutterResult, enabled: Bool) { + Task { + var error: NSError? + MobileSetPeerShareEnabled(enabled, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_PEER_PROXY_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + + func setPeerManualPort(result: @escaping FlutterResult, port: Int) { + Task { + var error: NSError? + MobileSetPeerManualPort(port, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_PEER_MANUAL_PORT_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + + func setUnboundedEnabled(result: @escaping FlutterResult, enabled: Bool) { + Task { + var error: NSError? + MobileSetUnboundedEnabled(enabled, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_UNBOUNDED_ENABLED_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + func updateTelemetryEvents(consent: Bool, result: @escaping FlutterResult) { Task { var error: NSError? diff --git a/pubspec.lock b/pubspec.lock index aa05b04761..0f69884877 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -547,6 +555,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_earth_globe: + dependency: "direct main" + description: + name: flutter_earth_globe + sha256: a5461c43f4dbf1c1af4e8fa46293c83e97b8af7908969ccd6c324282e60d9a31 + url: "https://pub.dev" + source: hosted + version: "2.2.1" flutter_hooks: dependency: "direct main" description: @@ -832,7 +848,7 @@ packages: source: hosted version: "4.3.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -1076,6 +1092,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "8b6359a7422167014aa73ce763fa133fb832065dcc0ac4d1dec1f603a5cef7d0" + url: "https://pub.dev" + source: hosted + version: "3.3.3" matcher: dependency: transitive description: @@ -1284,6 +1308,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b29849c89c..8c402c033d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,9 @@ dependencies: #Routing auto_route: ^11.1.0 #UI Utils + flutter_earth_globe: ^2.2.0 + lottie: ^3.3.1 + http: ^1.2.2 animated_toggle_switch: ^0.8.7 animated_text_kit: ^4.3.0 flutter_screenutil: ^5.9.3 @@ -163,6 +166,7 @@ flutter: platforms: - windows - assets/locales/ + - assets/unbounded/ - app.env ffigen: