cmd/tailscale,java: implement file sharing

Fixes tailscale/tailscale#1809

Signed-off-by: Elias Naur <mail@eliasnaur.com>
pull/14/head
Elias Naur 3 years ago
parent 331bc1e30a
commit 10ded1bad2

@ -9,7 +9,7 @@
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:name=".App" android:allowBackup="false">
<activity android:name="org.gioui.GioActivity"
<activity android:name="IPNActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
@ -22,6 +22,28 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE">

@ -8,7 +8,11 @@ import android.app.Application;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
@ -16,21 +20,28 @@ import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.Signature;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.webkit.MimeTypeMap;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.security.GeneralSecurityException;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
@ -41,6 +52,15 @@ import org.gioui.Gio;
public class App extends Application {
private final static String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2;
private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
private final static Handler mainHandler = new Handler(Looper.getMainLooper());
@Override public void onCreate() {
@ -48,6 +68,11 @@ public class App extends Application {
// Load and initialize the Go library.
Gio.init(this);
registerNetworkCallback();
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
}
private void registerNetworkCallback() {
@ -209,6 +234,53 @@ public class App extends Application {
return null;
}
String insertMedia(String name, String mimeType) throws IOException {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent fileIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
PendingIntent pending = PendingIntent.getActivity(this, 0, fileIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
static native void onVPNPrepared();
private static native void onConnectivityChanged(boolean connected);
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
}

@ -0,0 +1,117 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.net.Uri;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView;
public final class IPNActivity extends Activity {
private GioView view;
@Override public void onCreate(Bundle state) {
super.onCreate(state);
view = new GioView(this);
setContentView(view);
handleIntent();
}
@Override public void onNewIntent(Intent i) {
setIntent(i);
handleIntent();
}
private void handleIntent() {
Intent it = getIntent();
String act = it.getAction();
String[] texts;
Uri[] uris;
if (Intent.ACTION_SEND.equals(act)) {
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uris = extraUris.toArray(new Uri[0]);
texts = new String[uris.length];
} else {
return;
}
String mime = it.getType();
int nitems = uris.length;
String[] items = new String[nitems];
String[] mimes = new String[nitems];
int[] types = new int[nitems];
String[] names = new String[nitems];
long[] sizes = new long[nitems];
int nfiles = 0;
for (int i = 0; i < uris.length; i++) {
String text = texts[i];
Uri uri = uris[i];
if (text != null) {
types[nfiles] = 1; // FileTypeText
names[nfiles] = "file.txt";
mimes[nfiles] = mime;
items[nfiles] = text;
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
sizes[nfiles] = 0;
nfiles++;
} else if (uri != null) {
Cursor c = getContentResolver().query(uri, null, null, null, null);
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
c.moveToFirst();
String name = c.getString(nameCol);
long size = c.getLong(sizeCol);
types[nfiles] = 2; // FileTypeURI
mimes[nfiles] = mime;
items[nfiles] = uri.toString();
names[nfiles] = name;
sizes[nfiles] = size;
nfiles++;
}
}
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override public void onDestroy() {
view.destroy();
super.onDestroy();
}
@Override public void onStart() {
super.onStart();
view.start();
}
@Override public void onStop() {
view.stop();
super.onStop();
}
@Override public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c);
view.configurationChanged();
}
@Override public void onLowMemory() {
super.onLowMemory();
view.onLowMemory();
}
@Override public void onBackPressed() {
if (!view.backPressed())
super.onBackPressed();
}
}

@ -6,7 +6,6 @@ package com.tailscale.ipn;
import android.os.Build;
import android.app.PendingIntent;
import android.app.NotificationChannel;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.VpnService;
@ -21,14 +20,6 @@ public class IPNService extends VpnService {
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
private static final String STATUS_CHANNEL_ID = "tailscale-status";
private static final String STATUS_CHANNEL_NAME = "VPN Status";
private static final int STATUS_NOTIFICATION_ID = 1;
private static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
private static final String NOTIFY_CHANNEL_NAME = "Notifications";
private static final int NOTIFY_NOTIFICATION_ID = 2;
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
close();
@ -70,9 +61,7 @@ public class IPNService extends VpnService {
}
public void notify(String title, String message) {
createNotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_DEFAULT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFY_CHANNEL_ID)
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
@ -82,29 +71,18 @@ public class IPNService extends VpnService {
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(NOTIFY_NOTIFICATION_ID, builder.build());
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
}
public void updateStatusNotification(String title, String message) {
createNotificationChannel(STATUS_CHANNEL_ID, STATUS_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_LOW);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW);
startForeground(STATUS_NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
}
private native void connect();

@ -36,6 +36,9 @@ var (
// onGoogleToken receives google ID tokens.
onGoogleToken = make(chan string)
// onFileShare receives file sharing intents.
onFileShare = make(chan []File, 1)
)
const (
@ -129,3 +132,45 @@ func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C.jclass,
}
}
}
//export Java_com_tailscale_ipn_App_onShareIntent
func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclass, nfiles C.jint, jtypes C.jintArray, jmimes C.jobjectArray, jitems C.jobjectArray, jnames C.jobjectArray, jsizes C.jlongArray) {
const (
typeNone = 0
typeInline = 1
typeURI = 2
)
jenv := (*jni.Env)(unsafe.Pointer(env))
types := jni.GetIntArrayElements(jenv, jni.IntArray(jtypes))
mimes := jni.GetStringArrayElements(jenv, jni.ObjectArray(jmimes))
items := jni.GetStringArrayElements(jenv, jni.ObjectArray(jitems))
names := jni.GetStringArrayElements(jenv, jni.ObjectArray(jnames))
sizes := jni.GetLongArrayElements(jenv, jni.LongArray(jsizes))
var files []File
for i := 0; i < int(nfiles); i++ {
f := File{
Type: FileType(types[i]),
MIMEType: mimes[i],
Name: names[i],
}
if f.Name == "" {
f.Name = "file.bin"
}
switch f.Type {
case FileTypeText:
f.Text = items[i]
f.Size = int64(len(f.Text))
case FileTypeURI:
f.URI = items[i]
f.Size = sizes[i]
default:
panic("unknown file type")
}
files = append(files, f)
}
select {
case <-onFileShare:
default:
}
onFileShare <- files
}

@ -5,9 +5,17 @@
package main
import (
"context"
"crypto/sha1"
"errors"
"fmt"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@ -20,9 +28,12 @@ import (
"inet.af/netaddr"
"github.com/tailscale/tailscale-android/jni"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/net/dns"
"tailscale.com/net/netns"
"tailscale.com/paths"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/wgengine/router"
@ -41,10 +52,10 @@ type App struct {
prefs chan *ipn.Prefs
// browseURLs receives URLs when the backend wants to browse.
browseURLs chan string
// backend is the channel for events from the frontend to the
// backend.
backendEvents chan UIEvent
// targetsLoaded receives lists of file targets.
targetsLoaded chan FileTargets
// invalidates receives whenever the window should be refreshed.
invalidates chan struct{}
}
var (
@ -52,6 +63,30 @@ var (
googleClass jni.Class
)
type FileTargets struct {
Targets []*apitype.FileTarget
Err error
}
type File struct {
Type FileType
Name string
Size int64
MIMEType string
// URI of the file, valid if Type is FileTypeURI.
URI string
// Text is the content of the file, if Type is FileTypeText.
Text string
}
// FileSendInfo describes the state of an ongoing file send operation.
type FileSendInfo struct {
State FileSendState
// Progress tracks the progress of the transfer from 0.0 to 1.0. Valid
// only when State is FileSendStarted.
Progress float64
}
type clientState struct {
browseURL string
backend BackendState
@ -61,6 +96,14 @@ type clientState struct {
Peers []UIPeer
}
type FileType uint8
// FileType constants are known to IPNActivity.java.
const (
FileTypeText FileType = 1
FileTypeURI FileType = 2
)
type ExitStatus uint8
const (
@ -72,7 +115,17 @@ const (
ExitOnline
)
type ExitNode struct {
type FileSendState uint8
const (
FileSendNotStarted FileSendState = iota
FileSendConnecting
FileSendTransferring
FileSendComplete
FileSendFailed
)
type Peer struct {
Label string
Online bool
ID tailcfg.StableNodeID
@ -84,11 +137,11 @@ type BackendState struct {
NetworkMap *netmap.NetworkMap
LostInternet bool
// Exits are the peers that can act as exit node.
Exits []ExitNode
Exits []Peer
// ExitState describes the state of our exit node.
ExitStatus ExitStatus
// Exit is our current exit node, if any.
Exit ExitNode
Exit Peer
}
// UIEvent is an event flowing from the UI to the backend.
@ -114,13 +167,20 @@ type OAuth2Event struct {
Token *tailcfg.Oauth2Token
}
type FileSendEvent struct {
Target *apitype.FileTarget
Context context.Context
Updates func(FileSendInfo)
}
// UIEvent types.
type (
ToggleEvent struct{}
ReauthEvent struct{}
WebAuthEvent struct{}
GoogleAuthEvent struct{}
LogoutEvent struct{}
ToggleEvent struct{}
ReauthEvent struct{}
WebAuthEvent struct{}
GoogleAuthEvent struct{}
LogoutEvent struct{}
FileTargetsEvent struct{}
)
// serverOAuthID is the OAuth ID of the tailscale-android server, used
@ -136,11 +196,13 @@ var backendEvents = make(chan UIEvent)
func main() {
a := &App{
jvm: (*jni.JVM)(unsafe.Pointer(app.JavaVM())),
appCtx: jni.Object(app.AppContext()),
netStates: make(chan BackendState, 1),
browseURLs: make(chan string, 1),
prefs: make(chan *ipn.Prefs, 1),
jvm: (*jni.JVM)(unsafe.Pointer(app.JavaVM())),
appCtx: jni.Object(app.AppContext()),
netStates: make(chan BackendState, 1),
browseURLs: make(chan string, 1),
prefs: make(chan *ipn.Prefs, 1),
targetsLoaded: make(chan FileTargets, 1),
invalidates: make(chan struct{}, 1),
}
err := jni.Do(a.jvm, func(env *jni.Env) error {
loader := jni.ClassLoaderFor(env, a.appCtx)
@ -174,6 +236,7 @@ func (a *App) runBackend() error {
if err != nil {
fatalErr(err)
}
paths.AppSharedDir.Store(appDir)
type configPair struct {
rcfg *router.Config
dcfg *dns.OSConfig
@ -223,12 +286,33 @@ func (a *App) runBackend() error {
service jni.Object // of IPNService
signingIn bool
)
var (
waitingFilesDone = make(chan struct{})
waitingFiles bool
processingFiles bool
)
processFiles := func() {
if !waitingFiles || processingFiles {
return
}
processingFiles = true
waitingFiles = false
go func() {
if err := a.processWaitingFiles(b.backend); err != nil {
log.Printf("processWaitingFiles: %v", err)
}
waitingFilesDone <- struct{}{}
}()
}
for {
select {
case err := <-startErr:
if err != nil {
return err
}
case <-waitingFilesDone:
processingFiles = false
processFiles()
case s := <-configs:
cfg = s
if b == nil || service == 0 || cfg.rcfg == nil {
@ -290,6 +374,18 @@ func (a *App) runBackend() error {
if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline {
a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.")
}
targets, err := b.backend.FileTargets()
if err != nil {
// Construct a user-visible error message.
if b.backend.State() != ipn.Running {
err = fmt.Errorf("Not connected to tailscale")
} else {
err = fmt.Errorf("Failed to load device list")
}
}
a.targetsLoaded <- FileTargets{targets, err}
waitingFiles = n.FilesWaiting != nil
processFiles()
case <-alarmChan:
if m := state.NetworkMap; m != nil && service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
@ -385,6 +481,90 @@ func (a *App) runBackend() error {
}
}
func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error {
files, err := b.WaitingFiles()
if err != nil {
return err
}
var aerr error
for _, f := range files {
if err := a.downloadFile(b, f); err != nil && aerr == nil {
aerr = err
}
}
return aerr
}
func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.WaitingFile) (cerr error) {
in, _, err := b.OpenFile(f.Name)
if err != nil {
return err
}
defer in.Close()
ext := filepath.Ext(f.Name)
mimeType := mime.TypeByExtension(ext)
var mediaURI string
err = jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
insertMedia := jni.GetMethodID(env, cls, "insertMedia", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")
jname := jni.JavaString(env, f.Name)
jmime := jni.JavaString(env, mimeType)
uri, err := jni.CallObjectMethod(env, a.appCtx, insertMedia, jni.Value(jname), jni.Value(jmime))
if err != nil {
return err
}
mediaURI = jni.GoString(env, jni.String(uri))
return nil
})
if err != nil {
return fmt.Errorf("insertMedia: %w", err)
}
deleteURI := func(uri string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "deleteUri", "(Ljava/lang/String;)V")
juri := jni.JavaString(env, uri)
return jni.CallVoidMethod(env, a.appCtx, m, jni.Value(juri))
})
}
out, err := a.openURI(mediaURI, "w")
if err != nil {
deleteURI(mediaURI)
return fmt.Errorf("openUri: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
deleteURI(mediaURI)
return fmt.Errorf("copy: %w", err)
}
if err := out.Close(); err != nil {
deleteURI(mediaURI)
return fmt.Errorf("close: %w", err)
}
if err := a.notifyFile(mediaURI, f.Name); err != nil {
fatalErr(err)
}
return b.DeleteFile(f.Name)
}
// openURI calls a.appCtx.getContentResolver().openFileDescriptor on uri and
// mode and returns the detached file descriptor.
func (a *App) openURI(uri, mode string) (*os.File, error) {
var f *os.File
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
openURI := jni.GetMethodID(env, cls, "openUri", "(Ljava/lang/String;Ljava/lang/String;)I")
juri := jni.JavaString(env, uri)
jmode := jni.JavaString(env, mode)
fd, err := jni.CallIntMethod(env, a.appCtx, openURI, jni.Value(juri), jni.Value(jmode))
if err != nil {
return err
}
f = os.NewFile(uintptr(fd), "media-store")
return nil
})
return f, err
}
func (a *App) isChromeOS() bool {
var chromeOS bool
err := jni.Do(a.jvm, func(env *jni.Env) error {
@ -425,7 +605,7 @@ func (s *BackendState) updateExitNodes() {
}
myExit := p.StableID == exitID
hasMyExit = hasMyExit || myExit
exit := ExitNode{
exit := Peer{
Label: p.DisplayName(true),
Online: canRoute,
ID: p.StableID,
@ -445,8 +625,8 @@ func (s *BackendState) updateExitNodes() {
})
if !hasMyExit {
// Insert node missing from netmap.
s.Exit = ExitNode{Label: "Unknown device", ID: exitID}
s.Exits = append([]ExitNode{s.Exit}, s.Exits...)
s.Exit = Peer{Label: "Unknown device", ID: exitID}
s.Exits = append([]Peer{s.Exit}, s.Exits...)
}
}
@ -561,6 +741,16 @@ func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer {
return t
}
func (a *App) notifyFile(uri, msg string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
notify := jni.GetMethodID(env, cls, "notifyFile", "(Ljava/lang/String;Ljava/lang/String;)V")
juri := jni.JavaString(env, uri)
jmsg := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, a.appCtx, notify, jni.Value(juri), jni.Value(jmsg))
})
}
func (a *App) pushNotify(service jni.Object, title, msg string) error {
return jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, service)
@ -615,6 +805,8 @@ func (a *App) runUI() error {
// activity is the most recent Android Activity reference as reported
// by Gio ViewEvents.
activity jni.Object
// files is list of files from the most recent file sharing intent.
files []File
)
deleteActivityRef := func() {
if activity == 0 {
@ -677,6 +869,15 @@ func (a *App) runUI() error {
case <-onVPNRevoked:
ui.ShowMessage("VPN access denied or another VPN service is always-on")
w.Invalidate()
case files = <-onFileShare:
ui.ShowShareDialog()
w.Invalidate()
backendEvents <- FileTargetsEvent{}
case t := <-a.targetsLoaded:
ui.FillShareDialog(t.Targets, t.Err)
w.Invalidate()
case <-a.invalidates:
w.Invalidate()
case e := <-w.Events():
switch e := e.(type) {
case app.ViewEvent:
@ -699,6 +900,7 @@ func (a *App) runUI() error {
if e.Type == system.CommandBack {
if ui.onBack() {
e.Cancel = true
w.Invalidate()
}
}
case system.FrameEvent:
@ -707,7 +909,7 @@ func (a *App) runUI() error {
gtx := layout.NewContext(&ops, e)
events := ui.layout(gtx, ins, state)
e.Frame(gtx.Ops)
a.processUIEvents(w, events, activity, state)
a.processUIEvents(w, events, activity, state, files)
}
}
}
@ -829,7 +1031,7 @@ func requestBackend(e UIEvent) {
}()
}
func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState) {
func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState, files []File) {
for _, e := range events {
switch e := e.(type) {
case ReauthEvent:
@ -858,8 +1060,109 @@ func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, s
case SearchEvent:
state.query = strings.ToLower(e.Query)
a.updateState(act, state)
case FileSendEvent:
a.sendFiles(e, files)
}
}
}
func (a *App) sendFiles(e FileSendEvent, files []File) {
go func() {
var totalSize int64
for _, f := range files {
totalSize += f.Size
}
if totalSize == 0 {
totalSize = 1
}
var totalSent int64
progress := func(n int64) {
totalSent += n
e.Updates(FileSendInfo{
State: FileSendTransferring,
Progress: float64(totalSent) / float64(totalSize),
})
a.invalidate()
}
defer a.invalidate()
for _, f := range files {
if err := a.sendFile(e.Context, e.Target, f, progress); err != nil {
if errors.Is(err, context.Canceled) {
return
}
e.Updates(FileSendInfo{
State: FileSendFailed,
})
return
}
}
e.Updates(FileSendInfo{
State: FileSendComplete,
})
}()
}
func (a *App) invalidate() {
select {
case a.invalidates <- struct{}{}:
default:
}
}
func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget, f File, progress func(n int64)) error {
var body io.Reader
switch f.Type {
case FileTypeText:
body = strings.NewReader(f.Text)
case FileTypeURI:
f, err := a.openURI(f.URI, "r")
if err != nil {
return err
}
defer f.Close()
body = f
default:
panic("unknown file type")
}
body = &progressReader{r: body, size: f.Size, progress: progress}
dstURL := target.PeerAPIURL + "/v0/put/" + url.PathEscape(f.Name)
req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, body)
if err != nil {
return err
}
req.ContentLength = f.Size
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("PUT failed: %s", res.Status)
}
return nil
}
// progressReader wraps an io.Reader to call a progress function
// on every non-zero Read.
type progressReader struct {
r io.Reader
bytes int64
size int64
eof bool
progress func(n int64)
}
func (r *progressReader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
// The request body may be read after http.Client.Do returns, see
// https://github.com/golang/go/issues/30597. Don't update progress if the
// file has been read.
r.eof = r.eof || errors.Is(err, io.EOF)
if !r.eof && r.bytes < r.size {
r.progress(int64(n))
r.bytes += int64(n)
}
return n, err
}
func (a *App) signOut() {

@ -6,6 +6,7 @@ package main
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
@ -25,6 +26,7 @@ import (
"gioui.org/widget/material"
"golang.org/x/exp/shiny/materialdesign/icons"
"inet.af/netaddr"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
@ -92,15 +94,35 @@ type UI struct {
t0 time.Time
}
shareDialog struct {
show bool
dismiss Dismiss
list layout.List
// peers are the nodes ready to receive files.
targets []shareTarget
loaded bool
error error
}
icons struct {
search *widget.Icon
more *widget.Icon
exitStatus *widget.Icon
done *widget.Icon
error *widget.Icon
logo paint.ImageOp
google paint.ImageOp
}
}
type shareTarget struct {
btn widget.Clickable
target *apitype.FileTarget
info FileSendInfo
cancel func()
updates <-chan FileSendInfo
}
type signinType uint8
// An UIPeer is either a peer or a section header
@ -161,6 +183,14 @@ func newUI(store *stateStore) (*UI, error) {
if err != nil {
return nil, err
}
doneIcon, err := widget.NewIcon(icons.ActionCheckCircle)
if err != nil {
return nil, err
}
errorIcon, err := widget.NewIcon(icons.AlertErrorOutline)
if err != nil {
return nil, err
}
logo, _, err := image.Decode(bytes.NewReader(tailscaleLogo))
if err != nil {
return nil, err
@ -189,15 +219,20 @@ func newUI(store *stateStore) (*UI, error) {
ui.icons.search = searchIcon
ui.icons.more = moreIcon
ui.icons.exitStatus = exitStatus
ui.icons.done = doneIcon
ui.icons.error = errorIcon
ui.icons.logo = paint.NewImageOp(logo)
ui.icons.google = paint.NewImageOp(google)
ui.icons.more.Color = rgb(white)
ui.icons.search.Color = mulAlpha(ui.theme.Palette.Fg, 0xbb)
ui.icons.exitStatus.Color = rgb(white)
ui.icons.done.Color = ui.theme.Palette.ContrastBg
ui.icons.error.Color = rgb(0xcc6539)
ui.root.Axis = layout.Vertical
ui.intro.list.Axis = layout.Vertical
ui.search.SingleLine = true
ui.exitDialog.list.Axis = layout.Vertical
ui.shareDialog.list.Axis = layout.Vertical
return ui, nil
}
@ -207,11 +242,18 @@ func mulAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
}
func (ui *UI) onBack() bool {
if !ui.menu.show {
return false
switch {
case ui.menu.show:
ui.menu.show = false
return true
case ui.shareDialog.show:
ui.shareDialog.show = false
return true
case ui.exitDialog.show:
ui.exitDialog.show = false
return true
}
ui.menu.show = false
return true
return false
}
func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent {
@ -284,6 +326,42 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat
events = append(events, LogoutEvent{})
}
for i := range ui.shareDialog.targets {
t := &ui.shareDialog.targets[i]
select {
case t.info = <-t.updates:
default:
}
if !t.btn.Clicked() {
continue
}
switch t.info.State {
case FileSendTransferring, FileSendConnecting:
t.cancel()
t.info.State = FileSendNotStarted
t.updates = nil
continue
}
t.info = FileSendInfo{
State: FileSendConnecting,
}
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
updates := make(chan FileSendInfo, 1)
t.updates = updates
events = append(events, FileSendEvent{
Target: t.target,
Context: ctx,
Updates: func(info FileSendInfo) {
select {
case <-updates:
default:
}
updates <- info
},
})
}
for len(ui.peers) < len(state.Peers) {
ui.peers = append(ui.peers, widget.Clickable{})
}
@ -360,6 +438,8 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat
ui.layoutExitNodeDialog(gtx, sysIns, state.backend.Exits)
ui.layoutShareDialog(gtx, sysIns)
// Popup messages.
ui.layoutMessage(gtx, sysIns)
@ -380,6 +460,31 @@ func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientStat
return events
}
func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) {
ui.shareDialog.error = err
ui.shareDialog.loaded = true
targetSet := make(map[tailcfg.NodeID]int)
if ui.shareDialog.show {
// Update rather than replace list.
for i, t := range ui.shareDialog.targets {
targetSet[t.target.Node.ID] = i
}
} else {
ui.shareDialog.targets = nil
}
for _, t := range targets {
if i, ok := targetSet[t.Node.ID]; ok {
ui.shareDialog.targets[i].target = t
} else {
ui.shareDialog.targets = append(ui.shareDialog.targets, shareTarget{target: t})
}
}
}
func (ui *UI) ShowShareDialog() {
ui.shareDialog.show = true
}
func (ui *UI) ShowMessage(msg string) {
ui.message.text = msg
ui.message.t0 = time.Now()
@ -623,9 +728,118 @@ func (ui *UI) menuClicked(btn *widget.Clickable) bool {
return cl
}
// layoutExitNodeDialog lays out the exit node selection dialog. If the user changed the node,
// true is returned along with the node.
func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []ExitNode) {
// layoutShareDialog lays out the file sharing dialog shown on file send intents (ACTION_SEND, ACTION_SEND_MULTIPLE).
func (ui *UI) layoutShareDialog(gtx layout.Context, sysIns system.Insets) {
d := &ui.shareDialog
if d.dismiss.Dismissed(gtx) {
ui.shareDialog.show = false
}
if !d.show {
return
}
d.dismiss.Add(gtx, argb(0x66000000))
layout.Inset{
Top: unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)),
Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)),
Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)),
Left: unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)),
}.Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Px(unit.Dp(250))
gtx.Constraints.Max.X = gtx.Constraints.Min.X
return layoutDialog(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
// Header.
d := layout.Inset{
Top: unit.Dp(16),
Right: unit.Dp(20),
Left: unit.Dp(20),
Bottom: unit.Dp(16),
}.Layout(gtx, func(gtx C) D {
l := material.Body1(ui.theme, "Share via Tailscale")
l.Font.Weight = text.Bold
return l.Layout(gtx)
})
// Swallow clicks to title.
var c widget.Clickable
gtx.Constraints.Min = d.Size
c.Layout(gtx)
return d
}),
layout.Rigid(func(gtx C) D {
if d.loaded {
return D{}
}
return layout.UniformInset(unit.Dp(50)).Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
sz := gtx.Px(unit.Dp(32))
gtx.Constraints.Min = image.Pt(sz, sz)
gtx.Constraints.Max = gtx.Constraints.Min
return material.Loader(ui.theme).Layout(gtx)
})
})
}),
layout.Rigid(func(gtx C) D {
if d.error == nil {
return D{}
}
sz := gtx.Px(unit.Dp(50))
gtx.Constraints.Min.Y = sz
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx C) D {
return layout.W.Layout(gtx, func(gtx C) D {
return material.Body2(ui.theme, d.error.Error()).Layout(gtx)
})
})
}),
layout.Flexed(1, func(gtx C) D {
gtx.Constraints.Min.Y = 0
return d.list.Layout(gtx, len(d.targets), func(gtx C, idx int) D {
node := &d.targets[idx]
target := node.target.Node
lbl := target.ComputedName
offline := target.Online != nil && !*target.Online
if offline {
lbl = lbl + " (offline)"
}
w := material.Body2(ui.theme, lbl)
if offline {
w.Color = rgb(0xbbbbbb)
gtx.Queue = nil
}
return material.Clickable(gtx, &node.btn, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, w.Layout),
layout.Rigid(func(gtx C) D {
sz := gtx.Px(unit.Dp(16))
gtx.Constraints.Min = image.Pt(sz, sz)
switch node.info.State {
case FileSendConnecting:
return material.Loader(ui.theme).Layout(gtx)
case FileSendTransferring:
return material.ProgressCircle(ui.theme, float32(node.info.Progress)).Layout(gtx)
case FileSendFailed:
return ui.icons.error.Layout(gtx, unit.Dp(16))
case FileSendComplete:
return ui.icons.done.Layout(gtx, unit.Dp(16))
default:
return D{}
}
}),
)
})
})
})
}),
)
})
})
})
}
// layoutExitNodeDialog lays out the exit node selection dialog.
func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []Peer) {
d := &ui.exitDialog
if d.dismiss.Dismissed(gtx) {
d.show = false
@ -663,10 +877,7 @@ func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exi
// Add "none" exit node.
n := len(exits) + 1
return d.list.Layout(gtx, n, func(gtx C, idx int) D {
switch idx {
case n - 1:
}
node := ExitNode{Label: "None", Online: true}
node := Peer{Label: "None", Online: true}
if idx >= 1 {
node = exits[idx-1]
}

@ -4,7 +4,7 @@ go 1.16
require (
eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b
gioui.org v0.0.0-20210623091900-5b8da35a798b
gioui.org v0.0.0-20210626160534-a87206c3647c
gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b
golang.org/x/exp v0.0.0-20191227195350-da58074b4299
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22

@ -15,8 +15,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b h1:J9r7EuPdhvBTafg34EqrObAm/bDEaDh7LvhKJPGficE=
eliasnaur.com/font v0.0.0-20200617114307-e02d32decb4b/go.mod h1:CYwJpIhpzVfoHpFXGlXjSx9mXMWtHt4XXmZb6RjumRc=
gioui.org v0.0.0-20210518185901-8611894b4bb3/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
gioui.org v0.0.0-20210623091900-5b8da35a798b h1:N5MMMh46Yg3o8UDFpfEzIIXi4K9x02QoHl89kjmqTOc=
gioui.org v0.0.0-20210623091900-5b8da35a798b/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
gioui.org v0.0.0-20210626160534-a87206c3647c h1:PUxsHY4Ig4hn1gXO1iuJzplNdjSEzQbMOagao20UHOU=
gioui.org v0.0.0-20210626160534-a87206c3647c/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b h1:qPEu8AvPmYvUyZmQcOzAj+vc+BmdVtWrhCeHIm+pGJU=
gioui.org/cmd v0.0.0-20210623091900-5b8da35a798b/go.mod h1:SgBjN+8Jtku8vvbXc6AsTebvxPY9+h68PUjSKGNryvQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

Loading…
Cancel
Save