diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c5af1d1..27088d4 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ - + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index 391c195..ef2e1d9 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -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); } diff --git a/android/src/main/java/com/tailscale/ipn/IPNActivity.java b/android/src/main/java/com/tailscale/ipn/IPNActivity.java new file mode 100644 index 0000000..580691b --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/IPNActivity.java @@ -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 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(); + } +} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.java b/android/src/main/java/com/tailscale/ipn/IPNService.java index 7e00814..88d9a2a 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.java +++ b/android/src/main/java/com/tailscale/ipn/IPNService.java @@ -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(); diff --git a/cmd/tailscale/callbacks.go b/cmd/tailscale/callbacks.go index 42fc244..840f3f7 100644 --- a/cmd/tailscale/callbacks.go +++ b/cmd/tailscale/callbacks.go @@ -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 +} diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go index 915cf1f..2ecf735 100644 --- a/cmd/tailscale/main.go +++ b/cmd/tailscale/main.go @@ -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() { diff --git a/cmd/tailscale/ui.go b/cmd/tailscale/ui.go index 5df64f3..660b8dc 100644 --- a/cmd/tailscale/ui.go +++ b/cmd/tailscale/ui.go @@ -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] } diff --git a/go.mod b/go.mod index 145de2a..01c94e2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a62a4d1..a6f913e 100644 --- a/go.sum +++ b/go.sum @@ -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=