diff --git a/app/build.gradle b/app/build.gradle index be97d0df..fc5ca3c7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,19 @@ apply plugin: 'com.android.application' apply plugin: 'com.google.protobuf' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' +apply plugin: 'kotlin-parcelize' +apply plugin: 'org.jetbrains.kotlin.plugin.serialization' +apply plugin: 'com.google.devtools.ksp' +apply plugin: 'com.google.dagger.hilt.android' android { compileSdkVersion 36 defaultConfig { applicationId 'slowscript.warpinator' - minSdkVersion 21 //Required by NSD (attributes) - targetSdkVersion 35 + minSdkVersion 23 // Required by compose + targetSdkVersion 36 versionCode 1090 versionName "1.9" } @@ -18,6 +24,11 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + + buildFeatures { + compose true + } + compileOptions { sourceCompatibility = 17 targetCompatibility = 17 @@ -28,41 +39,70 @@ android { } } namespace 'slowscript.warpinator' + kotlinOptions { + jvmTarget = '17' + } buildFeatures { buildConfig true } + + androidResources { + generateLocaleConfig true + } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'androidx.appcompat:appcompat:1.7.1' - implementation 'androidx.core:core:1.17.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation 'androidx.documentfile:documentfile:1.1.0' - implementation 'com.google.android.material:material:1.13.0' - implementation 'androidx.recyclerview:recyclerview:1.4.0' - implementation 'androidx.cardview:cardview:1.0.0' + // Jetpack Compose + def composeBom = platform('androidx.compose:compose-bom-alpha:2025.12.01') + implementation composeBom + + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.activity:activity-compose' + + // Material 3 Implementations + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material3.adaptive:adaptive' + implementation 'androidx.compose.material3.adaptive:adaptive-layout' + implementation 'androidx.compose.material3.adaptive:adaptive-navigation' + implementation 'androidx.compose.material:material-icons-extended' + + // State management + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' + implementation 'androidx.lifecycle:lifecycle-service' + implementation 'com.google.dagger:hilt-android:2.57.2' + ksp 'com.google.dagger:hilt-compiler:2.57.2' + implementation 'androidx.hilt:hilt-navigation-compose:1.3.0' implementation 'org.openjax.security:nacl:0.3.2' //Update available, but API is weird now implementation 'org.bouncycastle:bcpkix-jdk15to18:1.82' implementation 'io.grpc:grpc-netty:1.75.0' implementation 'io.grpc:grpc-okhttp:1.75.0' - implementation ('io.grpc:grpc-protobuf:1.75.0') { + implementation('io.grpc:grpc-protobuf:1.75.0') { exclude group: 'com.google.api.grpc', module: 'proto-google-common-protos' } implementation 'io.grpc:grpc-stub:1.75.0' + implementation 'io.grpc:grpc-kotlin-stub:1.5.0' + implementation("com.google.protobuf:protobuf-kotlin:3.25.8") implementation 'javax.annotation:javax.annotation-api:1.3.2' implementation 'org.conscrypt:conscrypt-android:2.5.3' implementation 'com.github.tony19:logback-android:3.0.0' implementation 'androidx.preference:preference:1.2.1' - implementation 'com.google.guava:guava:33.4.8-android' //This was included by gRPC anyway, so why not use it - implementation 'org.jmdns:jmdns:3.5.8' //Device discovery worsened after update, let's see if this was the problem - // Also, new versions require desugaring on Android 5 and 6 + implementation 'com.google.guava:guava:33.4.8-android' + //This was included by gRPC anyway, so why not use it + implementation 'org.jmdns:jmdns:3.5.8' + //Device discovery worsened after update, let's see if this was the problem + // Also, new versions require desugaring on Android 5 and 6 implementation 'org.slf4j:slf4j-api:2.0.17' // For jmdns, it declares a too old dependency implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' implementation 'com.google.zxing:core:3.5.3' + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" + + testImplementation 'junit:junit:4.13.2' } protobuf { @@ -73,21 +113,27 @@ protobuf { grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.75.0' } + + grpckt { + artifact = "io.grpc:protoc-gen-grpc-kotlin:1.5.0:jdk8@jar" + } } generateProtoTasks { all().each { task -> task.builtins { - java { } + java {} + kotlin {} } task.plugins { - grpc { } + grpc {} + grpckt {} } } } } //If there is a better way to get rid of Netty logging, let me know -configurations.all { +configurations.configureEach { resolutionStrategy { dependencySubstitution { substitute module('ch.qos.logback:logback-classic') using module('com.github.tony19:logback-android:3.0.0') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7b88162b..e7113a8e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,23 +11,31 @@ - - - - + + + + + + android:supportsRtl="true"> @@ -41,34 +49,14 @@ - - - - - - - - - - - - - - - - - - + android:launchMode="singleTask" + android:theme="@style/Theme.Warpinator" + android:resizeableActivity="true" + android:windowSoftInputMode="adjustResize" + tools:targetApi="24"> @@ -77,32 +65,50 @@ + + + + + + + + + + + - - + - + tools:targetApi="24"> - diff --git a/app/src/main/java/slowscript/warpinator/AboutActivity.java b/app/src/main/java/slowscript/warpinator/AboutActivity.java deleted file mode 100644 index c8415d31..00000000 --- a/app/src/main/java/slowscript/warpinator/AboutActivity.java +++ /dev/null @@ -1,50 +0,0 @@ -package slowscript.warpinator; - -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; - -import android.os.Bundle; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.MenuItem; -import android.widget.TextView; - -import com.google.android.material.appbar.MaterialToolbar; - -public class AboutActivity extends AppCompatActivity { - - TextView versionView, warrantyView; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_about); - Utils.setEdgeToEdge(getWindow()); - MaterialToolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - } - Utils.setToolbarInsets(toolbar); - Utils.setContentInsets(findViewById(R.id.scrollView)); - - versionView = findViewById(R.id.versionText); - warrantyView = findViewById(R.id.warrantyText); - - versionView.setText(getString(R.string.version, BuildConfig.VERSION_NAME)); - warrantyView.setMovementMethod(LinkMovementMethod.getInstance()); - warrantyView.setText(Html.fromHtml(getResources().getString(R.string.warranty_html))); - - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Respond to the action bar's Up/Home button - if (item.getItemId() == android.R.id.home) { - super.onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } -} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/Authenticator.java b/app/src/main/java/slowscript/warpinator/Authenticator.java deleted file mode 100644 index 119cea1f..00000000 --- a/app/src/main/java/slowscript/warpinator/Authenticator.java +++ /dev/null @@ -1,241 +0,0 @@ -package slowscript.warpinator; - -import android.util.Base64; -import android.util.Log; - -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.GeneralName; -import org.bouncycastle.asn1.x509.GeneralNames; -import org.bouncycastle.asn1.x509.Time; -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.bouncycastle.util.io.pem.PemObject; -import org.bouncycastle.util.io.pem.PemReader; -import org.openjax.security.nacl.TweetNaclFast; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.KeyStore; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.Security; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.Date; -import java.util.List; -import java.util.Locale; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; - -public class Authenticator { - private static final String TAG = "AUTH"; - public static String DEFAULT_GROUP_CODE = "Warpinator"; - - static long day = 1000L * 60L * 60L * 24; - - public static long expireTime = 30L * day; - public static String groupCode = DEFAULT_GROUP_CODE; - - static String cert_begin = "-----BEGIN CERTIFICATE-----\n"; - static String cert_end = "-----END CERTIFICATE-----"; - static Exception certException = null; - - public static byte[] getBoxedCertificate() { - byte[] bytes = new byte[0]; - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - - final byte[] key = md.digest(groupCode.getBytes(StandardCharsets.UTF_8)); - TweetNaclFast.SecretBox box = new TweetNaclFast.SecretBox(key); - byte[] nonce = TweetNaclFast.makeSecretBoxNonce(); - byte[] res = box.box(getServerCertificate(), nonce); - - bytes = new byte[24 + res.length]; - System.arraycopy(nonce, 0, bytes, 0, 24); - System.arraycopy(res, 0, bytes, 24, res.length); - } catch (Exception e) { - Log.wtf(TAG, "WADUHEK", e); - } //This shouldn't fail - return bytes; - } - - public static byte[] getServerCertificate() { - String serverIP = MainService.svc.getCurrentIPStr(); - //Try loading it first - try { - Log.d(TAG, "Loading server certificate..."); - certException = null; - File f = getCertificateFile(".self"); - X509Certificate cert = getX509fromFile(f); - cert.checkValidity(); //Will throw if expired (and we generate a new one) - String ip = (String)((List)cert.getSubjectAlternativeNames().toArray()[0]).get(1); - if (!ip.equals(serverIP)) - throw new Exception(); //Throw if IPs don't match (and regenerate cert) - - return Utils.readAllBytes(f); - } catch (Exception ignored) {} - - //Create new one if doesn't exist yet - return createCertificate(Utils.getDeviceName(), serverIP); - } - - public static File getCertificateFile(String hostname) { - File certsDir = Utils.getCertsDir(); - return new File(certsDir, hostname + ".pem"); - } - - static byte[] createCertificate(String hostname, String ip) { - try { - Log.d(TAG, "Creating new server certificate..."); - - Security.addProvider(new BouncyCastleProvider()); - //Create KeyPair - KeyPair kp = createKeyPair("RSA", 2048); - - long now = System.currentTimeMillis(); - - //Only allowed chars - hostname = hostname.replaceAll("[^a-zA-Z0-9]", ""); - if (hostname.trim().isEmpty()) - hostname = "android"; - //Build certificate - X500Name name = new X500Name("CN="+hostname); - BigInteger serial = new BigInteger(Long.toString(now)); //Use current time as serial num - Time notBefore = new Time(new Date(now - day), Locale.ENGLISH); - Time notAfter = new Time(new Date(now + expireTime), Locale.ENGLISH); - - JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( - name, serial, notBefore, notAfter, name, kp.getPublic()); - builder.addExtension(Extension.subjectAlternativeName, true, new GeneralNames(new GeneralName(GeneralName.iPAddress, ip))); - - //Sign certificate - ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()); - X509CertificateHolder cert = builder.build(signer); - - //Save private key - byte[] privKeyBytes = kp.getPrivate().getEncoded(); - saveCertOrKey(".self.key-pem", privKeyBytes, true); - - //Save cert - byte[] certBytes = cert.getEncoded(); - saveCertOrKey(".self.pem", certBytes, false); - - return certBytes; - } - catch(Exception e) { - Log.e(TAG, "Failed to create certificate", e); - certException = e; - return null; - } - } - - public static boolean saveBoxedCert(byte[] bytes, String remoteUuid) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - - final byte[] key = md.digest(groupCode.getBytes("UTF-8")); - TweetNaclFast.SecretBox box = new TweetNaclFast.SecretBox(key); - byte[] nonce = new byte[24]; - byte[] ciph = new byte[bytes.length - 24]; - System.arraycopy(bytes, 0, nonce, 0, 24); - System.arraycopy(bytes, 24, ciph, 0, bytes.length - 24); - byte[] cert = box.open(ciph, nonce); - if (cert == null) { - Log.w(TAG, "Failed to unbox cert. Wrong group code?"); - return false; - } - - saveCertOrKey(remoteUuid + ".pem", cert, false); - return true; - } catch (Exception e) { - Log.e(TAG, "Failed to unbox and save certificate", e); - return false; - } - } - - private static void saveCertOrKey(String filename, byte[] bytes, boolean isPrivateKey) { - File certsDir = Utils.getCertsDir(); - if (!certsDir.exists()) - certsDir.mkdir(); - File cert = new File(certsDir, filename); - - String begin = cert_begin; - String end = cert_end; - if (isPrivateKey) { - begin = "-----BEGIN PRIVATE KEY-----\n"; - end = "-----END PRIVATE KEY-----"; - } - String cert64 = Base64.encodeToString(bytes, Base64.DEFAULT); - String certString = begin + cert64 + end; - try (FileOutputStream stream = new FileOutputStream(cert, false)) { - stream.write(certString.getBytes()); - } catch (Exception e) { - Log.w(TAG, "Failed to save certificate or private key: " + filename, e); - } - } - - private static byte[] loadCertificate(String hostname) throws IOException { - File cert = getCertificateFile(hostname); - return Utils.readAllBytes(cert); - } - - private static X509Certificate getX509fromFile(File f) throws GeneralSecurityException, IOException { - FileReader fileReader = new FileReader(f); - PemReader pemReader = new PemReader(fileReader); - PemObject obj = pemReader.readPemObject(); - pemReader.close(); - X509Certificate result; - try (InputStream in = new ByteArrayInputStream(obj.getContent());) { - result = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(in); - } - return result; - } - - private static KeyPair createKeyPair(String algorithm, int bitCount) throws NoSuchAlgorithmException - { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(algorithm); - keyPairGenerator.initialize(bitCount, new SecureRandom()); - - return keyPairGenerator.genKeyPair(); - } - - public static SSLSocketFactory createSSLSocketFactory(String name) throws GeneralSecurityException, IOException { - File crtFile = getCertificateFile(name); - - SSLContext sslContext = SSLContext.getInstance("SSL"); - - KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - trustStore.load(null, null); - - // Read the certificate from disk - X509Certificate cert = getX509fromFile(crtFile); - - // Add it to the trust store - trustStore.setCertificateEntry(crtFile.getName(), cert); - - // Convert the trust store to trust managers - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(trustStore); - TrustManager[] trustManagers = tmf.getTrustManagers(); - - sslContext.init(null, trustManagers, null); - return sslContext.getSocketFactory(); - } -} diff --git a/app/src/main/java/slowscript/warpinator/CertServer.java b/app/src/main/java/slowscript/warpinator/CertServer.java deleted file mode 100644 index c89e289f..00000000 --- a/app/src/main/java/slowscript/warpinator/CertServer.java +++ /dev/null @@ -1,71 +0,0 @@ -package slowscript.warpinator; - -import android.util.Base64; -import android.util.Log; - -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.net.SocketException; -import java.util.Arrays; - -public class CertServer implements Runnable{ - static String TAG = "CertServer"; - static int PORT; - public static String REQUEST = "REQUEST"; - - static Thread serverThread; - static DatagramSocket serverSocket; - static boolean running = false; - - public static void Start(int port) { - PORT = port; - if (serverSocket != null) - serverSocket.close(); - running = true; - serverThread = new Thread(new CertServer()); - serverThread.start(); - } - - public static void Stop() { - //It's a UDP server, it doesn't lock anything so this shouldn't matter - //Close should cancel the receive method - running = false; - if (serverSocket != null) - serverSocket.close(); - } - - public void run() { - try { - serverSocket = new DatagramSocket(PORT); - } catch (Exception e){ - Log.e(TAG, "Failed to start certificate server", e); - return; - } - byte[] receiveData = new byte[1024]; - byte[] cert = Authenticator.getBoxedCertificate(); - byte[] sendData = Base64.encode(cert, Base64.DEFAULT); - while(running) - { - try { - DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); - serverSocket.receive(receivePacket); - byte[] received = Arrays.copyOfRange(receivePacket.getData(), 0, receivePacket.getLength()); - String request = new String(received); - if (request.equals(REQUEST)) - { - InetAddress IPAddress = receivePacket.getAddress(); - int port = receivePacket.getPort(); - DatagramPacket sendPacket = - new DatagramPacket(sendData, sendData.length, IPAddress, port); - serverSocket.send(sendPacket); - Log.d(TAG, "Certificate sent"); - } - } catch (Exception e) { - if (running) { - Log.w(TAG, "Error while running CertServer. Restarting. || " + e.getMessage()); - } - } - } - } -} diff --git a/app/src/main/java/slowscript/warpinator/GrpcService.java b/app/src/main/java/slowscript/warpinator/GrpcService.java deleted file mode 100644 index 19d8545e..00000000 --- a/app/src/main/java/slowscript/warpinator/GrpcService.java +++ /dev/null @@ -1,247 +0,0 @@ -package slowscript.warpinator; - -import android.util.Base64; -import android.util.Log; - -import com.google.common.net.InetAddresses; -import com.google.protobuf.ByteString; - -import io.grpc.Status; -import io.grpc.StatusException; -import io.grpc.stub.ServerCallStreamObserver; -import io.grpc.stub.StreamObserver; - -public class GrpcService extends WarpGrpc.WarpImplBase { - static String TAG = "GRPC"; - - @Override - public void checkDuplexConnection(WarpProto.LookupName request, StreamObserver responseObserver) { - String id = request.getId(); - Remote r = MainService.remotes.get(id); - boolean haveDuplex = false; - if (r != null) { - haveDuplex = (r.status == Remote.RemoteStatus.CONNECTED) - || (r.status == Remote.RemoteStatus.AWAITING_DUPLEX); - //The other side is trying to connect with use after a connection failed - if (r.status == Remote.RemoteStatus.ERROR || r.status == Remote.RemoteStatus.DISCONNECTED) { - // Update IP address - r.address = Server.current.jmdns.getServiceInfo(Server.SERVICE_TYPE, r.uuid).getInetAddresses()[0]; - r.port = Server.current.jmdns.getServiceInfo(Server.SERVICE_TYPE, r.uuid).getPort(); - Log.v(TAG, "new ip for remote: " + r.address); - r.connect(); //Try reconnecting - } - } - Log.d(TAG, "Duplex check result: " + haveDuplex); - responseObserver.onNext(WarpProto.HaveDuplex.newBuilder().setResponse(haveDuplex).build()); - responseObserver.onCompleted(); - } - - private static final int MAX_TRIES = 32; //8 sec; Linux has 20 (5 sec), calling side timeout is 10s - @Override - public void waitingForDuplex(WarpProto.LookupName request, StreamObserver responseObserver) { - Log.d(TAG, request.getReadableName() + " is waiting for duplex..."); - Remote r = MainService.remotes.get(request.getId()); - if (r != null && (r.status == Remote.RemoteStatus.ERROR || r.status == Remote.RemoteStatus.DISCONNECTED)) - r.connect(); - - int i = 0; - boolean response = false; - while (i < MAX_TRIES) { - r = MainService.remotes.get(request.getId()); - if (r != null) - response = r.status == Remote.RemoteStatus.AWAITING_DUPLEX || r.status == Remote.RemoteStatus.CONNECTED; - if (response) - break; - i++; - if (i == MAX_TRIES) { - Log.d(TAG, request.getReadableName() + " failed to establish duplex"); - responseObserver.onError(new StatusException(Status.DEADLINE_EXCEEDED)); - return; - } - Utils.sleep(250); - } - Log.d(TAG, "Duplex wait result: " + response); - responseObserver.onNext(WarpProto.HaveDuplex.newBuilder().setResponse(response).build()); - responseObserver.onCompleted(); - } - - @Override - public void getRemoteMachineInfo(WarpProto.LookupName request, StreamObserver responseObserver) { - responseObserver.onNext(WarpProto.RemoteMachineInfo.newBuilder() - .setDisplayName(Server.current.displayName).setUserName("android").setFeatureFlags(Server.SERVER_FEATURES).build()); - responseObserver.onCompleted(); - } - - @Override - public void getRemoteMachineAvatar(WarpProto.LookupName request, StreamObserver responseObserver) { - responseObserver.onNext(WarpProto.RemoteMachineAvatar.newBuilder() - .setAvatarChunk(Server.current.getProfilePictureBytes()).build()); - responseObserver.onCompleted(); - } - - private Remote remoteForUUID(String uuid) { - Remote r = MainService.remotes.get(uuid); - if (r == null) { - Log.w(TAG, "Received transfer request from unknown remote"); - return null; - } - if (r.errorGroupCode) { - Log.w(TAG, "Sending user has wrong group code, transfer ignored"); - return null; - } - return r; - } - - @Override - public void processTransferOpRequest(WarpProto.TransferOpRequest request, StreamObserver responseObserver) { - String remoteUUID = request.getInfo().getIdent(); - Remote r = remoteForUUID(remoteUUID); - if (r == null) { - returnVoid(responseObserver); - return; - } - Log.i(TAG, "Receiving transfer from " + r.userName); - - Transfer t = new Transfer(); - t.direction = Transfer.Direction.RECEIVE; - t.remoteUUID = remoteUUID; - t.startTime = request.getInfo().getTimestamp(); - t.setStatus(Transfer.Status.WAITING_PERMISSION); - t.totalSize = request.getSize(); - t.fileCount = request.getCount(); - t.singleMime = request.getMimeIfSingle(); - t.singleName = request.getNameIfSingle(); - t.topDirBasenames = request.getTopDirBasenamesList(); - t.useCompression = request.getInfo().getUseCompression() && Server.current.useCompression; - - r.addTransfer(t); - t.prepareReceive(); - - returnVoid(responseObserver); - } - - @Override - public void pauseTransferOp(WarpProto.OpInfo request, StreamObserver responseObserver) { - super.pauseTransferOp(request, responseObserver); //Not implemented in upstream either - } - - @Override - public void sendTextMessage(WarpProto.TextMessage request, StreamObserver responseObserver) { - String remoteUUID = request.getIdent(); - Remote r = remoteForUUID(remoteUUID); - if (r == null) { - returnVoid(responseObserver); - return; - } - Log.d(TAG, "sendTextMessage from " + request.getIdent() + ": " + request.getMessage()); - - Transfer t = new Transfer(); - t.direction = Transfer.Direction.RECEIVE; - t.remoteUUID = remoteUUID; - t.startTime = request.getTimestamp(); - t.message = request.getMessage(); - t.setStatus(Transfer.Status.FINISHED); - - r.addTransfer(t); - t.showNewReceiveTransfer(true); - - returnVoid(responseObserver); - } - - @Override - public void startTransfer(WarpProto.OpInfo request, StreamObserver responseObserver) { - Log.d(TAG, "Transfer started by the other side"); - Transfer t = getTransfer(request); - if (t == null) - return; - t.useCompression &= request.getUseCompression(); - t.startSending((ServerCallStreamObserver) responseObserver); - } - - @Override - public void cancelTransferOpRequest(WarpProto.OpInfo request, StreamObserver responseObserver) { - Log.d(TAG, "Transfer cancelled by the other side"); - Transfer t = getTransfer(request); - if (t == null) { - returnVoid(responseObserver); - return; - } - t.makeDeclined(); - - returnVoid(responseObserver); - } - - @Override - public void stopTransfer(WarpProto.StopInfo request, StreamObserver responseObserver) { - Log.d(TAG, "Transfer stopped by the other side"); - Transfer t = getTransfer(request.getInfo()); - if (t == null) { - returnVoid(responseObserver); - return; - } - t.onStopped(request.getError()); - - returnVoid(responseObserver); - } - - @Override - public void ping(WarpProto.LookupName request, StreamObserver responseObserver) { - returnVoid(responseObserver); - } - - Transfer getTransfer(WarpProto.OpInfo info) { - String remoteUUID = info.getIdent(); - Remote r = MainService.remotes.get(remoteUUID); - if (r == null) { - Log.w(TAG, "Could not find corresponding remote"); - return null; - } - Transfer t = r.findTransfer(info.getTimestamp()); - if (t == null) { - Log.w(TAG, "Could not find corresponding transfer"); - } - return t; - } - - void returnVoid(StreamObserver responseObserver) { - responseObserver.onNext(WarpProto.VoidType.getDefaultInstance()); - responseObserver.onCompleted(); - } -} - -class RegistrationService extends WarpRegistrationGrpc.WarpRegistrationImplBase { - private static final String TAG = "REG_V2"; - @Override - public void requestCertificate(WarpProto.RegRequest request, StreamObserver responseObserver) { - byte[] cert = Authenticator.getBoxedCertificate(); - byte[] sendData = Base64.encode(cert, Base64.DEFAULT); - Log.v(TAG, "Sending certificate to " + request.getHostname() + " ; IP=" + request.getIp()); // IP can by mine (Linux impl) or remote's - responseObserver.onNext(WarpProto.RegResponse.newBuilder().setLockedCertBytes(ByteString.copyFrom(sendData)).build()); - responseObserver.onCompleted(); - } - - @Override - public void registerService(WarpProto.ServiceRegistration req, StreamObserver responseObserver) { - Remote r = MainService.remotes.get(req.getServiceId()); - Log.i(TAG, "Service registration from " + req.getServiceId()); - if (r != null) { - if (r.status != Remote.RemoteStatus.CONNECTED) { - r.address = InetAddresses.forString(req.getIp()); - r.authPort = req.getAuthPort(); - r.updateFromServiceRegistration(req); - if (r.status == Remote.RemoteStatus.DISCONNECTED || r.status == Remote.RemoteStatus.ERROR) - r.connect(); - else r.updateUI(); - } else Log.w("REG_V2", "Attempted registration from already connected remote"); - } else { - r = new Remote(); - r.uuid = req.getServiceId(); - r.address = InetAddresses.forString(req.getIp()); - r.authPort = req.getAuthPort(); - r.updateFromServiceRegistration(req); - Server.current.addRemote(r); - } - responseObserver.onNext(Server.current.getServiceRegistrationMsg()); - responseObserver.onCompleted(); - } -} diff --git a/app/src/main/java/slowscript/warpinator/LocalBroadcasts.java b/app/src/main/java/slowscript/warpinator/LocalBroadcasts.java deleted file mode 100644 index 25424785..00000000 --- a/app/src/main/java/slowscript/warpinator/LocalBroadcasts.java +++ /dev/null @@ -1,55 +0,0 @@ -package slowscript.warpinator; - -import android.content.Context; -import android.content.Intent; - -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -public class LocalBroadcasts { - public static final String ACTION_UPDATE_REMOTES = "update_remotes"; - public static final String ACTION_UPDATE_TRANSFERS = "update_transfers"; - public static final String ACTION_UPDATE_TRANSFER = "update_transfer"; - public static final String ACTION_UPDATE_NETWORK = "update_network"; - public static final String ACTION_DISPLAY_MESSAGE = "display_message"; - public static final String ACTION_DISPLAY_TOAST = "display_toast"; - public static final String ACTION_CLOSE_ALL = "close_all"; - - public static void updateRemotes(Context ctx) { - LocalBroadcastManager.getInstance(ctx).sendBroadcast(new Intent(ACTION_UPDATE_REMOTES)); - } - - public static void updateNetworkState(Context ctx) { - LocalBroadcastManager.getInstance(ctx).sendBroadcast(new Intent(ACTION_UPDATE_NETWORK)); - } - - public static void updateTransfers(Context ctx, String remote) { - Intent intent = new Intent(ACTION_UPDATE_TRANSFERS); - intent.putExtra("remote", remote); - LocalBroadcastManager.getInstance(ctx).sendBroadcast(intent); - } - - public static void updateTransfer(Context ctx, String remote, int id) { - Intent intent = new Intent(ACTION_UPDATE_TRANSFER); - intent.putExtra("remote", remote); - intent.putExtra("id", id); - LocalBroadcastManager.getInstance(ctx).sendBroadcast(intent); - } - - public static void displayMessage(Context ctx, String title, String msg) { - Intent intent = new Intent(ACTION_DISPLAY_MESSAGE); - intent.putExtra("title", title); - intent.putExtra("msg", msg); - LocalBroadcastManager.getInstance(ctx).sendBroadcast(intent); - } - - public static void displayToast(Context ctx, String msg, int length) { - Intent intent = new Intent(ACTION_DISPLAY_TOAST); - intent.putExtra("msg", msg); - intent.putExtra("length", length); - LocalBroadcastManager.getInstance(ctx).sendBroadcast(intent); - } - - public static void closeAll(Context ctx) { - LocalBroadcastManager.getInstance(ctx).sendBroadcast(new Intent(ACTION_CLOSE_ALL)); - } -} diff --git a/app/src/main/java/slowscript/warpinator/MainActivity.java b/app/src/main/java/slowscript/warpinator/MainActivity.java deleted file mode 100644 index 7d705d8c..00000000 --- a/app/src/main/java/slowscript/warpinator/MainActivity.java +++ /dev/null @@ -1,406 +0,0 @@ -package slowscript.warpinator; - -import android.Manifest; -import android.app.Activity; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.AutoCompleteTextView; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.app.ActivityCompat; -import androidx.documentfile.provider.DocumentFile; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.appbar.MaterialToolbar; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.common.io.Files; - -import java.io.File; -import java.io.OutputStream; -import java.util.ArrayList; - -public class MainActivity extends AppCompatActivity implements ActivityCompat.OnRequestPermissionsResultCallback { - - private static final String TAG = "MAIN"; - private static final String helpUrl = "https://slowscript.xyz/warpinator-android/connection-issues/"; - private static final int SAVE_LOG_REQCODE = 4; - - RecyclerView recyclerView; - RemotesAdapter adapter; - LinearLayout layoutNotFound; - TextView txtError, txtNoNetwork, txtOutgroup, txtManualConnectHint; - BroadcastReceiver receiver; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - Utils.setEdgeToEdge(getWindow()); - MaterialToolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - Utils.setToolbarInsets(toolbar); - Utils.setContentInsets(findViewById(R.id.layout)); - - receiver = newBroadcastReceiver(); - recyclerView = findViewById(R.id.recyclerView); - adapter = new RemotesAdapter(this); - recyclerView.setAdapter(adapter); - recyclerView.setLayoutManager(new LinearLayoutManager(this)); - layoutNotFound = findViewById(R.id.layoutNotFound); - txtError = findViewById(R.id.txtError); - txtNoNetwork = findViewById(R.id.txtNoNetwork); - txtOutgroup = findViewById(R.id.txtOutgroup); - txtManualConnectHint = findViewById(R.id.txtManualConnectHint); - txtManualConnectHint.postDelayed(() -> txtManualConnectHint.setVisibility(View.VISIBLE), 8000); - - //initializes theme based on preferences - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - switch (prefs.getString("theme_setting", "sysDefault")){ - case "sysDefault": - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); - break; - case "lightTheme": - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - break; - case "darkTheme": - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - break; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (checkCallingOrSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, 3); - } - } - - String dlDir = prefs.getString("downloadDir", ""); - boolean docFileExists = false; - try { - docFileExists = DocumentFile.fromTreeUri(this, Uri.parse(dlDir)).exists(); - } catch (Exception ignored) {} - if (dlDir.equals("") || !(new File(dlDir).exists() || docFileExists)) { - if (!trySetDefaultDirectory(this)) - askForDirectoryAccess(this); - } - - if ((getIntent().getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) - handleIntent(getIntent()); - } - - @Override - protected void onResume() { - super.onResume(); - recyclerView.post(() -> { //Run when UI is ready - if (!Utils.isMyServiceRunning(this, MainService.class)) - startMainService(); - }); - updateRemoteList(); - updateNetworkStateUI(); - - IntentFilter f = new IntentFilter(); - f.addAction(LocalBroadcasts.ACTION_UPDATE_REMOTES); - f.addAction(LocalBroadcasts.ACTION_UPDATE_NETWORK); - f.addAction(LocalBroadcasts.ACTION_DISPLAY_MESSAGE); - f.addAction(LocalBroadcasts.ACTION_DISPLAY_TOAST); - f.addAction(LocalBroadcasts.ACTION_CLOSE_ALL); - LocalBroadcastManager.getInstance(this).registerReceiver(receiver, f); - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - handleIntent(intent); - } - private void handleIntent(Intent intent) { - if (Intent.ACTION_VIEW.equals(intent.getAction())) { - Log.d(TAG, "recv action " + intent.getAction() + " -- " + intent.getData()); - String host = intent.getData().getAuthority(); - new MaterialAlertDialogBuilder(this).setTitle(R.string.manual_connection) - .setMessage(getString(R.string.confirm_connection, host)) - .setPositiveButton(android.R.string.yes,(a,b) -> Server.current.registerWithHost(host)) - .setNegativeButton(android.R.string.no, null) - .show(); - } - } - - @Override - protected void onPause() { - super.onPause(); - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); - } - - void startMainService() { - try { - startService(new Intent(this, MainService.class)); - } catch (Exception e) { - Log.e(TAG, "Could not start service", e); - Toast.makeText(this, "Could not start service: " + e.toString(), Toast.LENGTH_LONG).show(); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_main, menu); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Handle item selection - int itemID = item.getItemId(); - if (itemID == R.id.settings) { - startActivity(new Intent(this, SettingsActivity.class)); - } else if (itemID == R.id.manual_connect) { - manualConnectDialog(this); - } else if (itemID == R.id.conn_issues) { - Intent helpIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(helpUrl)); - startActivity(helpIntent); - } else if (itemID == R.id.save_log) { - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TITLE, "warpinator-log.txt"); - startActivityForResult(intent, SAVE_LOG_REQCODE); - } else if (itemID == R.id.reannounce) { - if (Server.current != null && Server.current.running) - Server.current.reannounce(); - else Toast.makeText(this, R.string.error_service_not_running, Toast.LENGTH_SHORT).show(); - } else if (itemID == R.id.rescan) { - if (Server.current != null) - Server.current.rescan(); - else Toast.makeText(this, R.string.error_service_not_running, Toast.LENGTH_SHORT).show(); - } else if (itemID == R.id.about) { - startActivity(new Intent(this, AboutActivity.class)); - } else if (itemID == R.id.menu_quit) { - Log.i(TAG, "Quitting"); - stopService(new Intent(this, MainService.class)); - finishAffinity(); - } else { - return super.onOptionsItemSelected(item); - } - return true; - } - - static void manualConnectDialog(Context c) { - if (Server.current == null || !Server.current.running) { - Toast.makeText(c, "Service is not running", Toast.LENGTH_LONG).show(); - return; - } - LayoutInflater inflater = LayoutInflater.from(c); - View v = inflater.inflate(R.layout.dialog_manual_connect, null); - AlertDialog dialog = new MaterialAlertDialogBuilder(c).setView(v) - .setPositiveButton(R.string.initiate_connection, (o,w) -> initiateConnection(c)) - .setNeutralButton(android.R.string.cancel, null) - .create(); - String host = MainService.svc.getCurrentIPStr() + ":" + Server.current.authPort; - String uri = "warpinator://"+host; - View.OnClickListener copyListener = (s) -> { - ClipData clip = ClipData.newPlainText("Device address", uri); - ((ClipboardManager)c.getSystemService(CLIPBOARD_SERVICE)).setPrimaryClip(clip); - Toast.makeText(c, R.string.address_copied, Toast.LENGTH_SHORT).show(); - }; - var txtHost = ((TextView)v.findViewById(R.id.txtHost)); - txtHost.setText(host); - txtHost.setOnClickListener(copyListener); - var imgQR = ((ImageView)v.findViewById(R.id.imgQR)); - imgQR.setImageBitmap(Utils.getQRCodeBitmap(uri)); - imgQR.setOnClickListener(copyListener); - var linearLayout = (LinearLayout)v.findViewById(R.id.dialogManualConnect); - for (int i = 0; i < Math.min(Server.current.recentRemotes.size(), 5); i++) { - var itm = inflater.inflate(R.layout.simple_list_item, linearLayout, false); - String txt = Server.current.recentRemotes.get(i); - ((TextView)itm).setText(txt); - itm.setOnClickListener((w) -> { - Server.current.registerWithHost(txt.split(" \\| ")[0]); - dialog.cancel(); - }); - linearLayout.addView(itm); - } - dialog.show(); - } - - static void initiateConnection(Context c) { - FrameLayout layout = new FrameLayout(c); - layout.setPadding(16,16,16,16); - AutoCompleteTextView editText = getIPAutoCompleteTextView(c); - layout.addView(editText); - new MaterialAlertDialogBuilder(c) - .setTitle(c.getString(R.string.enter_address)) - .setView(layout) - .setPositiveButton(android.R.string.ok, (a,b)->{ - String host = editText.getText().toString(); - Log.d(TAG, "initiateConnection: " + host); - Server.current.registerWithHost(host); - }) - .show(); - } - - @NonNull - private static AutoCompleteTextView getIPAutoCompleteTextView(Context c) { - AutoCompleteTextView editText = new AutoCompleteTextView(c); - editText.setSingleLine(); - editText.setHint("0.0.0.0:1234"); - editText.setThreshold(1); - var remoteIPs = new ArrayList(Server.current.recentRemotes.size()); - for (var r : Server.current.recentRemotes) - remoteIPs.add(r.split(" \\| ")[0]); - ArrayAdapter adapter = new ArrayAdapter<>(c, - android.R.layout.simple_list_item_1, remoteIPs); - editText.setAdapter(adapter); - return editText; - } - - private void updateRemoteList() { - recyclerView.post(() -> { - adapter.notifyDataSetChanged(); - layoutNotFound.setVisibility(MainService.remotes.size() == 0 ? View.VISIBLE : View.INVISIBLE); - int numOutgroup = 0; - for (Remote r : MainService.remotes.values()) - if (r.errorGroupCode) - numOutgroup++; - txtOutgroup.setText(numOutgroup > 0 ? getString(R.string.devices_outside_group, numOutgroup) : ""); - }); - } - - private void updateNetworkStateUI() { - runOnUiThread(() -> { - if (MainService.svc != null) - txtNoNetwork.setVisibility(MainService.svc.gotNetwork() ? View.GONE : View.VISIBLE); - if (Server.current != null) - txtError.setVisibility(Server.current.running ? View.GONE : View.VISIBLE); - }); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode == 1) { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "Got storage permission"); - if (!trySetDefaultDirectory(this)) - askForDirectoryAccess(this); - } else { - Log.d(TAG, "Storage permission denied"); - askForDirectoryAccess(this); - } - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == SAVE_LOG_REQCODE && resultCode == Activity.RESULT_OK) { - Uri savePath = data.getData(); - try (OutputStream os = getContentResolver().openOutputStream(savePath)) { - File logFile = MainService.dumpLog(); - if (logFile != null) { - Files.copy(logFile, os); - Log.d(TAG, "Log exported"); - } - } catch (Exception e) { - Log.e(TAG, "Could not save log to file", e); - Toast.makeText(this, "Could not save log to file: " + e, Toast.LENGTH_LONG).show(); - } - } - } - - private BroadcastReceiver newBroadcastReceiver() - { - Context ctx = this; - return new BroadcastReceiver() { - @Override - public void onReceive(Context c, Intent intent) { - String action = intent.getAction(); - if (action == null) return; - switch (action) { - case LocalBroadcasts.ACTION_UPDATE_REMOTES: - updateRemoteList(); - break; - case LocalBroadcasts.ACTION_UPDATE_NETWORK: - updateNetworkStateUI(); - break; - case LocalBroadcasts.ACTION_DISPLAY_MESSAGE: - String title = intent.getStringExtra("title"); - String msg = intent.getStringExtra("msg"); - Utils.displayMessage(ctx, title, msg); - break; - case LocalBroadcasts.ACTION_DISPLAY_TOAST: - msg = intent.getStringExtra("msg"); - int length = intent.getIntExtra("length", 0); - Toast.makeText(ctx, msg, length).show(); - break; - case LocalBroadcasts.ACTION_CLOSE_ALL: - finishAffinity(); - break; - } - } - }; - } - - public static void askForDirectoryAccess(Activity a) { - new MaterialAlertDialogBuilder(a) - .setTitle(R.string.download_dir_settings_title) - .setMessage(R.string.please_select_download_dir) - .setPositiveButton(android.R.string.ok, (b, c) -> { - Intent intent = new Intent(a, SettingsActivity.class); - intent.putExtra("pickDir", true); - a.startActivity(intent); - }) - .show(); - } - - public static boolean trySetDefaultDirectory(Activity a) { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && !checkWriteExternalPermission(a)) { - ActivityCompat.requestPermissions(a, new String[]{ - Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); - return true; //Don't fallback to SAF, wait for permission being granted - } - - File dir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Warpinator"); - Log.d(TAG, "Trying to set default directory: " + dir.getAbsolutePath()); - boolean res; - if (!dir.exists()) - res = dir.mkdirs(); - else res = true; - if (res) - PreferenceManager.getDefaultSharedPreferences(a).edit().putString("downloadDir", dir.getAbsolutePath()).apply(); - Log.d(TAG, "Directory set " + (res ? "successfully" : "failed")); - return res; - } - - private static boolean checkWriteExternalPermission(Activity a) { - String permission = android.Manifest.permission.WRITE_EXTERNAL_STORAGE; - int res = a.checkCallingOrSelfPermission(permission); - return (res == PackageManager.PERMISSION_GRANTED); - } -} diff --git a/app/src/main/java/slowscript/warpinator/MainService.java b/app/src/main/java/slowscript/warpinator/MainService.java deleted file mode 100644 index fe6eb756..00000000 --- a/app/src/main/java/slowscript/warpinator/MainService.java +++ /dev/null @@ -1,551 +0,0 @@ -package slowscript.warpinator; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.net.ConnectivityManager; -import android.net.LinkProperties; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkRequest; -import android.net.wifi.WifiManager; -import android.os.Build; -import android.os.IBinder; -import android.os.PowerManager; -import android.preference.PreferenceManager; -import android.text.format.Formatter; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -import java.io.File; -import java.lang.ref.WeakReference; -import java.lang.reflect.Method; -import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.ConcurrentModificationException; -import java.util.List; -import java.util.Locale; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; - -public class MainService extends Service { - private static final String TAG = "SERVICE"; - - public static String CHANNEL_SERVICE = "MainService"; - public static String CHANNEL_INCOMING = "IncomingTransfer"; - public static String CHANNEL_PROGRESS = "TransferProgress"; - static int SVC_NOTIFICATION_ID = 1; - static int PROGRESS_NOTIFICATION_ID = 2; - static String ACTION_STOP = "StopSvc"; - static int WAKELOCK_TIMEOUT = 10; // 10 min - static long pingTime = 10_000; - static long reconnectTime = 40_000; - static long autoStopTime = 60_000; - - public int runningTransfers = 0; - public boolean networkAvailable = false; - public boolean apOn = false; - int notifId = 1300; - Utils.IPInfo currentIPInfo = null; - - public static MainService svc; - public static ConcurrentHashMap remotes = new ConcurrentHashMap<>(); - public static final ArrayList> remoteCountObservers = new ArrayList<>(); - public static List remotesOrder = Collections.synchronizedList(new ArrayList<>()); - SharedPreferences prefs; - ExecutorService executor = Executors.newCachedThreadPool(); - public NotificationManagerCompat notificationMgr; - PowerManager.WakeLock wakeLock; - - private NotificationCompat.Builder notifBuilder = null; - private Server server; - private Timer timer; - private Process logcatProcess; - private WifiManager.MulticastLock lock; - private ConnectivityManager connMgr; - private ConnectivityManager.NetworkCallback networkCallback; - private BroadcastReceiver apStateChangeReceiver; - private TimerTask autoStopTask; - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return new MainServiceBinder(this); - } - - @Override - public boolean onUnbind(Intent intent) { - remoteCountObservers.clear(); - return false; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - super.onStartCommand(intent, flags, startId); - svc = this; - notificationMgr = NotificationManagerCompat.from(this); - prefs = PreferenceManager.getDefaultSharedPreferences(this); - - // Start logging - if (prefs.getBoolean("debugLog", false)) - logcatProcess = launchLogcat(); - - // Acquire multicast lock for mDNS - acquireMulticastLock(); - - PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,"MainService::TransferWakeLock"); - wakeLock.setReferenceCounted(false); - - server = new Server(this); - currentIPInfo = Utils.getIPAddress(); // Server needs to load iface setting before this - Log.d(TAG, Utils.dumpInterfaces()); - - createNotificationChannels(); - Notification notification = createForegroundNotification(); - startForeground(SVC_NOTIFICATION_ID, notification); //Sometimes fails. Maybe takes too long to get here? - Log.v(TAG, "Entered foreground"); - - // Actually start server if possible. This takes a long time so should be after startForeground() - if (currentIPInfo != null) { - Authenticator.getServerCertificate(); //Generate cert on start if doesn't exist - if (Authenticator.certException != null) { - LocalBroadcasts.displayMessage(this, "Failed to initialize service", - "A likely reason for this is that your IP address could not be obtained. " + - "Please make sure you are connected to WiFi.\n" + - "\nAvailable interfaces:\n" + Utils.dumpInterfaces() + - "\nException: " + Authenticator.certException.toString()); - Log.w(TAG, "Server will not start due to error"); - } else { - server.Start(); - } - } - - timer = new Timer(); - timer.schedule(new TimerTask() { - @Override - public void run() { - pingRemotes(); - } - }, 5L, pingTime); - timer.schedule(new TimerTask() { - @Override - public void run() { - autoReconnect(); - } - }, 5L, reconnectTime); - - listenOnNetworkChanges(); - - // Notify the tile service that MainService just started, so it can attempt to bind - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - TileMainService.requestListeningState(this); - } - - return START_STICKY; - } - - @Override - public void onDestroy() { - stopServer(); - super.onDestroy(); - } - - @Override - public void onTaskRemoved(Intent rootIntent) { - Log.d(TAG, "Task removed"); - if (runningTransfers == 0) // && autostop enabled - autoStop(); - super.onTaskRemoved(rootIntent); - } - - @Override - public void onTimeout(int startId, int fgsType) { - super.onTimeout(startId, fgsType); - Log.e(TAG, "Service has run out of time and must be stopped (Android 15+)"); - stopSelf(); - } - - private void stopServer () { - if (server == null) //I have no idea how this can happen - return; - for (Remote r : remotes.values()) { - if (r.status == Remote.RemoteStatus.CONNECTED) - r.disconnect(); - } - remotes.clear(); - remotesOrder.clear(); - server.Stop(true); - notificationMgr.cancelAll(); - connMgr.unregisterNetworkCallback(networkCallback); - unregisterReceiver(apStateChangeReceiver); - executor.shutdown(); - timer.cancel(); - if (lock != null) - lock.release(); - if (logcatProcess != null) - logcatProcess.destroy(); - } - - static void scheduleAutoStop() { - if (svc != null && svc.runningTransfers == 0 && svc.autoStopTask == null && - svc.isAutoStopEnabled() && WarpinatorApp.activitiesRunning < 1) { - svc.autoStopTask = new TimerTask() { - @Override - public void run() { - svc.autoStop(); - } - }; - try { - svc.timer.schedule(svc.autoStopTask, autoStopTime); - Log.d(TAG, "AutoStop scheduled for " + autoStopTime/1000 + " seconds"); - } catch (IllegalStateException ignored) {} //when quitting app - } - } - - static void cancelAutoStop() { - if (svc != null && svc.autoStopTask != null) { - Log.d(TAG, "Cancelling AutoStop"); - svc.autoStopTask.cancel(); - svc.autoStopTask = null; - } - } - - private void autoStop() { - if (!isAutoStopEnabled()) - return; - Log.i(TAG, "Autostopping"); - stopSelf(); - LocalBroadcasts.closeAll(this); - autoStopTask = null; - } - - public void updateProgress() { - //Do this on another thread as we don't want to block a sender or receiver thread - try { - executor.submit(this::updateNotification); - } catch (RejectedExecutionException e) { - Log.e(TAG, "Rejected execution exception: " + e.getMessage()); - } - } - - private Process launchLogcat() { - File output = new File(getExternalFilesDir(null), "latest.log"); - Process process; - String cmd = "logcat -f " + output.getAbsolutePath() + "\n"; - try { - output.delete(); //Delete original file - process = Runtime.getRuntime().exec(cmd); - Log.d(TAG, "---- Logcat started ----"); - } catch (Exception e) { - process = null; - Log.e(TAG, "Failed to start logging to file", e); - } - return process; - } - - public static File dumpLog() { - Log.d(TAG, "Saving log..."); - File output = new File(svc.getExternalCacheDir(), "dump.log"); - String cmd = "logcat -d -f " + output.getAbsolutePath() + "\n"; - try { - Process process = Runtime.getRuntime().exec(cmd); - process.waitFor(); - } catch (Exception e) { - Log.e(TAG, "Failed to dump log", e); - return null; - } - return output; - } - - private void listenOnNetworkChanges() { - connMgr = (ConnectivityManager)getSystemService(CONNECTIVITY_SERVICE); - assert connMgr != null; - NetworkRequest nr = new NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) - .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) - .build(); - networkCallback = new ConnectivityManager.NetworkCallback() { - @Override - public void onAvailable(@NonNull Network network) { - Log.d(TAG, "New network"); - networkAvailable = true; - LocalBroadcasts.updateNetworkState(MainService.this); - onNetworkChanged(); - } - @Override - public void onLost(@NonNull Network network) { - Log.d(TAG, "Network lost"); - networkAvailable = false; - LocalBroadcasts.updateNetworkState(MainService.this); - onNetworkLost(); - } - @Override - public void onLinkPropertiesChanged(@NonNull Network network, @NonNull LinkProperties linkProperties) { - Log.d(TAG, "Link properties changed"); - onNetworkChanged(); - } - }; - apStateChangeReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - int apState = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0); - if (apState % 10 == WifiManager.WIFI_STATE_ENABLED) { - Log.d(TAG, "AP was enabled"); - apOn = true; - LocalBroadcasts.updateNetworkState(MainService.this); - onNetworkChanged(); - } else if (apState % 10 == WifiManager.WIFI_STATE_DISABLED) { - Log.d(TAG, "AP was disabled"); - apOn = false; - LocalBroadcasts.updateNetworkState(MainService.this); - onNetworkLost(); - } - } - }; - apOn = isHotspotOn(); // Manually get state, some devices don't fire broadcast when registered - registerReceiver(apStateChangeReceiver, new IntentFilter("android.net.wifi.WIFI_AP_STATE_CHANGED")); - connMgr.registerNetworkCallback(nr, networkCallback); - } - - private boolean isHotspotOn() { - WifiManager manager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); - assert manager != null; - try { - final Method method = manager.getClass().getDeclaredMethod("isWifiApEnabled"); - method.setAccessible(true); //in the case of visibility change in future APIs - return (Boolean) method.invoke(manager); - } catch (Exception e) { - Log.e(TAG, "Failed to get hotspot state", e); - } - return false; - } - - public boolean gotNetwork() { - return networkAvailable || apOn; - } - - private void onNetworkLost() { - if (!gotNetwork()) - currentIPInfo = null; //Rebind even if we reconnected to the same net - } - - private void onNetworkChanged() { - var newInfo = Utils.getIPAddress(); - if (newInfo == null) { - Log.w(TAG, "Network changed, but we do not have an IP"); - currentIPInfo = null; - return; - } - InetAddress newIP = newInfo.address; - if (!newIP.equals(currentIPInfo == null ? null : currentIPInfo.address)) { - Log.d(TAG, ":: Restarting. New IP: " + newIP); - LocalBroadcasts.displayToast(this, getString(R.string.changed_network), 1); - currentIPInfo = newInfo; - // Regenerate cert - Authenticator.getServerCertificate(); - // Restart server - server.Stop(false); //Not async, so it finishes before we restart - if (Authenticator.certException == null) - server.Start(); - else Log.w(TAG, "No cert. Server not started."); - } - } - - String getCurrentIPStr() { - return currentIPInfo == null ? null : currentIPInfo.address.getHostAddress(); - } - - private void updateNotification() { - if (notifBuilder == null) { - notifBuilder = new NotificationCompat.Builder(this, CHANNEL_PROGRESS); - notifBuilder.setSmallIcon(R.drawable.ic_notification) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW); - } - runningTransfers = 0; - long bytesDone = 0; - long bytesTotal = 0; - long bytesPerSecond = 0; - for (Remote r : remotes.values()) { - for (Transfer t : r.transfers) { - if (t.getStatus() == Transfer.Status.TRANSFERRING) { - runningTransfers++; - bytesDone += t.bytesTransferred; - bytesTotal += t.totalSize; - bytesPerSecond += t.bytesPerSecond; - } - } - } - int progress = (int)((float)bytesDone / bytesTotal * 1000f); - if (runningTransfers > 0) { - notifBuilder.setOngoing(true); - notifBuilder.setProgress(1000, progress, false); - notifBuilder.setContentTitle(String.format(Locale.getDefault(), getString(R.string.transfer_notification), - progress/10f, runningTransfers, Formatter.formatFileSize(this, bytesPerSecond))); - } else { - notifBuilder.setProgress(0, 0, false); - notifBuilder.setContentTitle(getString(R.string.transfers_complete)); - notifBuilder.setOngoing(false); - scheduleAutoStop(); - if (wakeLock.isHeld()) { - Log.i(TAG, "Releasing transfer wake lock"); - wakeLock.release(); - } - } - if (runningTransfers > 0 || TransfersActivity.topmostRemote == null) - notificationMgr.notify(PROGRESS_NOTIFICATION_ID, notifBuilder.build()); - else notificationMgr.cancel(PROGRESS_NOTIFICATION_ID); - } - - private void pingRemotes() { - try { - for (Remote r : remotes.values()) { - if ((r.api == 1) && (r.status == Remote.RemoteStatus.CONNECTED)) { - r.ping(); - } - } - } catch (ConcurrentModificationException ignored) {} - } - - private void autoReconnect() { - if (!gotNetwork()) - return; - try { - for (Remote r : remotes.values()) { - if ((r.status == Remote.RemoteStatus.DISCONNECTED || r.status == Remote.RemoteStatus.ERROR) - && r.serviceAvailable && !r.errorGroupCode) { - // Try reconnecting - Log.d(TAG, "Automatically reconnecting to " + r.hostname); - r.connect(); - } - } - } catch (ConcurrentModificationException ignored) {} - } - - private Notification createForegroundNotification() { - Intent openIntent = new Intent(this, MainActivity.class); - openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - int immutable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0; - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, openIntent, immutable); - - Intent stopIntent = new Intent(this, StopSvcReceiver.class); - stopIntent.setAction(ACTION_STOP); - PendingIntent stopPendingIntent = - PendingIntent.getBroadcast(this, 0, stopIntent, immutable); - - String notificationTitle = getString(R.string.warpinator_notification_title); - String notificationButton = getString(R.string.warpinator_notification_button); - return new NotificationCompat.Builder(this, CHANNEL_SERVICE) - .setContentTitle(notificationTitle) - .setSmallIcon(R.drawable.ic_notification) - .setContentIntent(pendingIntent) - .addAction(0, notificationButton, stopPendingIntent) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setShowWhen(false) - .setOngoing(true).build(); - } - - private void createNotificationChannels() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = getString(R.string.service_running); - String description = getString(R.string.notification_channel_description); - int importance = NotificationManager.IMPORTANCE_LOW; - NotificationChannel channel = new NotificationChannel(CHANNEL_SERVICE, name, importance); - channel.setDescription(description); - - CharSequence name2 = getString(R.string.incoming_transfer_channel); - int importance2 = NotificationManager.IMPORTANCE_HIGH; - NotificationChannel channel2 = new NotificationChannel(CHANNEL_INCOMING, name2, importance2); - - CharSequence name3 = getString(R.string.transfer_progress_channel); - int importance3 = NotificationManager.IMPORTANCE_LOW; - NotificationChannel channel3 = new NotificationChannel(CHANNEL_PROGRESS, name3, importance3); - - NotificationManager notificationManager = getSystemService(NotificationManager.class); - assert notificationManager != null; - notificationManager.createNotificationChannel(channel); - notificationManager.createNotificationChannel(channel2); - notificationManager.createNotificationChannel(channel3); - } - } - - private void acquireMulticastLock() { - WifiManager wifi = (WifiManager)getApplicationContext() - .getSystemService(android.content.Context.WIFI_SERVICE); - if (wifi != null) { - lock = wifi.createMulticastLock("WarpMDNSLock"); - lock.setReferenceCounted(true); - lock.acquire(); - Log.d(TAG, "Multicast lock acquired"); - } - } - - private boolean isAutoStopEnabled() { - if (prefs == null) - return false; - return prefs.getBoolean("autoStop", true) && !prefs.getBoolean("bootStart", false); - } - - public void notifyDeviceCountUpdate() { - int count = 0; - Collection remotes = MainService.remotes.values(); - for (Remote remote : remotes) { - if (remote.status == Remote.RemoteStatus.CONNECTED) { - count++; - } - } - - for (WeakReference observer : remoteCountObservers) { - RemoteCountObserver obs = observer.get(); - if (obs != null) { - obs.onDeviceCountChange(count); - } - } - } - - public DisposableRemoteCountObserver observeDeviceCount(RemoteCountObserver observer) { - remoteCountObservers.add(new WeakReference<>(observer)); - observer.onDeviceCountChange(remotes.size()); - - return () -> { - remoteCountObservers.remove(new WeakReference<>(observer)); - }; - } - - public interface RemoteCountObserver { - void onDeviceCountChange(int newCount); - } - - public interface DisposableRemoteCountObserver { - void dispose(); - } - - public static class StopSvcReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (ACTION_STOP.equals(intent.getAction())) { - context.stopService(new Intent(context, MainService.class)); - LocalBroadcasts.closeAll(context); - } - } - } -} diff --git a/app/src/main/java/slowscript/warpinator/Remote.java b/app/src/main/java/slowscript/warpinator/Remote.java deleted file mode 100644 index 5e73bad0..00000000 --- a/app/src/main/java/slowscript/warpinator/Remote.java +++ /dev/null @@ -1,488 +0,0 @@ -package slowscript.warpinator; - -import android.annotation.SuppressLint; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.util.Base64; -import android.util.Log; - -import com.google.protobuf.ByteString; - -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.SSLException; - -import io.grpc.ConnectivityState; -import io.grpc.ManagedChannel; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import io.grpc.okhttp.OkHttpChannelBuilder; -import io.grpc.stub.StreamObserver; - -public class Remote { - public enum RemoteStatus { - CONNECTED, - DISCONNECTED, - CONNECTING, //Connect thread running -> Don't touch data!! - ERROR, //Failed to connect - AWAITING_DUPLEX - } - public static class RemoteFeatures { - public static final int TEXT_MESSAGES = 1; - } - - static String TAG = "Remote"; - - public InetAddress address; - public int port; - public int authPort; - public int api = 1; - public String serviceName; //Zeroconf service name, also uuid - public String userName = ""; - public String hostname; - public String displayName; - public String uuid; - public Bitmap picture; - public volatile RemoteStatus status; - public boolean serviceAvailable; - public boolean staticService = false; - public boolean supportsMessages = false; - - //Error flags - public boolean errorGroupCode = false; //Shown once by RemotesAdapter or TransfersActivity - public boolean errorReceiveCert = false; //Shown every time remote is opened until resolved - public String errorText = ""; - - ArrayList transfers = new ArrayList<>(); - - ManagedChannel channel; - WarpGrpc.WarpBlockingStub blockingStub; - WarpGrpc.WarpStub asyncStub; - - public void connect() { - Log.i(TAG, "Connecting to " + hostname + ", api " + api); - status = RemoteStatus.CONNECTING; - updateUI(); - new Thread(() -> { - //Receive certificate - if (!receiveCertificate()) { - status = RemoteStatus.ERROR; - if (errorGroupCode) - errorText = MainService.svc.getString(R.string.wrong_group_code); - else - errorText = "Couldn't receive certificate - check firewall"; - updateUI(); - return; - } - Log.d(TAG, "Certificate for " + hostname + " received and saved"); - - //Connect - try { - OkHttpChannelBuilder builder = OkHttpChannelBuilder.forAddress(address.getHostAddress(), port) - .sslSocketFactory(Authenticator.createSSLSocketFactory(uuid)) - .flowControlWindow(1280*1024); - if (api >= 2) { - builder.keepAliveWithoutCalls(true) - .keepAliveTime(11, TimeUnit.SECONDS) - .keepAliveTimeout(5, TimeUnit.SECONDS); - } - if (channel != null && !channel.isShutdown()) - channel.shutdown(); //just in case - channel = builder.build(); - if (api >= 2) - channel.notifyWhenStateChanged(channel.getState(true), this::onChannelStateChanged); - blockingStub = WarpGrpc.newBlockingStub(channel); - asyncStub = WarpGrpc.newStub(channel); - // Ensure connection is created, otherwise give correct error (not "duplex failed") - blockingStub.withDeadlineAfter(5, TimeUnit.SECONDS).ping(WarpProto.LookupName.newBuilder().setId(Server.current.uuid).build()); - } catch (SSLException e) { - Log.e(TAG, "Authentication with remote "+ hostname +" failed: " + e.getMessage(), e); - status = RemoteStatus.ERROR; - errorText = "SSLException: " + e.getLocalizedMessage(); - updateUI(); - return; - } catch (Exception e) { - Log.e(TAG, "Failed to connect to remote " + hostname + ". " + e.getMessage(), e); - status = RemoteStatus.ERROR; - errorText = e.toString(); - updateUI(); - return; - } finally { - // Clean up channel on failure - if (channel != null && status == RemoteStatus.ERROR) - channel.shutdownNow(); - } - - status = RemoteStatus.AWAITING_DUPLEX; - updateUI(); - - //Get duplex - if (!waitForDuplex()) { - Log.e(TAG, "Couldn't establish duplex with " + hostname); - status = RemoteStatus.ERROR; - errorText = MainService.svc.getString(R.string.error_no_duplex); - updateUI(); - channel.shutdown(); - return; - } - - //Connection ready - status = RemoteStatus.CONNECTED; - - //Get name - try { - WarpProto.RemoteMachineInfo info = blockingStub.getRemoteMachineInfo(WarpProto.LookupName.getDefaultInstance()); - displayName = info.getDisplayName(); - userName = info.getUserName(); - supportsMessages = (info.getFeatureFlags() & RemoteFeatures.TEXT_MESSAGES) != 0; - } catch (StatusRuntimeException ex) { - status = RemoteStatus.ERROR; - errorText = "Couldn't get username: " + ex.toString(); - Log.e(TAG, "connect: cannot get name: connection broken?", ex); - updateUI(); - channel.shutdown(); - return; - } - //Get avatar - try { - Iterator avatar = blockingStub.getRemoteMachineAvatar(WarpProto.LookupName.getDefaultInstance()); - ByteString bs = avatar.next().getAvatarChunk(); - while (avatar.hasNext()) { - WarpProto.RemoteMachineAvatar a = avatar.next(); - bs.concat(a.getAvatarChunk()); - } - byte[] bytes = bs.toByteArray(); - picture = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - } catch (Exception e) { //Profile picture not found, etc. - picture = null; - } - - updateUI(); - Log.i(TAG, "Connection established with " + hostname); - }).start(); - } - - public void disconnect() { - Log.i(TAG, "Disconnecting " + hostname); - try { - channel.shutdownNow(); - } catch (Exception ignored){} - status = RemoteStatus.DISCONNECTED; - } - - private void onChannelStateChanged() { - ConnectivityState state = channel.getState(false); - Log.d(TAG, "onChannelStateChanged: " + hostname + " -> " + state); - if (state == ConnectivityState.TRANSIENT_FAILURE || state == ConnectivityState.IDLE) { - status = RemoteStatus.DISCONNECTED; - updateUI(); - channel.shutdown(); //Dispose of channel so it can be recreated if device comes back - } - channel.notifyWhenStateChanged(state, this::onChannelStateChanged); - } - - @SuppressWarnings("ResultOfMethodCallIgnored") - @SuppressLint("CheckResult") - public void ping() { - try { - //Log.v(TAG, "Pinging " + hostname); - blockingStub.withDeadlineAfter(10L, TimeUnit.SECONDS).ping(WarpProto.LookupName.newBuilder().setId(Server.current.uuid).build()); - } catch (Exception e) { - Log.d(TAG, "ping: Failed with exception", e); - status = RemoteStatus.DISCONNECTED; - updateUI(); - channel.shutdown(); - } - } - - public boolean isFavorite() { - return Server.current.favorites.contains(uuid); - } - - // This does not update uuid, ip and authPort - void updateFromServiceRegistration(WarpProto.ServiceRegistration reg) { - hostname = reg.getHostname(); - api = reg.getApiVersion(); - port = reg.getPort(); - serviceName = reg.getServiceId(); - //r.serviceAvailable = true; - staticService = true; - } - - boolean sameSubnetWarning() { - if (status == RemoteStatus.CONNECTED) - return false; - if (MainService.svc.currentIPInfo == null) - return false; - return !Utils.isSameSubnet(address, MainService.svc.currentIPInfo.address, MainService.svc.currentIPInfo.prefixLength); - } - - public Transfer findTransfer(long timestamp) { - for (Transfer t : transfers) { - if(t.startTime == timestamp) - return t; - } - return null; - } - - public void addTransfer(Transfer t) { - transfers.add(0, t); - updateTransferIdxs(); - } - - public void clearTransfers() { - Iterator txs = transfers.iterator(); - while (txs.hasNext()) { - Transfer t = txs.next(); - if (t.getStatus() == Transfer.Status.FINISHED || t.getStatus() == Transfer.Status.DECLINED || - t.getStatus() == Transfer.Status.FINISHED_WITH_ERRORS || t.getStatus() == Transfer.Status.FAILED || - t.getStatus() == Transfer.Status.STOPPED) - txs.remove(); - } - updateTransferIdxs(); - LocalBroadcasts.updateTransfers(MainService.svc, uuid); - } - - private void updateTransferIdxs() { - for (int i = 0; i < transfers.size(); i++) - transfers.get(i).privId = i; - } - - public void startSendTransfer(Transfer t) { - t.useCompression = Server.current.useCompression; - WarpProto.OpInfo info = WarpProto.OpInfo.newBuilder() - .setIdent(Server.current.uuid) - .setTimestamp(t.startTime) - .setReadableName(Utils.getDeviceName()) - .setUseCompression(t.useCompression) - .build(); - WarpProto.TransferOpRequest op = WarpProto.TransferOpRequest.newBuilder() - .setInfo(info) - .setSenderName("Android") - .setReceiver(uuid) - .setSize(t.totalSize) - .setCount(t.fileCount) - .setNameIfSingle(t.singleName) - .setMimeIfSingle(t.singleMime) - .addAllTopDirBasenames(t.topDirBasenames) - .build(); - asyncStub.processTransferOpRequest(op, new Utils.VoidObserver()); - } - - public void startReceiveTransfer(Transfer _t) { - new Thread(() -> { - Transfer t = _t; //_t gets garbage collected or something... - WarpProto.OpInfo info = WarpProto.OpInfo.newBuilder() - .setIdent(Server.current.uuid) - .setTimestamp(t.startTime) - .setReadableName(Utils.getDeviceName()) - .setUseCompression(t.useCompression).build(); - try { - Iterator i = blockingStub.startTransfer(info); - boolean cancelled = false; - while (i.hasNext() && !cancelled) { - WarpProto.FileChunk c = i.next(); - cancelled = !t.receiveFileChunk(c); - } - if (!cancelled) - t.finishReceive(); - } catch (StatusRuntimeException e) { - if (e.getStatus().getCode() == Status.Code.CANCELLED) { - Log.i(TAG, "Transfer cancelled", e); - t.setStatus(Transfer.Status.STOPPED); - } else { - Log.e(TAG, "Connection error", e); - t.errors.add("Connection error: " + e.getLocalizedMessage()); - t.setStatus(Transfer.Status.FAILED); - } - t.updateUI(); - } catch (Exception e) { - Log.e(TAG, "Transfer error", e); - t.errors.add("Transfer error: " + e.getLocalizedMessage()); - t.setStatus(Transfer.Status.FAILED); - t.updateUI(); - } - }).start(); - } - - public void sendTextMessage(Transfer t) { - WarpProto.TextMessage msg = WarpProto.TextMessage.newBuilder() - .setIdent(Server.current.uuid) - .setTimestamp(t.startTime) - .setMessage(t.message) - .build(); - asyncStub.sendTextMessage(msg, new StreamObserver<>() { - @Override public void onNext(WarpProto.VoidType value) {} - @Override public void onError(Throwable e) { - Log.e(TAG, "Failed to send message", e); - t.setStatus(Transfer.Status.FAILED); - t.errors.add("Failed to send message: " + e); - t.updateUI(); - } - @Override public void onCompleted() { - t.setStatus(Transfer.Status.FINISHED); - t.updateUI(); - } - }); - } - - public void declineTransfer(Transfer t) { - WarpProto.OpInfo info = WarpProto.OpInfo.newBuilder() - .setIdent(Server.current.uuid) - .setTimestamp(t.startTime) - .setReadableName(Utils.getDeviceName()) - .build(); - asyncStub.cancelTransferOpRequest(info, new Utils.VoidObserver()); - } - - public void stopTransfer(Transfer t, boolean error) { - WarpProto.OpInfo i = WarpProto.OpInfo.newBuilder() - .setIdent(Server.current.uuid) - .setTimestamp(t.startTime) - .setReadableName(Utils.getDeviceName()) - .build(); - WarpProto.StopInfo info = WarpProto.StopInfo.newBuilder() - .setError(error) - .setInfo(i) - .build(); - asyncStub.stopTransfer(info, new Utils.VoidObserver()); - } - - // -- PRIVATE HELPERS -- - - private boolean receiveCertificate() { - errorGroupCode = false; - if (api == 2) { - if (receiveCertificateV2()) - return true; // Otherwise fall back in case of old port config etc... - else if (errorGroupCode) - return false; - else - Log.d(TAG, "Falling back to receiveCertificateV1"); - } - return receiveCertificateV1(); - } - - private boolean receiveCertificateV1() { - byte[] received = null; - int tryCount = 0; - while (tryCount < 3) { - try (DatagramSocket sock = new DatagramSocket()) { - Log.v(TAG, "Receiving certificate from " + address.toString() + ", try " + tryCount); - sock.setSoTimeout(1500); - - byte[] req = CertServer.REQUEST.getBytes(); - DatagramPacket p = new DatagramPacket(req, req.length, address, port); - sock.send(p); - - byte[] receiveData = new byte[2000]; - DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); - sock.receive(receivePacket); - - if (receivePacket.getAddress().equals(address) && (receivePacket.getPort() == port)) { - received = Arrays.copyOfRange(receivePacket.getData(), 0, receivePacket.getLength()); - break; - } //Received from wrong host. Give it another shot. - } catch (Exception e) { - tryCount++; - Log.d(TAG, "receiveCertificate: attempt " + tryCount + " failed: " + e.getMessage()); - } - } - if (tryCount == 3) { - Log.e(TAG, "Failed to receive certificate from " + hostname); - errorReceiveCert = true; - return false; - } - byte[] decoded = Base64.decode(received, Base64.DEFAULT); - errorGroupCode = !Authenticator.saveBoxedCert(decoded, uuid); - if (errorGroupCode) - return false; - errorReceiveCert = false; - return true; - } - - private boolean receiveCertificateV2() { - Log.v(TAG, "Receiving certificate (V2) from " + hostname + " at " + address.toString()); - ManagedChannel authChannel = null; - try { - authChannel = OkHttpChannelBuilder.forAddress(address.getHostAddress(), authPort) - .usePlaintext().build(); - WarpProto.RegResponse resp = WarpRegistrationGrpc.newBlockingStub(authChannel) - .withWaitForReady() //This will retry connection after 1s, then after exp. delay - .withDeadlineAfter(8, TimeUnit.SECONDS) - .requestCertificate(WarpProto.RegRequest.newBuilder() - .setHostname(Utils.getDeviceName()) - .setIp(MainService.svc.getCurrentIPStr()).build() - ); - byte[] lockedCert = resp.getLockedCertBytes().toByteArray(); - byte[] decoded = Base64.decode(lockedCert, Base64.DEFAULT); - errorGroupCode = !Authenticator.saveBoxedCert(decoded, uuid); - if (errorGroupCode) - return false; - errorReceiveCert = false; - return true; - } catch (Exception e) { - Log.w(TAG, "Could not receive certificate from " + hostname, e); - errorReceiveCert = true; - } finally { - if (authChannel != null) - authChannel.shutdownNow(); - } - return false; - } - - private boolean waitForDuplex() { - if (api == 2) - return waitForDuplexV2(); - else return waitForDuplexV1(); - } - - private boolean waitForDuplexV1() { - Log.d(TAG, "Waiting for duplex - V1"); - int tries = 0; - while (tries < 10) { - try { - boolean haveDuplex = blockingStub.checkDuplexConnection( - WarpProto.LookupName.newBuilder() - .setId(Server.current.uuid).setReadableName("Android").build()) - .getResponse(); - if (haveDuplex) - return true; - } catch (Exception e) { - Log.d(TAG, "Error while checking duplex", e); - return false; - } - Log.d (TAG, "Attempt " + tries + ": No duplex"); - try { - Thread.sleep(3000); - } catch (InterruptedException e) { throw new RuntimeException(e); } - - tries++; - } - return false; - } - - private boolean waitForDuplexV2() { - Log.d(TAG, "Waiting for duplex - V2"); - try { - return blockingStub.withDeadlineAfter(10, TimeUnit.SECONDS) - .waitingForDuplex(WarpProto.LookupName.newBuilder() - .setId(Server.current.uuid) - .setReadableName(Utils.getDeviceName()).build()) - .getResponse(); - } catch (Exception e) { - Log.d(TAG, "Error while waiting for duplex", e); - return false; - } - } - - public void updateUI() { - LocalBroadcasts.updateRemotes(MainService.svc); - } -} diff --git a/app/src/main/java/slowscript/warpinator/RemotesAdapter.java b/app/src/main/java/slowscript/warpinator/RemotesAdapter.java deleted file mode 100644 index 46c2656e..00000000 --- a/app/src/main/java/slowscript/warpinator/RemotesAdapter.java +++ /dev/null @@ -1,103 +0,0 @@ -package slowscript.warpinator; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.res.ColorStateList; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.cardview.widget.CardView; -import androidx.recyclerview.widget.RecyclerView; - -public class RemotesAdapter extends RecyclerView.Adapter { - - Activity app; - - public RemotesAdapter(Activity _app) { - app = _app; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(app); - View view = inflater.inflate(R.layout.remote_view, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Remote r = MainService.remotes.get(MainService.remotesOrder.get(position)); - setupViewHolder(holder, r); - - holder.cardView.setOnClickListener((view) -> { - Intent i = new Intent(app, TransfersActivity.class); - i.putExtra("remote", r.uuid); - app.startActivity(i); - }); - } - - void setupViewHolder(ViewHolder holder, Remote r) { - holder.txtName.setText(r.displayName); - holder.txtUsername.setText(r.userName + "@" + r.hostname); - holder.txtIP.setText((r.sameSubnetWarning() ? "⚠ " : "") + r.address.getHostAddress() + ":" + r.port); - - Context context = holder.imgProfile.getContext(); - - int color = Utils.getAndroidAttributeColor(context, android.R.attr.textColorSecondary); - - if(r.picture != null) { - holder.imgProfile.setImageTintList(null); - holder.imgProfile.setImageBitmap(r.picture); - } else { - holder.imgProfile.setImageTintList(ColorStateList.valueOf(color)); - } - holder.imgStatus.setImageResource(Utils.getIconForRemoteStatus(r.status)); - if (r.status == Remote.RemoteStatus.ERROR || r.status == Remote.RemoteStatus.DISCONNECTED) { - if (!r.serviceAvailable) - holder.imgStatus.setImageResource(R.drawable.ic_unavailable); - else - color = Utils.getAttributeColor(context.getTheme(), androidx.appcompat.R.attr.colorError); - } - holder.imgStatus.setImageTintList(ColorStateList.valueOf(color)); - holder.imgFav.setVisibility(r.isFavorite() ? View.VISIBLE : View.INVISIBLE); - - holder.cardView.setVisibility(r.errorGroupCode ? View.GONE : View.VISIBLE); - var layout = holder.cardView.getLayoutParams(); - layout.height = r.errorGroupCode ? 0 : ViewGroup.LayoutParams.WRAP_CONTENT; - holder.cardView.setLayoutParams(layout); - } - - @Override - public int getItemCount() { - return MainService.remotes.size(); - } - - public class ViewHolder extends RecyclerView.ViewHolder{ - - CardView cardView; - TextView txtName; - TextView txtUsername; - TextView txtIP; - ImageView imgProfile; - ImageView imgStatus; - ImageView imgFav; - - public ViewHolder(@NonNull View itemView) { - super(itemView); - cardView = itemView.findViewById(R.id.cardView); - txtName = itemView.findViewById(R.id.txtName); - txtUsername = itemView.findViewById(R.id.txtUsername); - txtIP = itemView.findViewById(R.id.txtIP); - imgProfile = itemView.findViewById(R.id.imgProfile); - imgStatus = itemView.findViewById(R.id.imgStatus); - imgFav = itemView.findViewById(R.id.imgFav); - } - } -} diff --git a/app/src/main/java/slowscript/warpinator/Server.java b/app/src/main/java/slowscript/warpinator/Server.java deleted file mode 100644 index 375baf93..00000000 --- a/app/src/main/java/slowscript/warpinator/Server.java +++ /dev/null @@ -1,540 +0,0 @@ -package slowscript.warpinator; - -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.provider.MediaStore; -import android.text.TextUtils; -import android.util.Log; -import android.widget.Toast; - -import androidx.core.content.res.ResourcesCompat; - -import com.google.common.net.InetAddresses; -import com.google.protobuf.ByteString; - -import org.conscrypt.Conscrypt; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.net.Inet4Address; -import java.net.InetAddress; -import java.security.Security; -import java.security.cert.CertificateException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Random; -import java.util.concurrent.TimeUnit; - -import javax.jmdns.JmDNS; -import javax.jmdns.ServiceEvent; -import javax.jmdns.ServiceInfo; -import javax.jmdns.ServiceListener; -import javax.jmdns.impl.DNSIncoming; -import javax.jmdns.impl.DNSOutgoing; -import javax.jmdns.impl.DNSQuestion; -import javax.jmdns.impl.DNSRecord; -import javax.jmdns.impl.JmDNSImpl; -import javax.jmdns.impl.ServiceInfoImpl; -import javax.jmdns.impl.constants.DNSConstants; -import javax.jmdns.impl.constants.DNSRecordClass; -import javax.jmdns.impl.constants.DNSRecordType; -import javax.jmdns.impl.tasks.resolver.ServiceResolver; - -import io.grpc.ManagedChannel; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import io.grpc.netty.GrpcSslContexts; -import io.grpc.netty.NettyServerBuilder; -import io.grpc.okhttp.OkHttpChannelBuilder; -import io.netty.handler.ssl.SslContextBuilder; - -public class Server { - private static final String TAG = "SRV"; - public static final String SERVICE_TYPE = "_warpinator._tcp.local."; - public static final String NETIFACE_AUTO = "auto"; - public static final int SERVER_FEATURES = Remote.RemoteFeatures.TEXT_MESSAGES; - - public static Server current; - public String displayName; - public int port; - public int authPort; - public static String iface; - public String uuid; - public String profilePicture; - public boolean allowOverwrite; - public boolean notifyIncoming; - public String downloadDirUri; - public boolean running = false; - public HashSet favorites = new HashSet<>(); - public ArrayList recentRemotes = new ArrayList<>(); // recent manually connected remotes - public boolean useCompression; - - JmDNS jmdns; - private ServiceInfo serviceInfo; - private final ServiceListener serviceListener; - private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; - private io.grpc.Server gServer; - private io.grpc.Server regServer; - private int apiVersion = 2; - - private final MainService svc; - - public Server(MainService _svc) { - svc = _svc; - current = this; - loadSettings(); - - serviceListener = newServiceListener(); - preferenceChangeListener = (p, k) -> loadSettings(); - } - - public void Start() { - Log.i(TAG, "--- Starting server"); - running = true; - startGrpcServer(); - startRegistrationServer(); - CertServer.Start(port); - svc.executor.submit(this::startMDNS); - svc.prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); - LocalBroadcasts.updateNetworkState(svc); - } - - public void Stop(boolean async) { - running = false; - CertServer.Stop(); - if (async) - svc.executor.submit(this::stopMDNS); //This takes a long time and we may be on the main thread - else stopMDNS(); - svc.prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); - if (gServer != null) - gServer.shutdownNow(); - if (regServer != null) - regServer.shutdownNow(); - if (!async && gServer != null) { - try { - gServer.awaitTermination(); - } catch (InterruptedException ignored) {} - } - LocalBroadcasts.updateNetworkState(svc); - Log.i(TAG, "--- Server stopped"); - } - - void startMDNS() { - try { - InetAddress addr = svc.currentIPInfo.address; - Log.d(TAG, "Starting mDNS on " + addr); - jmdns = JmDNS.create(addr); - - registerService(false); - Utils.sleep(500); - //Start looking for others - jmdns.addServiceListener(SERVICE_TYPE, serviceListener); - } - catch (Exception e) { - running = false; - Log.e(TAG, "Failed to init JmDNS", e); - LocalBroadcasts.displayToast(svc, "Failed to start JmDNS", 0); - } - } - - void stopMDNS() { - if (jmdns != null) { - try { - jmdns.unregisterAllServices(); - jmdns.removeServiceListener(SERVICE_TYPE, serviceListener); - jmdns.close(); - } catch (Exception e) { - Log.w(TAG, "Failed to close JmDNS", e); - } - } - } - - void loadSettings() { - if(!svc.prefs.contains("uuid")) - svc.prefs.edit().putString("uuid", Utils.generateServiceName()).apply(); - uuid = svc.prefs.getString("uuid", "default"); - displayName = svc.prefs.getString("displayName", "Android"); - port = Integer.parseInt(svc.prefs.getString("port", "42000")); - authPort = Integer.parseInt(svc.prefs.getString("authPort", "42001")); - iface = svc.prefs.getString("networkInterface", NETIFACE_AUTO); - Authenticator.groupCode = svc.prefs.getString("groupCode", Authenticator.DEFAULT_GROUP_CODE); - allowOverwrite = svc.prefs.getBoolean("allowOverwrite", false); - notifyIncoming = svc.prefs.getBoolean("notifyIncoming", true); - downloadDirUri = svc.prefs.getString("downloadDir", ""); - useCompression = svc.prefs.getBoolean("useCompression", false); - if(!svc.prefs.contains("profile")) - svc.prefs.edit().putString("profile", String.valueOf(new Random().nextInt(12))).apply(); - profilePicture = svc.prefs.getString("profile", "0"); - favorites.clear(); - favorites.addAll(svc.prefs.getStringSet("favorites", Collections.emptySet())); - recentRemotes.clear(); - String recentStr = svc.prefs.getString("recentRemotes", null); - if (recentStr != null) - recentRemotes.addAll(Arrays.asList(recentStr.split("\n"))); - - boolean bootStart = svc.prefs.getBoolean("bootStart", false); - boolean autoStop = svc.prefs.getBoolean("autoStop", true); - if (bootStart && autoStop) - svc.prefs.edit().putBoolean("autoStop", false).apply(); - } - - void saveFavorites() { - svc.prefs.edit().putStringSet("favorites", favorites).apply(); - } - - void addRecentRemote(String host, String hostname) { - String r = host + " | " + hostname; - recentRemotes.remove(r); // Ensure only one occurrence - recentRemotes.add(0, r); // Most recently used is first - if (recentRemotes.size() > 10) { - recentRemotes.remove(10); // Remove least recently used - } - svc.prefs.edit().putString("recentRemotes", TextUtils.join("\n", recentRemotes)).apply(); - } - - void startGrpcServer() { - try { - File cert = new File(Utils.getCertsDir(), ".self.pem"); - File key = new File(Utils.getCertsDir(), ".self.key-pem"); - SslContextBuilder ssl = GrpcSslContexts.forServer(cert, key).sslContextProvider(Conscrypt.newProvider()); - gServer = NettyServerBuilder.forPort(port) - .sslContext(ssl.build()) - .addService(new GrpcService()) - .permitKeepAliveWithoutCalls(true) - .permitKeepAliveTime(5, TimeUnit.SECONDS) - .build(); - gServer.start(); - Log.d(TAG, "GRPC server started"); - } catch(Exception e) { - running = false; - if (e.getCause() instanceof CertificateException) { - Log.e(TAG, "Failed to initialize SSL context", e); - Toast.makeText(svc, "Failed to start service due to TLS error. Please contact the developers.", Toast.LENGTH_LONG).show(); - return; - } - Log.e(TAG, "Failed to start GRPC server.", e); - Toast.makeText(svc, "Failed to start GRPC server. Please try rebooting your phone or changing port numbers.", Toast.LENGTH_LONG).show(); - } - } - - void startRegistrationServer() { - try { - regServer = NettyServerBuilder.forPort(authPort) - .addService(new RegistrationService()) - .build(); - regServer.start(); - Log.d(TAG, "Registration server started"); - } catch(Exception e) { - apiVersion = 1; - Log.w(TAG, "Failed to start V2 registration service.", e); - Toast.makeText(svc, "Failed to start V2 registration service. Only V1 will be available.", Toast.LENGTH_LONG).show(); - } - } - - void registerService(boolean flush) { - serviceInfo = ServiceInfo.create(SERVICE_TYPE, uuid, port, ""); - - Map props = new HashMap<>(); - props.put("hostname", Utils.getDeviceName()); - String type = flush ? "flush" : "real"; - props.put("type", type); - props.put("api-version", String.valueOf(apiVersion)); - props.put("auth-port", String.valueOf(authPort)); - serviceInfo.setText(props); - - // Unregister possibly leftover service info - // -> Announcement will trigger "new service" behavior and reconnect on other clients - unregister(); //Safe if fails - Utils.sleep(250); - try { - Log.d(TAG, "Registering as " + uuid); - jmdns.registerService(serviceInfo); - } catch (IOException e) { - Log.e(TAG, "Failed to register service.", e); - } - } - - WarpProto.ServiceRegistration getServiceRegistrationMsg() { - return WarpProto.ServiceRegistration.newBuilder() - .setServiceId(uuid) - .setIp(svc.getCurrentIPStr()) - .setPort(port) - .setHostname(Utils.getDeviceName()) - .setApiVersion(apiVersion) - .setAuthPort(authPort) - .build(); - } - - void registerWithHost(String host) { - svc.executor.submit(() -> { - Log.d(TAG, "Registering with host " + host); - ManagedChannel channel = null; - try { - int sep = host.lastIndexOf(':'); - // Use ip and authPort as specified by user - String IP = host.substring(0,sep); - int aport = Integer.parseInt(host.substring(sep+1)); - InetAddress ia = InetAddress.getByName(IP); - if (!Utils.isSameSubnet(ia, MainService.svc.currentIPInfo.address, MainService.svc.currentIPInfo.prefixLength)) - LocalBroadcasts.displayToast(svc, svc.getString(R.string.warning_subnet), Toast.LENGTH_LONG); - - channel = OkHttpChannelBuilder.forTarget(host).usePlaintext().build(); - WarpProto.ServiceRegistration resp = WarpRegistrationGrpc.newBlockingStub(channel) - .registerService(getServiceRegistrationMsg()); - Log.d(TAG, "registerWithHost: registration sent"); - addRecentRemote(host, resp.getHostname()); - Remote r = MainService.remotes.get(resp.getServiceId()); - boolean newRemote = r == null; - if (newRemote) { - r = new Remote(); - r.uuid = resp.getServiceId(); - } else if (r.status == Remote.RemoteStatus.CONNECTED) { - Log.w(TAG, "registerWithHost: remote already connected"); - LocalBroadcasts.displayToast(svc, "Device already connected", Toast.LENGTH_SHORT); - return; - } - r.address = InetAddresses.forString(IP); - r.authPort = aport; - r.updateFromServiceRegistration(resp); - if (newRemote) { - addRemote(r); - Log.d(TAG, "registerWithHost: remote added"); - } else { - if (r.status == Remote.RemoteStatus.DISCONNECTED || r.status == Remote.RemoteStatus.ERROR) - r.connect(); - else r.updateUI(); - } - } catch (Exception e) { - if (e instanceof StatusRuntimeException && ((StatusRuntimeException)e).getStatus() == Status.Code.UNIMPLEMENTED.toStatus()) { - Log.e(TAG, "Host " + host + " does not support manual connect -- " + e); - LocalBroadcasts.displayToast(svc, "Host " + host + " does not support manual connect", Toast.LENGTH_LONG); - } else { - Log.e(TAG, "Failed to connect to " + host, e); - LocalBroadcasts.displayToast(svc, "Failed to connect to " + host + " - " + e, Toast.LENGTH_LONG); - } - } finally { - if (channel != null) - channel.shutdown(); - } - }); - } - - void reannounce() { - svc.executor.submit(()->{ - Log.d(TAG, "Reannouncing"); - try { - DNSOutgoing out = new DNSOutgoing(DNSConstants.FLAGS_QR_RESPONSE | DNSConstants.FLAGS_AA); - for (DNSRecord answer : ((JmDNSImpl)jmdns).getLocalHost().answers(DNSRecordClass.CLASS_ANY, DNSRecordClass.UNIQUE, DNSConstants.DNS_TTL)) { - out = dnsAddAnswer(out, null, answer); - } - for (DNSRecord answer : ((ServiceInfoImpl) serviceInfo).answers(DNSRecordClass.CLASS_ANY, - DNSRecordClass.UNIQUE, DNSConstants.DNS_TTL, ((JmDNSImpl)jmdns).getLocalHost())) { - out = dnsAddAnswer(out, null, answer); - } - ((JmDNSImpl)jmdns).send(out); - } catch (Exception e) { - Log.e(TAG, "Reannounce failed", e); - LocalBroadcasts.displayToast(svc, "Reannounce failed: " + e.getMessage(), Toast.LENGTH_LONG); - } - }); - } - - void rescan() { - svc.executor.submit(()->{ - Log.d(TAG, "Rescanning"); - //Need a new one every time since it can only run three times - ServiceResolver resolver = new ServiceResolver((JmDNSImpl)jmdns, SERVICE_TYPE); - DNSOutgoing out = new DNSOutgoing(DNSConstants.FLAGS_QR_QUERY); - try { - out = resolver.addQuestion(out, DNSQuestion.newQuestion(SERVICE_TYPE, DNSRecordType.TYPE_PTR, DNSRecordClass.CLASS_IN, DNSRecordClass.NOT_UNIQUE)); - //out = resolver.addQuestion(out, DNSQuestion.newQuestion(SERVICE_TYPE, DNSRecordType.TYPE_TXT, DNSRecordClass.CLASS_IN, DNSRecordClass.NOT_UNIQUE)); - ((JmDNSImpl) jmdns).send(out); - } catch (Exception e) { - Log.e(TAG, "Rescan failed", e); - LocalBroadcasts.displayToast(svc, "Rescan failed: " + e.getMessage(), Toast.LENGTH_LONG); - } - }); - } - - void unregister() { - Log.d(TAG, "Unregistering"); - try { - DNSOutgoing out = new DNSOutgoing(DNSConstants.FLAGS_QR_RESPONSE | DNSConstants.FLAGS_AA); - for (DNSRecord answer : ((ServiceInfoImpl) serviceInfo).answers(DNSRecordClass.CLASS_ANY, - DNSRecordClass.UNIQUE, 0, ((JmDNSImpl)jmdns).getLocalHost())) { - out = dnsAddAnswer(out, null, answer); - } - ((JmDNSImpl)jmdns).send(out); - } catch (Exception e) { - Log.e(TAG, "Unregistering failed", e); - LocalBroadcasts.displayToast(svc, "Unregistering failed: " + e.getMessage(), Toast.LENGTH_LONG); - } - } - - DNSOutgoing dnsAddAnswer(DNSOutgoing out, DNSIncoming in, DNSRecord rec) throws IOException { - DNSOutgoing newOut = out; - try { - newOut.addAnswer(in, rec); - } catch (final IOException e) { - int flags = newOut.getFlags(); - newOut.setFlags(flags | DNSConstants.FLAGS_TC); - ((JmDNSImpl)jmdns).send(newOut); - - newOut = new DNSOutgoing(flags, newOut.isMulticast(), newOut.getMaxUDPPayload()); - newOut.addAnswer(in, rec); - } - return newOut; - } - - void addRemote(Remote remote) { - //Add to remotes list - MainService.remotes.put(remote.uuid, remote); - svc.notifyDeviceCountUpdate(); - if (favorites.contains(remote.uuid)) //Add favorites at the beginning - MainService.remotesOrder.add(0, remote.uuid); - else - MainService.remotesOrder.add(remote.uuid); - //Connect to it - remote.connect(); - } - - ServiceListener newServiceListener() { - return new ServiceListener() { - @Override - public void serviceAdded(ServiceEvent event) { - Log.d(TAG, "Service found: " + event.getInfo()); - } - - @Override - public void serviceRemoved(ServiceEvent event) { - String svcName = event.getInfo().getName(); - Log.v(TAG, "Service lost: " + svcName); - if (MainService.remotes.containsKey(svcName)) { - Remote r = MainService.remotes.get(svcName); - r.serviceAvailable = false; - r.updateUI(); - } - } - - @Override - public void serviceResolved(ServiceEvent event) { - ServiceInfo info = event.getInfo(); - Log.d(TAG, "*** Service resolved: " + info.getName()); - Log.d(TAG, "Details: " + info); - if (info.getName().equals(uuid)) { - Log.v(TAG, "That's me. Ignoring."); - return; - } - //TODO: Same subnet check - - //Ignore flush registration - ArrayList props = Collections.list(info.getPropertyNames()); - if (props.contains("type") && "flush".equals(info.getPropertyString("type"))) { - Log.v(TAG, "Ignoring \"flush\" registration"); - return; - } - if (!props.contains("hostname")) { - Log.d(TAG, "Ignoring incomplete service info. (no hostname, might be resolved later)"); - return; - } - - String svcName = info.getName(); - if (MainService.remotes.containsKey(svcName)) { - Remote r = MainService.remotes.get(svcName); - Log.d(TAG, "Service already known. Status: " + r.status); - r.hostname = info.getPropertyString("hostname"); - if(props.contains("auth-port")) - r.authPort = Integer.parseInt(info.getPropertyString("auth-port")); - InetAddress addr = getIPv4Address(info.getInetAddresses()); - if (addr != null) - r.address = addr; - r.port = info.getPort(); - r.serviceAvailable = true; - if ((r.status == Remote.RemoteStatus.DISCONNECTED) || (r.status == Remote.RemoteStatus.ERROR)) { - Log.d(TAG, "Reconnecting to " + r.hostname); - r.connect(); - } else r.updateUI(); - return; - } - - Remote remote = new Remote(); - InetAddress addr = getIPv4Address(info.getInetAddresses()); - if (addr != null) - remote.address = addr; - else { - //remote.address = info.getInetAddresses()[0]; - Log.w(TAG, "Service resolved with no IPv4 address. Most implementations don't properly support IPv6."); - return; - } - remote.hostname = info.getPropertyString("hostname"); - if(props.contains("api-version")) - remote.api = Integer.parseInt(info.getPropertyString("api-version")); - if(props.contains("auth-port")) - remote.authPort = Integer.parseInt(info.getPropertyString("auth-port")); - remote.port = info.getPort(); - remote.serviceName = svcName; - remote.uuid = svcName; - remote.serviceAvailable = true; - - addRemote(remote); - } - }; - } - - public static Bitmap getProfilePicture(String picture, Context ctx) { - int[] colors = new int[]{0xfff44336, 0xffe91e63, 0xff9c27b0, 0xff3f51b5, 0xff2196f3, 0xff4caf50, - 0xff8bc34a, 0xffcddc39, 0xffffeb3b, 0xffffc107, 0xffff9800, 0xffff5722}; - if (picture.startsWith("content")) { - // Legacy: load from persisted uri - try { - return MediaStore.Images.Media.getBitmap(ctx.getContentResolver(), Uri.parse(picture)); - } catch (Exception e) { - picture = "0"; - } - } else if ("profilePic.png".equals(picture)) { - try { - var is = ctx.openFileInput("profilePic.png"); - return BitmapFactory.decodeStream(is); - } catch (Exception e) { - Log.e(TAG, "Could not load profile pic", e); - picture = "0"; - } - } - int i = Integer.parseInt(picture); //Could be also a content uri in the future - Drawable foreground = ResourcesCompat.getDrawable(ctx.getResources(), R.drawable.ic_warpinator, null); - Bitmap bmp = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bmp); - Paint paint = new Paint(); - paint.setColor(colors[i]); - canvas.drawCircle(48,48,48, paint); - foreground.setBounds(0,0,96,96); - foreground.draw(canvas); - return bmp; - } - - ByteString getProfilePictureBytes() { - ByteArrayOutputStream os = new ByteArrayOutputStream(); - Bitmap bmp = getProfilePicture(profilePicture, svc); - bmp.compress(Bitmap.CompressFormat.PNG, 90, os); - return ByteString.copyFrom(os.toByteArray()); - } - - private InetAddress getIPv4Address(InetAddress[] addresses) { - for (InetAddress a : addresses){ - if (a instanceof Inet4Address) - return a; - } - return null; - } -} diff --git a/app/src/main/java/slowscript/warpinator/SettingsActivity.java b/app/src/main/java/slowscript/warpinator/SettingsActivity.java deleted file mode 100644 index 0a487e1e..00000000 --- a/app/src/main/java/slowscript/warpinator/SettingsActivity.java +++ /dev/null @@ -1,250 +0,0 @@ -package slowscript.warpinator; - -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.provider.DocumentsContract; -import android.text.InputType; -import android.util.Log; -import android.view.MenuItem; -import android.widget.Toast; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.preference.EditTextPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceGroup; -import androidx.preference.PreferenceScreen; -import androidx.preference.SwitchPreferenceCompat; - -import com.google.android.material.appbar.MaterialToolbar; - -import java.net.SocketException; -import java.util.Objects; - -import slowscript.warpinator.preferences.ListPreference; -import slowscript.warpinator.preferences.ResetablePreference; - -public class SettingsActivity extends AppCompatActivity { - - SettingsFragment fragment; - boolean pickDir; - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.settings_activity); - Utils.setEdgeToEdge(getWindow()); - fragment = new SettingsFragment(); - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.settings, fragment) - .commit(); - MaterialToolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - } - Utils.setToolbarInsets(toolbar); - Utils.setContentInsets(findViewById(R.id.settings)); - pickDir = getIntent().getBooleanExtra("pickDir", false); - } - - @Override - protected void onStart() { - super.onStart(); - if(pickDir) { - pickDir = false; //Only once - fragment.pickDirOnStart = true; - fragment.pickDirectory(); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Respond to the action bar's Up/Home button - if (item.getItemId() == android.R.id.home) { - super.onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - public static class SettingsFragment extends PreferenceFragmentCompat { - private static final int CHOOSE_ROOT_REQ_CODE = 10; - private static final String TAG = "Settings"; - private static final String INIT_URI = "content://com.android.externalstorage.documents/document/primary:"; - - private static final String DOWNLOAD_DIR_PREF = "downloadDir"; - private static final String PORT_PREF = "port"; - private static final String AUTH_PORT_PREF = "authPort"; - private static final String GROUPCODE_PREF = "groupCode"; - private static final String THEME_PREF = "theme_setting"; - private static final String PROFILE_PREF = "profile"; - private static final String DEBUGLOG_PREF = "debugLog"; - private static final String NETIFACE_PREF = "networkInterface"; - private static final String AUTOSTART_PREF = "bootStart"; - public boolean pickDirOnStart = false; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.root_preferences, rootKey); - var prefs = getPreferenceManager().getSharedPreferences(); - - EditTextPreference gcPref = findPreference(GROUPCODE_PREF); - SwitchPreferenceCompat debugPref = findPreference(DEBUGLOG_PREF); - ResetablePreference dlPref = findPreference(DOWNLOAD_DIR_PREF); - Preference themePref = findPreference(THEME_PREF); - Preference autostartPref = findPreference(AUTOSTART_PREF); - ListPreference networkIfacePref = findPreference(NETIFACE_PREF); - EditTextPreference portPref = findPreference(PORT_PREF); - EditTextPreference authPortPref = findPreference(AUTH_PORT_PREF); - portPref.setOnBindEditTextListener((edit)-> edit.setInputType(InputType.TYPE_CLASS_NUMBER)); - authPortPref.setOnBindEditTextListener((edit)-> edit.setInputType(InputType.TYPE_CLASS_NUMBER)); - - //Warn about preference not being applied immediately - for (Preference pref : new Preference[]{gcPref, debugPref}) { - pref.setOnPreferenceChangeListener((p,v) -> { - Toast.makeText(getContext(), R.string.requires_restart_warning, Toast.LENGTH_SHORT).show(); - return true; - }); - } - //Ensure port number is correct - Preference.OnPreferenceChangeListener onPortChanged = (p, val) -> { - int port = Integer.parseInt((String)val); - if (port > 65535 || port < 1024) { - Toast.makeText(getContext(), R.string.port_range_warning, Toast.LENGTH_LONG).show(); - return false; - } - Toast.makeText(getContext(), R.string.requires_restart_warning, Toast.LENGTH_SHORT).show(); - return true; - }; - portPref.setOnPreferenceChangeListener(onPortChanged); - authPortPref.setOnPreferenceChangeListener(onPortChanged); - //Change theme based on the new value - themePref.setOnPreferenceChangeListener((preference, newValue) -> { - switch (newValue.toString()){ - case "sysDefault": - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); - break; - case "lightTheme": - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - break; - case "darkTheme": - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - break; - } - return true; - }); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - autostartPref.setEnabled(false); - autostartPref.setSummary(getString(R.string.boot_settings_summary_a15)); - } - var autoStopPref = ((SwitchPreferenceCompat)findPreference("autoStop")); - if (prefs.getBoolean(AUTOSTART_PREF, false)) - autoStopPref.setEnabled(false); - autostartPref.setOnPreferenceChangeListener((p, val) -> { - if ((boolean)val) { - autoStopPref.setChecked(false); - autoStopPref.setEnabled(false); - } else autoStopPref.setEnabled(true); - return true; - }); - - String dlDest = prefs.getString(DOWNLOAD_DIR_PREF, ""); - dlPref.setSummary(Uri.parse(dlDest).getPath()); - dlPref.setOnPreferenceClickListener((p)->{ - pickDirectory(); - return true; - }); - dlPref.setOnResetListener((v)->{ - if (MainActivity.trySetDefaultDirectory(getActivity())) { - dlPref.setSummary(getPreferenceManager().getSharedPreferences().getString(DOWNLOAD_DIR_PREF, "")); - dlPref.setResetEnabled(false); - } - }); - dlPref.setResetEnabled(dlDest.startsWith("content")); - - var ifaces = Utils.getNetworkInterfaces(); - if (ifaces == null) ifaces = new String[] {"Failed to get network interfaces"}; - String[] newIfaces = new String[ifaces.length+1]; - newIfaces[0] = Server.NETIFACE_AUTO; - System.arraycopy(ifaces, 0, newIfaces, 1, ifaces.length); - String[] newIfaceNames = newIfaces.clone(); - for (int i = 1; i < newIfaceNames.length; i++) { - try { - var ip = Utils.getIPForIfaceName(newIfaceNames[i]); - if (ip != null) newIfaceNames[i] += " (" + ip.address.getHostAddress() + " /" + ip.prefixLength + ")"; - } catch (Exception ignored) {} - } - networkIfacePref.setEntries(newIfaceNames); - networkIfacePref.setEntryValues(newIfaces); - String curIface = prefs.getString(NETIFACE_PREF, Server.NETIFACE_AUTO); - networkIfacePref.setSummary(Server.NETIFACE_AUTO.equals(curIface) ? curIface : - getString(R.string.network_interface_settings_summary, curIface)); - networkIfacePref.setOnPreferenceChangeListener((pre, val) -> { - pre.setSummary(Server.NETIFACE_AUTO.equals(val) ? (String)val : - getString(R.string.network_interface_settings_summary, val)); - Toast.makeText(getContext(), R.string.requires_restart_warning, Toast.LENGTH_SHORT).show(); - return true; - }); - - PreferenceScreen screen = getPreferenceScreen(); - for (int i = 0; i < screen.getPreferenceCount(); i++) { - PreferenceGroup group = (PreferenceGroup) screen.getPreference(i); - group.setIconSpaceReserved(false); - for (int j = 0; j < group.getPreferenceCount(); j++) - group.getPreference(j).setIconSpaceReserved(false); - } - } - - public void pickDirectory() { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(INIT_URI)); - try { - startActivityForResult(intent, CHOOSE_ROOT_REQ_CODE); - } catch (ActivityNotFoundException e) { - Toast.makeText(getContext(), R.string.required_dialog_not_found, Toast.LENGTH_LONG).show(); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == CHOOSE_ROOT_REQ_CODE) { - if (data == null) - return; - Uri uri = data.getData(); - //Validate URI - Log.d(TAG, "Selected directory: " + uri); - if (uri == null || !Objects.equals(uri.getAuthority(), "com.android.externalstorage.documents")) { - Toast.makeText(getContext(), R.string.unsupported_provider, Toast.LENGTH_LONG).show(); - return; - } - ResetablePreference dlPref = findPreference(DOWNLOAD_DIR_PREF); - dlPref.setSummary(uri.getPath()); - dlPref.setResetEnabled(true); - getContext().getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - getPreferenceManager().getSharedPreferences().edit() - .putString(DOWNLOAD_DIR_PREF, uri.toString()) - .apply(); - - // Close activity if started only for initial dl directory selection - if (pickDirOnStart) { - pickDirOnStart = false; - getActivity().finish(); - } - else Toast.makeText(getContext(), R.string.warning_lastmod, Toast.LENGTH_LONG).show(); - } - } - } -} diff --git a/app/src/main/java/slowscript/warpinator/ShareActivity.java b/app/src/main/java/slowscript/warpinator/ShareActivity.java deleted file mode 100644 index 51b17c21..00000000 --- a/app/src/main/java/slowscript/warpinator/ShareActivity.java +++ /dev/null @@ -1,282 +0,0 @@ -package slowscript.warpinator; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.text.Editable; -import android.text.method.ScrollingMovementMethod; -import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.documentfile.provider.DocumentFile; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.appbar.MaterialToolbar; - -import java.io.File; -import java.util.ArrayList; - -public class ShareActivity extends AppCompatActivity { - - static final String TAG = "Share"; - - RecyclerView recyclerView; - LinearLayout layoutNotFound, layoutMessage; - RemotesAdapter adapter; - TextView txtError, txtNoNetwork, txtSharing; - EditText editMessage; - BroadcastReceiver receiver; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_share); - Utils.setEdgeToEdge(getWindow()); - MaterialToolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - setTitle(R.string.title_activity_share); - Utils.setToolbarInsets(toolbar); - Utils.setContentInsets(findViewById(R.id.layout), true); - receiver = newBroadcastReceiver(); - - //Get uris to send - Intent intent = getIntent(); - ArrayList uris; - String message = null; - if(Intent.ACTION_SEND.equals(intent.getAction())) { - Log.d(TAG, String.valueOf(intent)); - uris = new ArrayList<>(); - Uri u = intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (u != null) - uris.add(u); - else - message = intent.getStringExtra(Intent.EXTRA_TEXT); - } else if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) { - uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - } else { - Log.w(TAG, "Received unsupported intent: " + intent.getAction()); - Toast.makeText(this, R.string.unsupported_intent, Toast.LENGTH_LONG).show(); - finish(); - return; - } - - if (message == null && (uris == null || uris.isEmpty())) { - Log.d(TAG, "Nothing to share"); - Toast.makeText(this, R.string.nothing_to_share, Toast.LENGTH_LONG).show(); - finish(); - return; - } - boolean isTextMessage = message != null; - if (isTextMessage) - Log.d(TAG, "Sharing a text message - l" + message.length()); - else - Log.d(TAG, "Sharing " + uris.size() + " files"); - for (Uri u : uris) { - Log.v(TAG, u.toString()); - try { // This doesn't seem to work most of the time, we need a better solution - getContentResolver().takePersistableUriPermission(u, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } catch (Exception e) { - Log.w(TAG, "Uri permission was not persisted: " + e); - } - } - - //Start service (if not running) - if (!Utils.isMyServiceRunning(this, MainService.class)) - startMainService(); - - //Set up UI - recyclerView = findViewById(R.id.recyclerView); - layoutNotFound = findViewById(R.id.layoutNotFound); - layoutMessage = findViewById(R.id.messageLayout); - txtError = findViewById(R.id.txtError); - txtNoNetwork = findViewById(R.id.txtNoNetwork); - txtSharing = findViewById(R.id.txtSharing); - txtSharing.setMovementMethod(new ScrollingMovementMethod()); - String sharedFilesList = getString(R.string.files_being_sent); - for (int i = 0; i < uris.size(); i++) { - sharedFilesList += "\n " + Utils.getNameFromUri(this, uris.get(i)); - if (i >= 29) { - sharedFilesList += "\n + " + (uris.size() - 30); - break; - } - } - editMessage = findViewById(R.id.editMessage); - if (isTextMessage) { - txtSharing.setVisibility(View.INVISIBLE); - layoutMessage.setVisibility(View.VISIBLE); - editMessage.setText(message); - } else { - txtSharing.setVisibility(View.VISIBLE); - layoutMessage.setVisibility(View.INVISIBLE); - txtSharing.setText(sharedFilesList); - } - adapter = new RemotesAdapter(this) { - boolean sent = false; - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Remote remote = MainService.remotes.get(MainService.remotesOrder.get(position)); - setupViewHolder(holder, remote); - - //Send to selected remote - holder.cardView.setOnClickListener((view) -> { - if (remote.status != Remote.RemoteStatus.CONNECTED || sent) - return; - Transfer t = new Transfer(); - t.uris = uris; - t.remoteUUID = remote.uuid; - - remote.addTransfer(t); - if (!isTextMessage) { - t.setStatus(Transfer.Status.INITIALIZING); - new Thread(() -> { - t.prepareSend(false); - remote.startSendTransfer(t); - }).start(); - } else { - t.direction = Transfer.Direction.SEND; - t.message = editMessage.getText().toString(); - t.startTime = System.currentTimeMillis(); - t.setStatus(Transfer.Status.TRANSFERRING); - remote.sendTextMessage(t); - } - - Intent i = new Intent(app, TransfersActivity.class); - i.putExtra("remote", remote.uuid); - i.putExtra("shareMode", true); - app.startActivity(i); - sent = true; - }); - } - }; - recyclerView.setAdapter(adapter); - recyclerView.setLayoutManager(new LinearLayoutManager(this)); - layoutNotFound.setVisibility(MainService.remotes.size() == 0 ? View.VISIBLE : View.INVISIBLE); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - String dlDir = prefs.getString("downloadDir", ""); - boolean docFileExists = false; - try { - docFileExists = DocumentFile.fromTreeUri(this, Uri.parse(dlDir)).exists(); - } catch (Exception ignored) {} - if (dlDir.equals("") || !(new File(dlDir).exists() || docFileExists)) { - if (!MainActivity.trySetDefaultDirectory(this)) - MainActivity.askForDirectoryAccess(this); - } - } - - void startMainService() { - startService(new Intent(this, MainService.class)); - } - - @Override - protected void onResume() { - super.onResume(); - if (adapter == null) // If onCreate did not finish (nothing to share) - return; - updateRemotes(); - updateNetworkStateUI(); - IntentFilter f = new IntentFilter(LocalBroadcasts.ACTION_UPDATE_REMOTES); - f.addAction(LocalBroadcasts.ACTION_UPDATE_NETWORK); - f.addAction(LocalBroadcasts.ACTION_DISPLAY_MESSAGE); - f.addAction(LocalBroadcasts.ACTION_DISPLAY_TOAST); - f.addAction(LocalBroadcasts.ACTION_CLOSE_ALL); - LocalBroadcastManager.getInstance(this).registerReceiver(receiver, f); - } - - @Override - protected void onPause() { - super.onPause(); - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_share, menu); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int itemID = item.getItemId(); - if (itemID == R.id.manual_connect) { - MainActivity.manualConnectDialog(this); - } else if (itemID == R.id.reannounce) { - if (Server.current != null && Server.current.running) - Server.current.reannounce(); - else Toast.makeText(this, R.string.error_service_not_running, Toast.LENGTH_SHORT).show(); - } else if (itemID == R.id.rescan) { - if (Server.current != null) - Server.current.rescan(); - else Toast.makeText(this, R.string.error_service_not_running, Toast.LENGTH_SHORT).show(); - } else { - return super.onOptionsItemSelected(item); - } - return true; - } - - private BroadcastReceiver newBroadcastReceiver() { - Context ctx = this; - return new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (action == null) - return; - switch (action) { - case LocalBroadcasts.ACTION_UPDATE_REMOTES: - updateRemotes(); - break; - case LocalBroadcasts.ACTION_UPDATE_NETWORK: - updateNetworkStateUI(); - break; - case LocalBroadcasts.ACTION_DISPLAY_MESSAGE: - String title = intent.getStringExtra("title"); - String msg = intent.getStringExtra("msg"); - Utils.displayMessage(ctx, title, msg); - break; - case LocalBroadcasts.ACTION_DISPLAY_TOAST: - msg = intent.getStringExtra("msg"); - int length = intent.getIntExtra("length", 0); - Toast.makeText(ctx, msg, length).show(); - break; - case LocalBroadcasts.ACTION_CLOSE_ALL: - finishAffinity(); - break; - } - } - }; - } - - private void updateRemotes() { - recyclerView.post(() -> { - adapter.notifyDataSetChanged(); - layoutNotFound.setVisibility(MainService.remotes.size() == 0 ? View.VISIBLE : View.INVISIBLE); - }); - } - - private void updateNetworkStateUI() { - runOnUiThread(() -> { - if (MainService.svc != null) - txtNoNetwork.setVisibility(MainService.svc.gotNetwork() ? View.GONE : View.VISIBLE); - if (Server.current != null) - txtError.setVisibility(Server.current.running ? View.GONE : View.VISIBLE); - }); - } -} diff --git a/app/src/main/java/slowscript/warpinator/Transfer.java b/app/src/main/java/slowscript/warpinator/Transfer.java deleted file mode 100644 index 82576fba..00000000 --- a/app/src/main/java/slowscript/warpinator/Transfer.java +++ /dev/null @@ -1,755 +0,0 @@ -package slowscript.warpinator; - -import android.Manifest; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.media.MediaScannerConnection; -import android.media.RingtoneManager; -import android.net.Uri; -import android.os.Build; -import android.provider.DocumentsContract; -import android.util.Log; - -import androidx.core.app.ActivityCompat; -import androidx.core.app.NotificationCompat; -import androidx.documentfile.provider.DocumentFile; - -import com.google.common.base.Strings; -import com.google.common.io.Files; -import com.google.protobuf.ByteString; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URLConnection; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -import javax.annotation.Nullable; - -import io.grpc.StatusException; -import io.grpc.stub.CallStreamObserver; - -import static java.util.zip.Deflater.DEFAULT_COMPRESSION; -import static slowscript.warpinator.MainService.svc; - -public class Transfer { - public enum Direction { SEND, RECEIVE } - public enum Status { INITIALIZING, WAITING_PERMISSION, DECLINED, - TRANSFERRING, PAUSED, STOPPED, - FAILED, FAILED_UNRECOVERABLE, FILE_NOT_FOUND, FINISHED, FINISHED_WITH_ERRORS - } - static final class FileType { - static final int FILE = 1; static final int DIRECTORY = 2; static final int SYMLINK = 3; - } - - private static final String TAG = "TRANSFER"; - private static final int CHUNK_SIZE = 1024 * 512; //512 kB - private static final long UI_UPDATE_LIMIT = 250; - private static final String NOTIFICATION_GROUP_MESSAGE = "slowscript.warpinator.MESSAGE_NOTIFICATION"; - private static final String TMP_FILE_SUFFIX = ".warpinatortmp"; - - private final AtomicReference status = new AtomicReference<>(); - public Direction direction; - public String remoteUUID; - public long startTime; - public long totalSize; - public long fileCount; - public String singleName = ""; - public String singleMime = ""; - public List topDirBasenames; - public boolean useCompression = false; - int privId; - //SEND only - public ArrayList uris; - private ArrayList files; - private ArrayList dirs; - //RECEIVE only - private ArrayList recvdPaths; - //MESSAGE - public String message; // Transfer is message type when this is not null - - public boolean overwriteWarning = false; - - private String currentRelativePath; - private long currentLastMod = -1; - Uri currentUri; - File currentFile; - private OutputStream currentStream; - private boolean safeOverwriteFlag = false; - public final ArrayList errors = new ArrayList<>(); - private boolean cancelled = false; - public long bytesTransferred; - public long bytesPerSecond; - TransferSpeed transferSpeed = new TransferSpeed(24); - public long actualStartTime; - long lastBytes = 0; - long lastMillis = 0; - long lastUiUpdate = 0; - - // -- COMMON -- - public void stop(boolean error) { - Log.i(TAG, "Transfer stopped"); - try { - MainService.remotes.get(remoteUUID).stopTransfer(this, error); - } catch (NullPointerException ignored) {} //Service stopped and remotes cleared -> there must be a better solution - onStopped(error); - } - - public void onStopped(boolean error) { - Log.v(TAG, "Stopping transfer"); - if (!error) - setStatus(Status.STOPPED); - if (direction == Transfer.Direction.RECEIVE) - stopReceiving(); - else stopSending(); - updateUI(); - } - - public void makeDeclined() { - setStatus(Status.DECLINED); - updateUI(); - } - - public int getProgress() { - return (int)((float)bytesTransferred / totalSize * 100f); - } - - void updateUI() { - long now = System.currentTimeMillis(); - if (getStatus() == Status.TRANSFERRING && (now - lastUiUpdate) < UI_UPDATE_LIMIT) - return; - if (direction == Direction.SEND) { - long bps = (long) ((bytesTransferred - lastBytes) / ((now - lastUiUpdate) / 1000f)); - transferSpeed.add(bps); - bytesPerSecond = transferSpeed.getMovingAverage(); - lastBytes = bytesTransferred; - } - - lastUiUpdate = now; - LocalBroadcasts.updateTransfer(svc, remoteUUID, privId); - //Update notification - svc.updateProgress(); - } - - // -- SEND -- - public void prepareSend(boolean isdir) { - //Only uris and remoteUUID are set from before - direction = Direction.SEND; - startTime = System.currentTimeMillis(); - fileCount = uris.size(); - topDirBasenames = new ArrayList<>(); - files = new ArrayList<>(); - dirs = new ArrayList<>(); - for (Uri u : uris) { - String name = Utils.getNameFromUri(svc, u); - topDirBasenames.add(name); - if (isdir) { - String docId = DocumentsContract.getTreeDocumentId(u); - MFile topdir = new MFile(); - topdir.relPath = topdir.name = name; - topdir.isDirectory = true; - dirs.add(topdir); - resolveTreeUri(u, docId, name); //Get info about all child files - } else files.addAll(resolveUri(u)); //Get info about single file - } - fileCount = files.size() + dirs.size(); - if (fileCount == 1) { - singleName = Strings.nullToEmpty(topDirBasenames.get(0)); - singleMime = Strings.nullToEmpty(svc.getContentResolver().getType(uris.get(0))); - } - totalSize = getTotalSendSize(); - setStatus(Status.WAITING_PERMISSION); - updateUI(); - } - - // Gets all children of a document and adds them to files and dirs - private void resolveTreeUri(Uri rootUri, String docId, String parent) { - Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, docId); - ArrayList items = resolveUri(childrenUri); - for (MFile f : items) { - if (f.documentID == null) - break; //Provider is broken, what can we do... - f.uri = DocumentsContract.buildDocumentUriUsingTree(rootUri, f.documentID); - f.relPath = parent + "/" + f.name; - if (f.isDirectory) { - dirs.add(f); - resolveTreeUri(rootUri, f.documentID, f.relPath); - } - else files.add(f); - } - } - - // Get info about all documents represented by uri - could be just a single document - // or all children in case of special uri - private ArrayList resolveUri(Uri u) { - ArrayList mfs = new ArrayList<>(); - try (Cursor c = svc.getContentResolver().query(u, null, null, null, null)) { - if (c == null) { - Log.w(TAG, "Could not resolve uri: " + u); - return mfs; - } - int idCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID); - int nameCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME); - int mimeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE); - int mTimeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED); - int sizeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE); - - while (c.moveToNext()) { - MFile f = new MFile(); - if (idCol != -1) - f.documentID = c.getString(idCol); - else Log.w(TAG, "Could not get document ID"); - f.name = c.getString(nameCol); //Name is mandatory - if (mimeCol != -1) - f.mime = c.getString(mimeCol); - if (mimeCol == -1 || f.mime == null) { - Log.w(TAG, "Could not get MIME type"); - f.mime = "application/octet-stream"; - } - if (mTimeCol != -1) - f.lastMod = c.getLong(mTimeCol); - else - f.lastMod = -1; - f.length = c.getLong(sizeCol); //Size is mandatory - f.isDirectory = f.mime.endsWith("directory"); - f.uri = u; - f.relPath = f.name; - mfs.add(f); - } - } catch(Exception e) { - Log.e(TAG, "Could not query resolver: ", e); - } - return mfs; - } - - public void startSending(CallStreamObserver observer) { - setStatus(Status.TRANSFERRING); - Log.d(TAG, "Sending, compression " + useCompression); - actualStartTime = System.currentTimeMillis(); - transferSpeed = new TransferSpeed(8); - bytesTransferred = lastBytes = 0; - cancelled = false; - MainService.cancelAutoStop(); - Log.i(TAG, "Acquiring wake lock for " + MainService.WAKELOCK_TIMEOUT + " min"); - svc.wakeLock.acquire(MainService.WAKELOCK_TIMEOUT*60*1000L); - updateUI(); - observer.setOnReadyHandler(new Runnable() { - int i, iDir = 0; - InputStream is; - byte[] chunk = new byte[CHUNK_SIZE]; - boolean first_chunk = true; - - @Override - public void run() { - while (observer.isReady()) { - try { - if (cancelled) { //Exit if cancelled - observer.onError(new StatusException(io.grpc.Status.CANCELLED)); - is.close(); - return; - } - if (iDir < dirs.size()) { - WarpProto.FileChunk fc = WarpProto.FileChunk.newBuilder() - .setRelativePath(dirs.get(iDir).relPath) - .setFileType(FileType.DIRECTORY) - .setFileMode(0755) - .build(); - observer.onNext(fc); - iDir++; - continue; - } - if (is == null) { - is = svc.getContentResolver().openInputStream(files.get(i).uri); - first_chunk = true; - } - int read = is.read(chunk); - if (read < 1) { - is.close(); - is = null; - i++; - if (i >= files.size()) { - observer.onCompleted(); - setStatus(Status.FINISHED); - unpersistUris(); - updateUI(); - } - continue; - } - WarpProto.FileTime ft = WarpProto.FileTime.getDefaultInstance(); - if (first_chunk) { - first_chunk = false; - long lastmod = files.get(i).lastMod; - if (lastmod > 0) // lastmod 0 is likely invalid - ft = WarpProto.FileTime.newBuilder().setMtime(lastmod / 1000).setMtimeUsec((int)(lastmod % 1000) * 1000).build(); - else Log.w(TAG, "File doesn't have lastmod"); - } - byte[] toSend = chunk; - int chunkLen = read; - if (useCompression) { - toSend = ZlibCompressor.compress(chunk, read, DEFAULT_COMPRESSION); - chunkLen = toSend.length; - } - WarpProto.FileChunk fc = WarpProto.FileChunk.newBuilder() - .setRelativePath(files.get(i).relPath) - .setFileType(FileType.FILE) - .setChunk(ByteString.copyFrom(toSend, 0, chunkLen)) - .setFileMode(0644) - .setTime(ft) - .build(); - observer.onNext(fc); - bytesTransferred += read; - updateUI(); - } catch (FileNotFoundException e) { - observer.onError(new StatusException(io.grpc.Status.NOT_FOUND)); - errors.add("Not found: " + e.getMessage()); - setStatus(Status.FAILED); - updateUI(); - return; - } catch (Exception e) { - Log.e(TAG, "Error sending files", e); - setStatus(Status.FAILED); - errors.add("Unknown error: " + e.getMessage()); - updateUI(); - observer.onError(e); - return; - } - } - } - }); - } - - private void stopSending() { - cancelled = true; - } - - private void unpersistUris() { - for (Uri u : uris) { - svc.getContentResolver().releasePersistableUriPermission(u, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - long getTotalSendSize() { - long size = 0; - for (MFile f : files) { - size += f.length; - } - return size; - } - - // -- RECEIVE -- - public void prepareReceive() { - if (BuildConfig.DEBUG && direction != Direction.RECEIVE) { - throw new AssertionError("Assertion failed"); - } - //Check enough space - - //Check if will overwrite - if (Server.current.allowOverwrite) { - for (String file : topDirBasenames) { - if (checkWillOverwrite(file)) { - overwriteWarning = true; - break; - } - } - } - - boolean autoAccept = svc.prefs.getBoolean("autoAccept", false); - - //Show in UI - showNewReceiveTransfer(!autoAccept); - if (autoAccept) this.startReceive(); - } - - public void showNewReceiveTransfer(boolean notify) { - if (remoteUUID.equals(TransfersActivity.topmostRemote)) - LocalBroadcasts.updateTransfers(svc, remoteUUID); - else if (Server.current.notifyIncoming && notify) { //Notification - if (ActivityCompat.checkSelfPermission(svc, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) - return; - Intent intent = new Intent(svc, TransfersActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.putExtra("remote", remoteUUID); - int immutable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0; - PendingIntent pendingIntent = PendingIntent.getActivity(svc, svc.notifId, intent, immutable); - Uri notifSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); - var notifBuilder = new NotificationCompat.Builder(svc, MainService.CHANNEL_INCOMING) - .setContentTitle(svc.getString(message == null ? R.string.incoming_transfer : R.string.incoming_message, MainService.remotes.get(remoteUUID).displayName)) - .setContentText(message != null ? message : (fileCount == 1 ? singleName : svc.getString(R.string.num_files, fileCount))) - .setSmallIcon(message != null ? R.drawable.ic_notification : android.R.drawable.stat_sys_download_done) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setSound(notifSound) - .setContentIntent(pendingIntent) - .setAutoCancel(true); - if (message != null) { - notifBuilder.setGroup(NOTIFICATION_GROUP_MESSAGE); - } - Notification notification = notifBuilder.build(); - svc.notificationMgr.notify(svc.notifId++, notification); - } - } - - void startReceive() { - Log.i(TAG, "Transfer accepted, compression " + useCompression); - setStatus(Status.TRANSFERRING); - actualStartTime = System.currentTimeMillis(); - recvdPaths = new ArrayList<>(); - updateUI(); - MainService.remotes.get(remoteUUID).startReceiveTransfer(this); - MainService.cancelAutoStop(); //startRecv is asynchronous and may fail -> do this after - Log.i(TAG, "Acquiring wake lock for " + MainService.WAKELOCK_TIMEOUT + " min"); - svc.wakeLock.acquire(MainService.WAKELOCK_TIMEOUT*60*1000L); - } - - void declineTransfer() { - Log.i(TAG, "Transfer declined"); - Remote r = MainService.remotes.get(remoteUUID); - if (r != null) - r.declineTransfer(this); - else Log.w(TAG, "Transfer was from an unknown remote"); - makeDeclined(); - } - - public boolean receiveFileChunk(WarpProto.FileChunk chunk) { - long chunkSize = 0; - if (!chunk.getRelativePath().equals(currentRelativePath)) { - //End old file - closeStream(); - if (currentLastMod != -1) { - setLastModified(); - currentLastMod = -1; - } - finishSafeOverwrite(); - //Begin new file - currentRelativePath = chunk.getRelativePath(); - if ("".equals(Server.current.downloadDirUri)) { - errors.add(svc.getString(R.string.error_download_dir)); - failReceive(); - return false; - } - - String sanitizedName = currentRelativePath.replaceAll("[\\\\<>*|?:\"]", "_"); - if (chunk.getFileType() == FileType.DIRECTORY) { - createDirectory(sanitizedName); - } - else if (chunk.getFileType() == FileType.SYMLINK) { - Log.w(TAG, "Symlinks not supported."); - errors.add("Symlinks not supported."); //This one can be ignored - } - else { - if (chunk.hasTime()) { - WarpProto.FileTime ft = chunk.getTime(); - currentLastMod = ft.getMtime()*1000 + ft.getMtimeUsec()/1000; - } - try { - currentStream = openFileStream(sanitizedName); - byte[] data = chunk.getChunk().toByteArray(); - if (useCompression) - data = ZlibCompressor.decompress(data); - currentStream.write(data); - chunkSize = data.length; - } catch (Exception e) { - Log.e(TAG, "Failed to open file for writing: " + currentRelativePath, e); - errors.add("Failed to open file for writing: " + currentRelativePath); - failReceive(); - } - } - } else { - try { - byte[] data = chunk.getChunk().toByteArray(); - if (useCompression) - data = ZlibCompressor.decompress(data); - currentStream.write(data); - chunkSize = data.length; - } catch (Exception e) { - Log.e(TAG, "Failed to write to file " + currentRelativePath + ": " + e.getMessage()); - errors.add("Failed to write to file " + currentRelativePath + ": " + e.getMessage()); - failReceive(); - } - } - bytesTransferred += chunkSize; - long now = System.currentTimeMillis(); - long bps = (long)(chunkSize / ((now - lastMillis) / 1000f)); - transferSpeed.add(bps); - bytesPerSecond = transferSpeed.getMovingAverage(); - lastMillis = now; - updateUI(); - return getStatus() == Status.TRANSFERRING; //True if not interrupted - } - - public void finishReceive() { - Log.d(TAG, "Finalizing transfer"); - if(errors.size() > 0) - setStatus(Status.FINISHED_WITH_ERRORS); - else setStatus(Status.FINISHED); - closeStream(); - if (currentLastMod > 0) - setLastModified(); - finishSafeOverwrite(); - if (!Server.current.downloadDirUri.startsWith("content:")) - MediaScannerConnection.scanFile(svc, recvdPaths.toArray(new String[0]), null, null); - updateUI(); - } - - private void finishSafeOverwrite() { - if (safeOverwriteFlag) { - safeOverwriteFlag = false; - String p = currentFile.getPath(); - assert p.endsWith(TMP_FILE_SUFFIX); - String dst = p.substring(0, p.length()-TMP_FILE_SUFFIX.length()); - Log.d(TAG, "Renaming tempfile to " + dst); - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - java.nio.file.Files.move(currentFile.toPath(), Paths.get(dst), StandardCopyOption.ATOMIC_MOVE); - else if (!currentFile.renameTo(new File(dst))) - throw new IOException("Could not rename temp file to target name"); - } catch (IOException e) { - Log.e(TAG, "Could not replace target file with temp file", e); - errors.add("Failed to overwrite " + currentRelativePath); - } - } - } - - private void stopReceiving() { - Log.v(TAG, "Stopping receiving"); - closeStream(); - //Delete incomplete file - try { - if (Server.current.downloadDirUri.startsWith("content:")) { - DocumentFile f = DocumentFile.fromSingleUri(svc, currentUri); - f.delete(); - } else { - currentFile.delete(); - } - } catch (Exception e) { - Log.w(TAG, "Could not delete incomplete file", e); - } - } - - private void failReceive() { - //Don't overwrite other reason for stopping - if (getStatus() == Status.TRANSFERRING) { - Log.v(TAG, "Receiving failed"); - setStatus(Status.FAILED); - stop(true); //Calls stopReceiving for us - } - } - - private void closeStream() { - if(currentStream != null) { - try { - currentStream.close(); - currentStream = null; - } catch (Exception ignored) {} - } - } - - private void setLastModified() { - //This is apparently not possible with SAF - if (!Server.current.downloadDirUri.startsWith("content:")) { - Log.d(TAG, "Setting lastMod: " + currentLastMod); - currentFile.setLastModified(currentLastMod); - } - } - - private boolean checkWillOverwrite(String relPath) { - if (Server.current.downloadDirUri.startsWith("content:")) { - Uri treeRoot = Uri.parse(Server.current.downloadDirUri); - return Utils.pathExistsInTree(svc, treeRoot, relPath); - } else { - return new File(Server.current.downloadDirUri, relPath).exists(); - } - } - - private File handleFileExists(File f) { - Log.d(TAG, "File exists: " + f.getAbsolutePath()); - if(Server.current.allowOverwrite) { - f = new File(f.getParentFile(), f.getName() + TMP_FILE_SUFFIX); - Log.v(TAG, "Writing to temp file " + f); - safeOverwriteFlag = true; - } else { - String name = f.getParent() + "/" + Files.getNameWithoutExtension(f.getAbsolutePath()); - String ext = Files.getFileExtension(f.getAbsolutePath()); - int i = 1; - while (f.exists()) - f = new File(String.format("%s(%d).%s", name, i++, ext)); - Log.d(TAG, "Renamed to " + f.getAbsolutePath()); - } - return f; - } - - private String handleUriExists(String path) { - Uri root = Uri.parse(Server.current.downloadDirUri); - DocumentFile f = Utils.getChildFromTree(svc, root, path); - Log.d(TAG, "File exists: " + f.getUri()); - if(Server.current.allowOverwrite) { - Log.v(TAG, "Overwriting"); - f.delete(); - } else { - String dir = path.substring(0, path.lastIndexOf("/")+1); - String _fileName = path.substring(path.lastIndexOf("/")+1); - - String name = _fileName; - String ext = ""; - if(_fileName.contains(".")) { - name = _fileName.substring(0, _fileName.indexOf(".")); - ext = _fileName.substring(_fileName.indexOf(".")); - } - int i = 1; - while (Utils.pathExistsInTree(svc, root, path)) { - path = dir + name + "(" + i + ")" + ext; - i++; - } - Log.d(TAG, "Renamed to " + path); - } - return path; - } - - private void createDirectory(String path) { - if (Server.current.downloadDirUri.startsWith("content:")) { - Uri rootUri = Uri.parse(Server.current.downloadDirUri); - DocumentFile root = DocumentFile.fromTreeUri(svc, rootUri); - createDirectories(root, path, null); // Note: .. segment is created as (invalid) - } else { - File dir = new File(Server.current.downloadDirUri, path); - if (!validateFile(dir)) - throw new IllegalArgumentException("The dir path leads outside download dir"); - if (!dir.mkdirs()) { - errors.add("Failed to create directory " + path); - Log.e(TAG, "Failed to create directory " + path); - } - } - } - - private void createDirectories(DocumentFile parent, String path, @Nullable String done) { - String dir = path; - String rest = null; - if (path.contains("/")) { - dir = path.substring(0, path.indexOf("/")); - rest = path.substring(path.indexOf("/")+1); - } - String absDir = done == null ? dir : done +"/"+ dir; //Path from rootUri - just to check existence - DocumentFile newDir = DocumentFile.fromTreeUri(svc, Utils.getChildUri(Uri.parse(Server.current.downloadDirUri), absDir)); - if (!newDir.exists()) { - newDir = parent.createDirectory(dir); - if (newDir == null) { - errors.add("Failed to create directory " + absDir); - Log.e(TAG, "Failed to create directory " + absDir); - return; - } - } - if (rest != null) - createDirectories(newDir, rest, absDir); - } - - private OutputStream openFileStream(String fileName) throws FileNotFoundException { - if (Server.current.downloadDirUri.startsWith("content:")) { - Uri rootUri = Uri.parse(Server.current.downloadDirUri); - DocumentFile root = DocumentFile.fromTreeUri(svc, rootUri); - if(Utils.pathExistsInTree(svc, rootUri, fileName)) { - fileName = handleUriExists(fileName); - } - //Get parent - createFile will substitute / with _ and checks if parent is descendant of tree root - DocumentFile parent = root; - if (fileName.contains("/")) { - String parentRelPath = fileName.substring(0, fileName.lastIndexOf("/")); - fileName = fileName.substring(fileName.lastIndexOf("/")+1); - Uri dirUri = Utils.getChildUri(rootUri, parentRelPath); - parent = DocumentFile.fromTreeUri(svc, dirUri); - } - //Create file - String mime = guessMimeType(fileName); - DocumentFile file = parent.createFile(mime, fileName); - currentUri = file.getUri(); - return svc.getContentResolver().openOutputStream(currentUri); - } else { - currentFile = new File(Server.current.downloadDirUri, fileName); - if(currentFile.exists()) { - currentFile = handleFileExists(currentFile); - } - if (!validateFile(currentFile)) - throw new IllegalArgumentException("The file name leads to a file outside download dir"); - if (!safeOverwriteFlag) - recvdPaths.add(currentFile.getAbsolutePath()); - return new FileOutputStream(currentFile, false); - } - } - - private boolean validateFile(File f) { - boolean res = false; - try { - res = (f.getCanonicalPath() + "/").startsWith(Server.current.downloadDirUri); - } catch (Exception e) { - Log.w(TAG, "Could not resolve canonical path for " + f.getAbsolutePath() + ": " + e.getMessage()); - } - return res; - } - - private String guessMimeType(String name) { - //We don't care about knowing the EXACT mime type - //This is only to prevent fail on some devices that reject empty mime type - String mime = URLConnection.guessContentTypeFromName(name); - if (mime == null) - mime = "application/octet-stream"; - return mime; - } - - public void setStatus(Status s) { - status.set(s); - } - - public Status getStatus() { - return status.get(); - } - - static class MFile { - String documentID; - String name; - String mime; - String relPath; - Uri uri; - long length; - long lastMod; - boolean isDirectory; - } - - static class TransferSpeed { - private final int historyLength; - private final long[] history; - private int idx = 0; - private int count = 0; - - public TransferSpeed(int historyLen) { - historyLength = historyLen; - history = new long[historyLength]; - } - - public void add(long bps) { - history[idx] = bps; - idx = (idx + 1) % historyLength; - if (count < historyLength) - count++; - } - - public long getMovingAverage() { - if (count == 0) - return 0; - else if (count == 1) - return history[0]; - - long sum = 0; - for (int i = 0; i < count; i++) - sum += history[i]; - return sum / count; - } - } -} diff --git a/app/src/main/java/slowscript/warpinator/TransfersActivity.java b/app/src/main/java/slowscript/warpinator/TransfersActivity.java deleted file mode 100644 index b75b5c06..00000000 --- a/app/src/main/java/slowscript/warpinator/TransfersActivity.java +++ /dev/null @@ -1,391 +0,0 @@ -package slowscript.warpinator; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.res.ColorStateList; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; -import android.view.View; -import android.widget.Button; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import android.widget.ToggleButton; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.snackbar.Snackbar; - -import java.util.ArrayList; - -public class TransfersActivity extends AppCompatActivity { - - static final String TAG = "TransferActivity"; - static final int SEND_FILE_REQ_CODE = 10; - static final int SEND_FOLDER_REQ_CODE = 11; - - public static String topmostRemote; - - Remote remote; - boolean shareMode = false; - - RecyclerView recyclerView; - TransfersAdapter adapter; - BroadcastReceiver receiver; - - TextView txtName; - TextView txtRemote; - TextView txtIP; - ImageView imgProfile; - ImageView imgStatus; - FloatingActionButton fabSend; - FloatingActionButton fabSendFiles; - FloatingActionButton fabSendDir; - FloatingActionButton fabSendMsg; - FloatingActionButton fabClear; - Button btnReconnect; - ToggleButton tglStar; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - overridePendingTransition(R.anim.anim_push_up, R.anim.anim_null); - setContentView(R.layout.activity_transfers); - Utils.setEdgeToEdge(getWindow()); - String id = getIntent().getStringExtra("remote"); - shareMode = getIntent().getBooleanExtra("shareMode", false); - if (!MainService.remotes.containsKey(id)) { - finish(); - return; - } - remote = MainService.remotes.get(id); - receiver = newBroadcastReceiver(); - - recyclerView = findViewById(R.id.recyclerView2); - adapter = new TransfersAdapter(this); - recyclerView.setAdapter(adapter); - recyclerView.setLayoutManager(new LinearLayoutManager(this)); - //Prevent blinking on update - RecyclerView.ItemAnimator animator = recyclerView.getItemAnimator(); - if (animator instanceof SimpleItemAnimator) { - ((SimpleItemAnimator) animator).setSupportsChangeAnimations(false); - } - - txtName = findViewById(R.id.txtDisplayName); - txtRemote = findViewById(R.id.txtRemote); - txtIP = findViewById(R.id.txtIP); - imgStatus = findViewById(R.id.imgStatus); - imgProfile = findViewById(R.id.imgProfile); - fabSend = findViewById(R.id.fabSend); - fabSendFiles = findViewById(R.id.fabSendFile); - fabSendDir = findViewById(R.id.fabSendDir); - fabSendMsg = findViewById(R.id.fabSendMsg); - fabSend.setOnClickListener((v) -> { - int vis = fabSendFiles.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE; - setFabVisibility(vis); - }); - fabSendFiles.setOnClickListener((v) -> openFiles()); - fabSendDir.setOnClickListener((v) -> openFolder()); - fabSendMsg.setOnClickListener((v) -> sendMessage()); - fabClear = findViewById(R.id.fabClear); - fabClear.setOnClickListener((v) -> remote.clearTransfers()); - btnReconnect = findViewById(R.id.btnReconnect); - btnReconnect.setOnClickListener((v) -> reconnect()); - tglStar = findViewById(R.id.tglStar); - tglStar.setChecked(remote.isFavorite()); - tglStar.setOnCheckedChangeListener((v, checked) -> onFavoritesCheckChanged(checked)); - - //Connection status toast - imgStatus.setOnClickListener(view -> { - String s = getResources().getStringArray(R.array.connected_states)[remote.status.ordinal()]; - if (!remote.serviceAvailable) - s += getString(R.string.service_unavailable); - if (remote.sameSubnetWarning()) - s += getString(R.string.wrong_subnet); - if (remote.status == Remote.RemoteStatus.ERROR) - s += " (" + remote.errorText + ")"; - CoordinatorLayout cdView = findViewById(R.id.activityTransfersRoot); - Snackbar.make(cdView, s, Snackbar.LENGTH_LONG).setAnimationMode(Snackbar.ANIMATION_MODE_SLIDE).show(); - }); - - updateUI(); - } - - - @Override - protected void onResume() { - super.onResume(); - if (!Utils.isMyServiceRunning(this, MainService.class)) { - finish(); - return; - } - topmostRemote = remote.uuid; - updateTransfers(remote.uuid); - updateUI(); - if (MainService.svc.runningTransfers == 0) - MainService.svc.notificationMgr.cancel(MainService.PROGRESS_NOTIFICATION_ID); - //if (remote.errorReceiveCert) - // Utils.displayMessage(this, getString(R.string.connection_error), getString(R.string.cert_not_received, remote.hostname, remote.port)); - if (remote.sameSubnetWarning()) - Utils.displayMessage(this, getString(R.string.warning), getString(R.string.warning_subnet)); - - IntentFilter f = new IntentFilter(LocalBroadcasts.ACTION_UPDATE_REMOTES); - f.addAction(LocalBroadcasts.ACTION_UPDATE_TRANSFERS); - f.addAction(LocalBroadcasts.ACTION_UPDATE_TRANSFER); - f.addAction(LocalBroadcasts.ACTION_DISPLAY_MESSAGE); - f.addAction(LocalBroadcasts.ACTION_DISPLAY_TOAST); - f.addAction(LocalBroadcasts.ACTION_CLOSE_ALL); - LocalBroadcastManager.getInstance(this).registerReceiver(receiver, f); - } - - @Override - protected void onPause() { - super.onPause(); - topmostRemote = null; - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); - } - - @Override - public void onBackPressed(){ - if (shareMode) { - if (transferInProgress()) - Toast.makeText(this, R.string.dont_close_when_sharing, Toast.LENGTH_LONG).show(); - else finishAffinity(); //Close everything incl. ShareActivity - } else { - super.onBackPressed(); - TransfersActivity.this.overridePendingTransition(R.anim.anim_null, R.anim.anim_push_down); - } - } - - private BroadcastReceiver newBroadcastReceiver() { - Context ctx = this; - return new BroadcastReceiver() { - @Override - public void onReceive(Context c, Intent intent) { - String action = intent.getAction(); - if (action == null) return; - switch (action) { - case LocalBroadcasts.ACTION_UPDATE_REMOTES: - updateUI(); - break; - case LocalBroadcasts.ACTION_UPDATE_TRANSFERS: - String r = intent.getStringExtra("remote"); - if (r != null) updateTransfers(r); - break; - case LocalBroadcasts.ACTION_UPDATE_TRANSFER: - r = intent.getStringExtra("remote"); - int id = intent.getIntExtra("id", -1); - if (r != null) updateTransfer(r, id); - break; - case LocalBroadcasts.ACTION_DISPLAY_MESSAGE: - String title = intent.getStringExtra("title"); - String msg = intent.getStringExtra("msg"); - Utils.displayMessage(ctx, title, msg); - break; - case LocalBroadcasts.ACTION_DISPLAY_TOAST: - msg = intent.getStringExtra("msg"); - int length = intent.getIntExtra("length", 0); - Toast.makeText(ctx, msg, length).show(); - break; - case LocalBroadcasts.ACTION_CLOSE_ALL: - finishAffinity(); - break; - } - } - }; - } - - private boolean transferInProgress() { - for (Transfer t : remote.transfers) { - if (t.direction == Transfer.Direction.SEND && (t.getStatus() == Transfer.Status.WAITING_PERMISSION || t.getStatus() == Transfer.Status.TRANSFERRING)) - return true; - } - return false; - } - - @SuppressLint("SetTextI18n") - public void updateUI() { - runOnUiThread(() -> { //Will run immediately if on correct thread already - txtName.setText(remote.displayName); - txtRemote.setText(remote.userName + "@" + remote.hostname); - txtIP.setText((remote.sameSubnetWarning() ? "⚠ " : "") + remote.address.getHostAddress()); - imgStatus.setImageResource(Utils.getIconForRemoteStatus(remote.status)); - - int color = Utils.getAndroidAttributeColor(this, android.R.attr.textColorTertiary); - - if (remote.picture != null) { - imgProfile.setImageTintList(null); - imgProfile.setImageBitmap(remote.picture); - } else { - imgProfile.setImageTintList(ColorStateList.valueOf(color)); - } - - if (remote.status == Remote.RemoteStatus.ERROR || remote.status == Remote.RemoteStatus.DISCONNECTED) { - if (!remote.serviceAvailable) - imgStatus.setImageResource(R.drawable.ic_unavailable); - else - color = Utils.getAttributeColor(getTheme(), androidx.appcompat.R.attr.colorError); - } - imgStatus.setImageTintList(ColorStateList.valueOf(color)); - - boolean sendEnabled = remote.status == Remote.RemoteStatus.CONNECTED; - fabSend.setEnabled(sendEnabled); - if (!sendEnabled) - setFabVisibility(View.GONE); - fabSendMsg.setEnabled(remote.supportsMessages); - btnReconnect.setVisibility((remote.status == Remote.RemoteStatus.ERROR) - || (remote.status == Remote.RemoteStatus.DISCONNECTED) - ? View.VISIBLE : View.INVISIBLE); - }); - } - - void reconnect() { - remote.connect(); - } - - private void onFavoritesCheckChanged(boolean checked) { - MainService.remotesOrder.remove(remote.uuid); - if (checked) { - Server.current.favorites.add(remote.uuid); - MainService.remotesOrder.add(0, remote.uuid); - } else { - Server.current.favorites.remove(remote.uuid); - MainService.remotesOrder.add(remote.uuid); - } - Server.current.saveFavorites(); - } - - private void setFabVisibility(int vis) { - fabSendFiles.setVisibility(vis); - fabSendDir.setVisibility(vis); - fabSendMsg.setVisibility(vis); - fabSend.setImageResource(vis == View.VISIBLE ? R.drawable.ic_decline : R.drawable.ic_upload); - } - - private void openFiles() { - Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType("*/*"); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - - Log.d(TAG, "Starting file browser activty (prevent autostop)"); - WarpinatorApp.activitiesRunning++; //Prevent autostop - startActivityForResult(i, SEND_FILE_REQ_CODE); - setFabVisibility(View.GONE); - } - - private void openFolder() { - Toast.makeText(this, R.string.send_folder_toast, Toast.LENGTH_SHORT).show(); - Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - try { - Log.d(TAG, "Starting folder browser activity (prevent auto-stop)"); - startActivityForResult(i, SEND_FOLDER_REQ_CODE); - WarpinatorApp.activitiesRunning++; //Prevent auto-stop only if actually opened - } catch (ActivityNotFoundException e) { - Toast.makeText(this, R.string.required_dialog_not_found, Toast.LENGTH_LONG).show(); - } - setFabVisibility(View.GONE); - } - - private void sendMessage() { - FrameLayout layout = new FrameLayout(this); - layout.setPadding(16,16,16,16); - EditText editText = new EditText(this); - editText.setSingleLine(false); - editText.setMinLines(2); - editText.setHint(R.string.message_hint); - layout.addView(editText); - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.enter_message) - .setView(layout) - .setPositiveButton(android.R.string.ok, (a,b)->{ - String msg = editText.getText().toString(); - Log.d(TAG, "Sending text message to " + remote.hostname); - Transfer t = new Transfer(); - t.direction = Transfer.Direction.SEND; - t.remoteUUID = remote.uuid; - t.startTime = System.currentTimeMillis(); - t.message = msg; - t.setStatus(Transfer.Status.TRANSFERRING); - remote.addTransfer(t); - updateTransfers(remote.uuid); - remote.sendTextMessage(t); - }) - .show(); - setFabVisibility(View.GONE); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == SEND_FILE_REQ_CODE || requestCode == SEND_FOLDER_REQ_CODE) { - Log.d(TAG, "File browser activity finished"); //We return to this activity - WarpinatorApp.activitiesRunning--; - if ((resultCode != Activity.RESULT_OK) || (data == null)) - return; - Transfer t = new Transfer(); - t.uris = new ArrayList<>(); - ClipData cd = data.getClipData(); - if (requestCode == SEND_FOLDER_REQ_CODE || cd == null) { - Uri u = data.getData(); - if (u == null) { - Log.w(TAG, "No uri to send"); - return; - } - Log.v(TAG, u.toString()); - getContentResolver().takePersistableUriPermission(u, Intent.FLAG_GRANT_READ_URI_PERMISSION); - t.uris.add(u); - } else { - for (int i = 0; i < cd.getItemCount(); i++) { - var uri = cd.getItemAt(i).getUri(); - getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - t.uris.add(uri); - Log.v(TAG, uri.toString()); - } - } - t.remoteUUID = remote.uuid; - - remote.addTransfer(t); - t.setStatus(Transfer.Status.INITIALIZING); - updateTransfers(remote.uuid); - new Thread(() -> { - t.prepareSend(requestCode == SEND_FOLDER_REQ_CODE); - remote.startSendTransfer(t); - }).start(); - } - } - - private void updateTransfer(String r, int i) { - if (!r.equals(remote.uuid)) - return; - runOnUiThread(() -> adapter.notifyItemChanged(i)); - } - - private void updateTransfers(String r) { - if (!r.equals(remote.uuid)) - return; - runOnUiThread(() -> { - adapter.notifyDataSetChanged(); - fabClear.setVisibility(remote.transfers.size() > 0 ? View.VISIBLE : View.INVISIBLE); - }); - } -} diff --git a/app/src/main/java/slowscript/warpinator/TransfersAdapter.java b/app/src/main/java/slowscript/warpinator/TransfersAdapter.java deleted file mode 100644 index 7aafd488..00000000 --- a/app/src/main/java/slowscript/warpinator/TransfersAdapter.java +++ /dev/null @@ -1,231 +0,0 @@ -package slowscript.warpinator; - -import android.annotation.SuppressLint; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.provider.DocumentsContract; -import android.text.SpannableString; -import android.text.format.Formatter; -import android.text.method.LinkMovementMethod; -import android.text.util.Linkify; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.AppCompatImageButton; -import androidx.core.content.FileProvider; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.common.base.Joiner; - -public class TransfersAdapter extends RecyclerView.Adapter { - - TransfersActivity activity; - private static final String TAG = "TransferAdapter"; - - public TransfersAdapter(TransfersActivity _app) { - activity = _app; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(activity); - View view = inflater.inflate(R.layout.transfer_view, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Transfer t = activity.remote.transfers.get(position); - - //ProgressBar - holder.progressBar.setVisibility(t.getStatus() == Transfer.Status.TRANSFERRING ? View.VISIBLE : View.INVISIBLE); - holder.progressBar.setProgress(t.getProgress()); - //Buttons - if (t.getStatus() == Transfer.Status.WAITING_PERMISSION) { - if (t.direction == Transfer.Direction.RECEIVE) { - holder.btnAccept.setOnClickListener((v) -> t.startReceive()); - holder.btnAccept.setVisibility(View.VISIBLE); - } else holder.btnAccept.setVisibility(View.INVISIBLE); - holder.btnDecline.setOnClickListener((v) -> t.declineTransfer()); - holder.btnDecline.setVisibility(View.VISIBLE); - } else { - holder.btnAccept.setVisibility(View.INVISIBLE); - holder.btnDecline.setVisibility(View.INVISIBLE); - } - holder.btnStop.setVisibility(t.getStatus() == Transfer.Status.TRANSFERRING ? View.VISIBLE : View.INVISIBLE); - holder.btnStop.setOnClickListener((v) -> t.stop(false)); - if (t.direction == Transfer.Direction.SEND && (t.getStatus() == Transfer.Status.FAILED || - t.getStatus() == Transfer.Status.STOPPED || t.getStatus() == Transfer.Status.FINISHED_WITH_ERRORS || - t.getStatus() == Transfer.Status.DECLINED)) { - holder.btnRetry.setVisibility(View.VISIBLE); - holder.btnRetry.setOnClickListener((v) -> { - t.setStatus(t.message != null ? Transfer.Status.TRANSFERRING : Transfer.Status.WAITING_PERMISSION); - t.updateUI(); - if (t.message == null) - activity.remote.startSendTransfer(t); - else - activity.remote.sendTextMessage(t); - }); - } else holder.btnRetry.setVisibility(View.INVISIBLE); - if (t.message != null && t.getStatus() == Transfer.Status.FINISHED && t.direction == Transfer.Direction.RECEIVE) { - holder.btnCopy.setVisibility(View.VISIBLE); - holder.btnCopy.setOnClickListener((v) -> { - ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("Message", t.message); - clipboard.setPrimaryClip(clip); - Toast.makeText(activity, R.string.message_copied, Toast.LENGTH_SHORT).show(); - }); - } else holder.btnCopy.setVisibility(View.INVISIBLE); - //Main label - String text = t.message != null ? t.message : (t.fileCount == 1 ? t.singleName: activity.getString(R.string.num_files, t.fileCount)); - if (t.message == null) - text += " (" + Formatter.formatFileSize(activity, t.totalSize) + ")"; - holder.txtTransfer.setText(text); - //Status label - switch (t.getStatus()) { - case WAITING_PERMISSION: - String str = activity.getString(R.string.waiting_for_permission); - if (t.overwriteWarning) - str += " " + activity.getString(R.string.files_overwritten_warning); - holder.txtStatus.setText(str); - break; - case TRANSFERRING: - String status = Formatter.formatFileSize(activity, t.bytesTransferred) + "/" + - Formatter.formatFileSize(activity, t.totalSize) + " (" + - Formatter.formatFileSize(activity, t.bytesPerSecond) + "/s, " + getRemainingTime(t) + ")"; - holder.txtStatus.setText(status); - break; - case FINISHED: - if (t.message != null) { - holder.txtStatus.setText(t.direction == Transfer.Direction.SEND ? R.string.sent : R.string.received); - break; - } - default: - holder.txtStatus.setText(activity.getResources().getStringArray(R.array.transfer_states)[t.getStatus().ordinal()]); - } - //Images - holder.imgFromTo.setImageResource(t.message != null ? R.drawable.ic_message : ( - t.direction == Transfer.Direction.SEND ? R.drawable.ic_upload : R.drawable.ic_download)); - holder.root.setOnClickListener((v)-> { - if (t.getStatus() == Transfer.Status.FAILED || t.getStatus() == Transfer.Status.FINISHED_WITH_ERRORS) { - Context c = holder.root.getContext(); - Utils.displayMessage(c, c.getString(R.string.errors_during_transfer), Joiner.on("\n").join(t.errors)); - } else if (t.getStatus() == Transfer.Status.TRANSFERRING) { - String remaining = getRemainingTime(t) + " " + activity.getString(R.string.remaining); - Toast.makeText(activity, remaining, Toast.LENGTH_SHORT).show(); - } else if (t.getStatus() == Transfer.Status.WAITING_PERMISSION && t.fileCount > 1) { - Utils.displayMessage(activity, activity.getString(R.string.files_being_sent), Joiner.on("\n").join(t.topDirBasenames)); - } else if (t.getStatus() == Transfer.Status.FINISHED && t.direction == Transfer.Direction.RECEIVE) { - Intent intent = new Intent(Intent.ACTION_VIEW); - if (t.message != null) { - // Dialog with clickable links (see https://stackoverflow.com/a/3367392) - final SpannableString s = new SpannableString(t.message); - Linkify.addLinks(s, Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS); - var dialog = new MaterialAlertDialogBuilder(activity) - .setTitle("Message") - .setMessage(s) - .setPositiveButton(android.R.string.ok, null) - .create(); - dialog.show(); - // Must be called after show() - ((TextView)dialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); - return; - } else if (t.fileCount == 1) { - Uri uri; - if (t.currentFile != null) { - uri = FileProvider.getUriForFile(activity, activity.getApplicationContext().getPackageName() + ".provider", t.currentFile); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - } else uri = t.currentUri; - intent.setDataAndType(uri, t.singleMime); - } else { - Uri u = Uri.parse(Server.current.downloadDirUri); - if (Server.current.downloadDirUri.startsWith("content:/")) { - String id = DocumentsContract.getTreeDocumentId(u); - u = DocumentsContract.buildDocumentUriUsingTree(u, id); - } - intent.setDataAndType(u, DocumentsContract.Document.MIME_TYPE_DIR); - } - try { - Intent chooser = Intent.createChooser(intent, null); - activity.startActivity(chooser); - } catch (Exception e) { - Log.e(TAG, "Failed to open received file", e); - Toast.makeText(activity, R.string.open_failed, Toast.LENGTH_SHORT).show(); - } - } - }); - } - - @Override - public int getItemCount() { - return activity.remote.transfers.size(); - } - - String getRemainingTime(Transfer t) { - long now = System.currentTimeMillis(); - float avgSpeed = t.bytesTransferred / ((now - t.actualStartTime) / 1000f); - int secondsRemaining = (int)((t.totalSize - t.bytesTransferred) / avgSpeed); - return formatTime(secondsRemaining); - } - - @SuppressLint("DefaultLocale") - String formatTime(int seconds) { - if (seconds > 60) { - int minutes = seconds / 60; - if (seconds > 3600) { - int hours = seconds / 3600; - minutes -= hours * 60; - return String.format("%dh %dm", hours, minutes); - } - seconds -= minutes*60; - return String.format("%dm %ds", minutes, seconds); - } - else if (seconds > 5) { - return String.format("%ds", seconds); - } - else { - return activity.getString(R.string.a_few_seconds); - } - } - - public class ViewHolder extends RecyclerView.ViewHolder { - - ImageButton btnAccept; - ImageButton btnDecline; - ImageButton btnStop; - ImageButton btnRetry; - ImageButton btnCopy; - ImageView imgFromTo; - TextView txtTransfer; - TextView txtStatus; - ProgressBar progressBar; - View root; - - public ViewHolder(@NonNull View itemView) { - super(itemView); - root = itemView; - btnAccept = itemView.findViewById(R.id.btnAccept); - btnDecline = itemView.findViewById(R.id.btnDecline); - btnStop = itemView.findViewById(R.id.btnStop); - btnRetry = itemView.findViewById(R.id.btnRetry); - btnCopy = itemView.findViewById(R.id.btnCopyMessage); - imgFromTo = itemView.findViewById(R.id.imgFromTo); - txtStatus = itemView.findViewById(R.id.txtStatus); - txtTransfer = itemView.findViewById(R.id.txtTransfer); - progressBar = itemView.findViewById(R.id.progressBar); - } - } -} diff --git a/app/src/main/java/slowscript/warpinator/Utils.java b/app/src/main/java/slowscript/warpinator/Utils.java deleted file mode 100644 index 38523f6e..00000000 --- a/app/src/main/java/slowscript/warpinator/Utils.java +++ /dev/null @@ -1,506 +0,0 @@ -package slowscript.warpinator; - -import android.annotation.SuppressLint; -import android.app.ActivityManager; -import android.content.ContentResolver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.Color; -import android.net.ConnectivityManager; -import android.net.LinkAddress; -import android.net.LinkProperties; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.net.Uri; -import android.net.wifi.WifiManager; -import android.os.Build; -import android.provider.DocumentsContract; -import android.provider.OpenableColumns; -import android.provider.Settings; -import android.text.TextUtils; -import android.util.Log; -import android.util.TypedValue; -import android.view.View; -import android.view.Window; - -import androidx.annotation.AttrRes; -import androidx.annotation.RequiresApi; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.ViewGroupCompat; -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.documentfile.provider.DocumentFile; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.common.net.InetAddresses; -import com.google.common.primitives.Ints; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.WriterException; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.lang.reflect.Method; -import java.net.Inet4Address; -import java.net.InetAddress; -import java.net.InterfaceAddress; -import java.net.NetworkInterface; -import java.net.SocketException; -import java.net.URLDecoder; -import java.net.UnknownHostException; -import java.text.CharacterIterator; -import java.text.StringCharacterIterator; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Enumeration; -import java.util.List; -import java.util.Locale; -import java.util.Random; - -import io.grpc.stub.StreamObserver; - -import static android.content.Context.CONNECTIVITY_SERVICE; -import static android.content.Context.WIFI_SERVICE; - -public class Utils { - - private static final String TAG = "Utils"; - - public static String getDeviceName() { - String name = null; - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) - name = Settings.Global.getString(MainService.svc.getContentResolver(), Settings.Global.DEVICE_NAME); - if(name == null) - name = Settings.Secure.getString(MainService.svc.getContentResolver(), "bluetooth_name"); - } catch (Exception ignored) {} - if (name == null) { - Log.v(TAG, "Could not get device name - using default"); - name = "Android Phone"; - } - return name; - } - - public static IPInfo getIPAddress() { - try { - if (Server.iface != null && !Server.iface.isEmpty() && !Server.iface.equals(Server.NETIFACE_AUTO)) { - IPInfo ia = getIPForIfaceName(Server.iface); - if (ia != null) - return ia; - else Log.d(TAG, "Preferred network interface is unavailable, falling back to automatic"); - } - IPInfo ip = null; - //Works for most cases - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - ip = getNetworkIP(); - if (ip == null) - ip = getWifiIP(); - //Try figuring out what interface wifi has, fallback to wlan0 - in case of hotspot - if (ip == null) - ip = getIPForIfaceName(getWifiInterface()); - //Get IP of an active interface (except loopback and data) - if (ip == null) { - NetworkInterface activeNi = getActiveIface(); - if (activeNi != null) - ip = getIPForIface(activeNi); - } - Log.v(TAG, "Got IP: " + (ip == null ? "null" : (ip.address.getHostAddress() + "/" + ip.prefixLength))); - return ip; - } catch (Exception ex) { - Log.e(TAG, "Couldn't get IP address", ex); - return null; - } - } - - static IPInfo getWifiIP() { - WifiManager wifiManager = (WifiManager) MainService.svc.getSystemService(WIFI_SERVICE); - if (wifiManager == null) return null; - int ip = wifiManager.getConnectionInfo().getIpAddress(); - if (ip == 0) return null; - try { - // No way to get prefix length, guess 24 - return new IPInfo((Inet4Address)InetAddresses.fromLittleEndianByteArray(Ints.toByteArray(ip)), 24); - } catch (UnknownHostException e) { - return null; - } - } - - @RequiresApi(api = Build.VERSION_CODES.M) - static IPInfo getNetworkIP() { - ConnectivityManager connMgr = (ConnectivityManager)MainService.svc.getSystemService(CONNECTIVITY_SERVICE); - assert connMgr != null; - Network activeNetwork = connMgr.getActiveNetwork(); - NetworkCapabilities networkCaps = connMgr.getNetworkCapabilities(activeNetwork); - LinkProperties properties = connMgr.getLinkProperties(activeNetwork); - if (properties != null && networkCaps != null && - networkCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) && - (networkCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - networkCaps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))) { - for (LinkAddress addr : properties.getLinkAddresses()) - if (addr.getAddress() instanceof Inet4Address) - return new IPInfo((Inet4Address)addr.getAddress(), addr.getPrefixLength()); - } - return null; - } - - static NetworkInterface getActiveIface() throws SocketException { - List nis = Collections.list(NetworkInterface.getNetworkInterfaces()); - //prioritize wlan(...) interfaces and deprioritize tun(...) which can be leftover from VPN apps - Collections.sort(nis, - (i1, i2) -> { - String i1Name = i1.getDisplayName(); - String i2Name = i2.getDisplayName(); - if (i1Name.contains("wlan") || i2Name.contains("tun")) { - return -1; - } else if (i1Name.contains("tun") || i2Name.contains("wlan")){ - return 1; - } - return 0; - } - ); - - for (NetworkInterface ni : nis) { - if ((!ni.isLoopback()) && ni.isUp()) { - String name = ni.getDisplayName(); - if (name.contains("dummy") || name.contains("rmnet") || name.contains("ifb")) - continue; - if (getIPForIface(ni) == null) //Skip ifaces with no IPv4 address - continue; - Log.d(TAG, "Selected interface: " + ni.getDisplayName()); - return ni; - } - } - return null; - } - - static String getWifiInterface() { - String iface = null; - try { - Method m = Class.forName("android.os.SystemProperties").getMethod("get", String.class); - iface = (String) m.invoke(null, "wifi.interface"); - } catch(Throwable ignored) {} - if (iface == null || iface.isEmpty()) - iface = "wlan0"; - return iface; - } - - public static String[] getNetworkInterfaces() { - ArrayList nisNames = new ArrayList<>(); - try { - Enumeration nis = NetworkInterface.getNetworkInterfaces(); - while (nis.hasMoreElements()) { - NetworkInterface ni = nis.nextElement(); - if (ni.isUp()) { - nisNames.add(ni.getDisplayName()); - } - } - } catch (SocketException e) { - Log.e(TAG, "Could not get network interfaces", e); - return null; - } - return nisNames.toArray(new String[0]); - } - - public static String dumpInterfaces() { - var nis = getNetworkInterfaces(); - if (nis == null) - return "Failed to get network interfaces"; - return TextUtils.join("\n", nis); - } - - public static IPInfo getIPForIfaceName(String ifaceName) throws SocketException { - Enumeration nis = NetworkInterface.getNetworkInterfaces(); - NetworkInterface ni; - while (nis.hasMoreElements()) { - ni = nis.nextElement(); - if (ni.getDisplayName().equals(ifaceName)) { - return getIPForIface(ni); - } - } - return null; - } - - static IPInfo getIPForIface(NetworkInterface ni) { - for (InterfaceAddress ia : ni.getInterfaceAddresses()) { - //filter for ipv4/ipv6 - if (ia.getAddress().getAddress().length == 4) { - //4 for ipv4, 16 for ipv6 - return new IPInfo((Inet4Address)ia.getAddress(), ia.getNetworkPrefixLength()); - } - } - return null; - } - - static boolean isSameSubnet(InetAddress a, InetAddress b, int prefix) { - var aa = a.getAddress(); - var ba = b.getAddress(); - if (aa.length != ba.length) - return false; - for (int i = 0; i < prefix; i+=8) { - int bi = i / 8; - if (bi >= aa.length) break; - int rem = prefix-i; - if (rem >= 8) { - if (aa[bi] != ba[bi]) - return false; - } else { - byte mask = (byte) ((0xFF << (8-rem)) & 0xFF); - if ((aa[bi] & mask) != (ba[bi] & mask)) - return false; - } - } - return true; - } - - public static File getCertsDir() - { - return new File(MainService.svc.getCacheDir(), "certs"); - } - - public static byte[] readAllBytes(File file) throws IOException { - try (RandomAccessFile f = new RandomAccessFile(file, "r")) { - byte[] b = new byte[(int) f.length()]; - f.readFully(b); - return b; - } - } - - public static void displayMessage(Context ctx, String title, String msg) { - displayMessage(ctx, title, msg, null); - } - public static void displayMessage(Context ctx, String title, String msg, DialogInterface.OnClickListener listener) { - new MaterialAlertDialogBuilder(ctx) - .setTitle(title) - .setMessage(msg) - .setPositiveButton(android.R.string.ok, listener) - .show(); - } - - static void setEdgeToEdge(Window window) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) // Problems with statusbar icon color on A5 - WindowCompat.enableEdgeToEdge(window); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) // No automatic content protection - window.setNavigationBarColor(Color.parseColor("#3E000000")); - ViewGroupCompat.installCompatInsetsDispatch(window.getDecorView().getRootView()); - } - - static void setToolbarInsets(View toolbar) { - ViewCompat.setOnApplyWindowInsetsListener(toolbar, (v, i) -> { - Insets insets = i.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); - v.setPadding(insets.left, insets.top, insets.right, 0); - return WindowInsetsCompat.CONSUMED; - }); - } - - static void setContentInsets(View content) { - setContentInsets(content, false); - } - static void setContentInsets(View content, boolean handleIme) { - ViewCompat.setOnApplyWindowInsetsListener(content, (v, i) -> { - var insetTypes = WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout(); - if (handleIme) insetTypes |= WindowInsetsCompat.Type.ime(); - Insets insets = i.getInsets(insetTypes); - v.setPadding(insets.left, 0, insets.right, insets.bottom); - return WindowInsetsCompat.CONSUMED; - }); - } - - public static String bytesToHumanReadable(long bytes) { - long absB = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes); - if (absB < 1024) { - return bytes + " B"; - } - long value = absB; - CharacterIterator ci = new StringCharacterIterator("KMGTPE"); - for (int i = 40; i >= 0 && absB > 0xfffccccccccccccL >> i; i -= 10) { - value >>= 10; - ci.next(); - } - value *= Long.signum(bytes); - return String.format("%.1f %cB", value / 1024.0, ci.current()); - } - - public static Bitmap getQRCodeBitmap(String text) { - QRCodeWriter writer = new QRCodeWriter(); - try { - BitMatrix bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, 512, 512); - int width = bitMatrix.getWidth(); - int height = bitMatrix.getHeight(); - Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); - for (int x = 0; x < width; x++) { - for (int y = 0; y < height; y++) { - bmp.setPixel(x, y, bitMatrix.get(x, y) ? Color.BLACK : Color.WHITE); - } - } - return bmp; - } catch (WriterException e) { - e.printStackTrace(); - } - return null; - } - - @SuppressLint("Range") - public static String getNameFromUri(Context ctx, Uri uri) { - String result = null; - if ("content".equals(uri.getScheme())) { - try { - Cursor cursor = ctx.getContentResolver().query(uri, null, null, null, null); - if (cursor != null && cursor.moveToFirst()) { - result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); - cursor.close(); - } - } catch(Exception ignored) {} - } - if (result == null) { - String[] parts = URLDecoder.decode(uri.toString()).split("/"); - return parts[parts.length-1]; - } - return result; - } - - public static int getIconForRemoteStatus(Remote.RemoteStatus status) { - switch (status) { - case CONNECTING: - return R.drawable.ic_status_connecting; - case AWAITING_DUPLEX: - return R.drawable.ic_status_awaiting_duplex; - case CONNECTED: - return R.drawable.ic_status_connected; - case DISCONNECTED: - case ERROR: - default: - return R.drawable.ic_error; - } - } - - public static Uri getChildUri(Uri treeUri, String path) { - String rootID = DocumentsContract.getTreeDocumentId(treeUri); - String docID = rootID + "/" + path; - return DocumentsContract.buildDocumentUriUsingTree(treeUri, docID); - } - - public static DocumentFile getChildFromTree(Context ctx, Uri treeUri, String path) { - Uri childUri = getChildUri(treeUri, path); - return DocumentFile.fromSingleUri(ctx, childUri); - } - - //Just like DocumentFile.exists() but doesn't spam "Failed query" when file is not found - public static boolean pathExistsInTree(Context ctx, Uri treeUri, String path) { - ContentResolver resolver = ctx.getContentResolver(); - Uri u = getChildUri(treeUri, path); - Cursor c; - try { - c = resolver.query(u, new String[]{ - DocumentsContract.Document.COLUMN_DOCUMENT_ID}, null, null, null); - boolean found = c.getCount() > 0; - c.close(); - return found; - } catch (Exception ignored) {} - return false; - } - - static boolean isMyServiceRunning(Context ctx, Class serviceClass) { - ActivityManager manager = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE); - assert manager != null; - for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { - if (serviceClass.getName().equals(service.service.getClassName())) { - return true; - } - } - return false; - } - - public static boolean isConnectedToWiFiOrEthernet(Context ctx) { - ConnectivityManager connManager = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); - assert connManager != null; - NetworkInfo wifi = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); - NetworkInfo ethernet = connManager.getNetworkInfo(ConnectivityManager.TYPE_ETHERNET); - return (wifi != null && wifi.isConnected()) || (ethernet != null && ethernet.isConnected()); - } - - public static boolean isHotspotOn(Context ctx) { - WifiManager manager = (WifiManager) ctx.getApplicationContext().getSystemService(Context.WIFI_SERVICE); - assert manager != null; - try { - final Method method = manager.getClass().getDeclaredMethod("isWifiApEnabled"); - method.setAccessible(true); //in the case of visibility change in future APIs - return (Boolean) method.invoke(manager); - } catch (Exception e) { - Log.e(TAG, "Failed to get hotspot state", e); - } - - return false; - } - - public static void sleep(long millis) - { - try { - Thread.sleep(millis); - } catch (InterruptedException e){} - } - - public static int getAttributeColor(Resources.Theme theme, @AttrRes int resID){ - TypedValue typedValue = new TypedValue(); - theme.resolveAttribute(resID, typedValue, true); - return typedValue.data; - } - - public static int getAndroidAttributeColor(Context context, @AttrRes int resID){ - TypedValue typedValue = new TypedValue(); - int[] args = {resID}; - TypedArray a = context.obtainStyledAttributes(args); - int color = a.getColor(0, 0); - a.recycle(); - return color; - } - - public static String generateServiceName() { - return getDeviceName().toUpperCase(Locale.ROOT).replace(" ", "") + "-" + getRandomHexString(6); - } - - static String getRandomHexString(int len) - { - char[] buf = new char[len]; - Random random = new Random(); - for (int idx = 0; idx < buf.length; ++idx) - buf[idx] = HEX_ARRAY[random.nextInt(HEX_ARRAY.length)]; - return new String(buf); - } - - //FOR DEBUG PURPOSES - private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); - public static String bytesToHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for (int j = 0; j < bytes.length; j++) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = HEX_ARRAY[v >>> 4]; - hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; - } - return new String(hexChars); - } - - public static class IPInfo { - public Inet4Address address; - public int prefixLength; - IPInfo(Inet4Address addr, int prefix) { - address = addr; - prefixLength = prefix; - } - } - - static class VoidObserver implements StreamObserver { - @Override public void onNext(WarpProto.VoidType value) {} - @Override public void onError(Throwable t) { - Log.e(TAG, "Call failed with exception", t); - } - @Override public void onCompleted() { } - } -} diff --git a/app/src/main/java/slowscript/warpinator/WarpinatorApp.java b/app/src/main/java/slowscript/warpinator/WarpinatorApp.java deleted file mode 100644 index 37d34006..00000000 --- a/app/src/main/java/slowscript/warpinator/WarpinatorApp.java +++ /dev/null @@ -1,63 +0,0 @@ -package slowscript.warpinator; - -import android.app.Activity; -import android.app.Application; -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; - -import com.google.android.material.color.DynamicColors; - -import org.conscrypt.Conscrypt; - -import java.security.Security; - -public class WarpinatorApp extends Application implements Application.ActivityLifecycleCallbacks { - static int activitiesRunning = 0; - static final String TAG = "APP"; - - @Override - public void onCreate() { - super.onCreate(); - Security.insertProviderAt(Conscrypt.newProvider(), 1); - DynamicColors.applyToActivitiesIfAvailable(this); - registerActivityLifecycleCallbacks(this); - activitiesRunning = 0; - - // Clear old persisted URI permissions (except profile picture) - String picture = PreferenceManager.getDefaultSharedPreferences(this).getString("profile", "0"); - for (var u : getContentResolver().getPersistedUriPermissions()) { - if (u.getUri().toString().equals(picture)) { - Log.v(TAG, "keeping permission for " + u); - continue; - } - Log.v(TAG, "releasing uri permission " + u); - getContentResolver().releasePersistableUriPermission(u.getUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - @Override - public void onActivityStarted(@NonNull Activity activity) { - Log.d(TAG, "Started activity"); - activitiesRunning++; - MainService.cancelAutoStop(); - } - - @Override - public void onActivityStopped(@NonNull Activity activity) { - activitiesRunning--; - Log.d(TAG, "Stopped activity -> " + activitiesRunning); - if (activitiesRunning < 1) - MainService.scheduleAutoStop(); - } - - @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) { } - @Override public void onActivityResumed(@NonNull Activity activity) {} - @Override public void onActivityPaused(@NonNull Activity activity) {} - @Override public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle bundle) { } - @Override public void onActivityDestroyed(@NonNull Activity activity) { } -} diff --git a/app/src/main/java/slowscript/warpinator/app/MainActivity.kt b/app/src/main/java/slowscript/warpinator/app/MainActivity.kt new file mode 100644 index 00000000..8fdf7574 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/app/MainActivity.kt @@ -0,0 +1,89 @@ +package slowscript.warpinator.app + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.input.key.KeyEvent +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint +import slowscript.warpinator.core.data.ThemeViewModel +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.service.MainService +import slowscript.warpinator.core.utils.KeyShortcutDispatcher +import slowscript.warpinator.core.utils.LocalKeyShortcutDispatcher +import slowscript.warpinator.core.utils.Utils + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + val keyShortcutDispatcher = KeyShortcutDispatcher() + + override fun onKeyDown(keyCode: Int, event: android.view.KeyEvent?): Boolean { + if (event != null && keyShortcutDispatcher.dispatch(KeyEvent(event))) return true + return super.onKeyDown(keyCode, event) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 3) + } + } + + if (!Utils.isMyServiceRunning(this, MainService::class.java)) { + startService(Intent(this, MainService::class.java)) + } + + setContent { + val navController = rememberNavController() + + val themeViewModel: ThemeViewModel = hiltViewModel() + val theme by themeViewModel.theme + val useDynamicColors by themeViewModel.dynamicColors + + val isDark = when (theme) { + themeViewModel.themeLightKey -> false + themeViewModel.themeDarkKey -> true + else -> isSystemInDarkTheme() + } + + DisposableEffect(isDark) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + Color.TRANSPARENT, + Color.TRANSPARENT, + ) { isDark }, + navigationBarStyle = SystemBarStyle.auto( + Color.TRANSPARENT, + Color.TRANSPARENT, + ) { isDark }, + ) + onDispose {} + } + + WarpinatorTheme( + darkTheme = isDark, + dynamicColor = useDynamicColors, + ) { + CompositionLocalProvider( + LocalKeyShortcutDispatcher provides keyShortcutDispatcher, + ) { + WarpinatorApp(navController) + } + } + } + } +} diff --git a/app/src/main/java/slowscript/warpinator/app/WarpinatorApp.kt b/app/src/main/java/slowscript/warpinator/app/WarpinatorApp.kt new file mode 100644 index 00000000..f645af5a --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/app/WarpinatorApp.kt @@ -0,0 +1,92 @@ +package slowscript.warpinator.app + +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import slowscript.warpinator.feature.about.AboutScreen +import slowscript.warpinator.feature.home.HomeScreen +import slowscript.warpinator.feature.settings.SettingsScreen + +val LocalNavController = staticCompositionLocalOf { + null +} + +@Composable +fun WarpinatorApp( + navController: NavHostController, +) { + var remoteTarget by remember { mutableStateOf?>(null) } + + Surface(color = MaterialTheme.colorScheme.surface) { + CompositionLocalProvider(LocalNavController provides navController) { + Box(Modifier.fillMaxSize()) { + WarpinatorIntentHandler( + onOpenRemote = { uuid, openMessages -> + remoteTarget = Pair(uuid, openMessages) + + // If we are currently on Settings or About, pop back to Home + if (navController.currentDestination?.route != "home") { + navController.navigate("home") { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + }, + ) + + NavHost( + navController = navController, startDestination = "home", + + enterTransition = { + slideInHorizontally(initialOffsetX = { it }) + }, + exitTransition = { + scaleOut(targetScale = 0.9f) + }, + popEnterTransition = { + scaleIn(initialScale = 0.9f) + }, + popExitTransition = { + slideOutHorizontally(targetOffsetX = { it }) + }, + ) { + composable("home") { + HomeScreen( + remoteTarget = remoteTarget, + onRemoteTargetConsumed = { remoteTarget = null }, + ) + } + + composable("settings") { + SettingsScreen() + } + + composable("about") { + AboutScreen() + } + } + } + } + } +} diff --git a/app/src/main/java/slowscript/warpinator/app/WarpinatorApplication.kt b/app/src/main/java/slowscript/warpinator/app/WarpinatorApplication.kt new file mode 100644 index 00000000..12e3d24c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/app/WarpinatorApplication.kt @@ -0,0 +1,35 @@ +package slowscript.warpinator.app + +import android.app.Application +import android.content.Intent +import android.util.Log +import androidx.preference.PreferenceManager +import dagger.hilt.android.HiltAndroidApp +import org.conscrypt.Conscrypt +import java.security.Security + +@HiltAndroidApp +class WarpinatorApplication : Application() { + override fun onCreate() { + super.onCreate() + Security.insertProviderAt(Conscrypt.newProvider(), 1) + + // Clear old persisted URI permissions (except profile picture) + val picture: String = + PreferenceManager.getDefaultSharedPreferences(this).getString("profile", "0")!! + for (u in contentResolver.persistedUriPermissions) { + if (u.uri.toString() == picture) { + Log.v(TAG, "keeping permission for $u") + continue + } + Log.v(TAG, "releasing uri permission $u") + contentResolver.releasePersistableUriPermission( + u.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + companion object { + const val TAG: String = "APP" + } +} diff --git a/app/src/main/java/slowscript/warpinator/app/WarpinatorIntentHandler.kt b/app/src/main/java/slowscript/warpinator/app/WarpinatorIntentHandler.kt new file mode 100644 index 00000000..e5a45a53 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/app/WarpinatorIntentHandler.kt @@ -0,0 +1,121 @@ +package slowscript.warpinator.app + +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.IntentCompat +import androidx.core.util.Consumer +import slowscript.warpinator.core.utils.transformers.ProtocolAddressInputValidator +import slowscript.warpinator.feature.manual_connection.ManualConnectionDialog +import slowscript.warpinator.feature.manual_connection.ManualConnectionDialogState +import slowscript.warpinator.feature.share.ShareDialog + +@Composable +fun WarpinatorIntentHandler( + onOpenRemote: (uuid: String, openMessages: Boolean) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val activity = context as? ComponentActivity + + var showConnectDialog by rememberSaveable { mutableStateOf(false) } + var connectAddress by rememberSaveable { mutableStateOf(null) } + + var showShareDialog by rememberSaveable { mutableStateOf(false) } + var sharedUris by rememberSaveable { mutableStateOf>(emptyList()) } + var sharedText by rememberSaveable { mutableStateOf(null) } + + fun handleIntent(intent: Intent?) { + if (intent == null) return + + val remoteUuid = intent.getStringExtra("remote") + if (remoteUuid != null) { + val openMessages = intent.getBooleanExtra("messages", false) + onOpenRemote(remoteUuid, openMessages) + } + + when (intent.action) { + Intent.ACTION_VIEW -> { + val data = intent.data + if (data != null && data.scheme == "warpinator") { + val address = data.schemeSpecificPart.removePrefix("//") + if (ProtocolAddressInputValidator.isValidIp(address, false)) { + connectAddress = address + showConnectDialog = true + } + } + } + + Intent.ACTION_SEND -> { + val uri = + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + if (uri != null) { + sharedUris = listOf(uri) + showShareDialog = true + } else if (intent.type?.startsWith("text/") == true) { + sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + showShareDialog = true + } + } + + Intent.ACTION_SEND_MULTIPLE -> { + val uris = IntentCompat.getParcelableArrayListExtra( + intent, + Intent.EXTRA_STREAM, + Uri::class.java, + ) + if (!uris.isNullOrEmpty()) { + sharedUris = uris + showShareDialog = true + } + } + } + + intent.data = null + intent.replaceExtras(null) + } + + + LaunchedEffect(Unit) { + handleIntent(activity?.intent) + + } + + DisposableEffect(Unit) { + val listener = Consumer { intent -> + handleIntent(intent) + } + activity?.addOnNewIntentListener(listener) + onDispose { activity?.removeOnNewIntentListener(listener) } + } + + if (showConnectDialog && connectAddress != null) { + ManualConnectionDialog( + onDismiss = { + showConnectDialog = false + connectAddress = null + }, + dialogState = ManualConnectionDialogState.Connecting(connectAddress!!), + ) + } + + if (showShareDialog && (sharedUris.isNotEmpty() || sharedText != null)) { + ShareDialog( + onDismiss = { + showShareDialog = false + sharedUris = emptyList() + sharedText = null + }, + onOpenRemote = onOpenRemote, + uris = sharedUris, + text = sharedText, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/data/ApplicationScope.kt b/app/src/main/java/slowscript/warpinator/core/data/ApplicationScope.kt new file mode 100644 index 00000000..e50d6deb --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/ApplicationScope.kt @@ -0,0 +1,29 @@ +package slowscript.warpinator.core.data + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineModule { + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +} + +/** + * Dagger qualifier used to identify a [CoroutineScope] that is tied to the lifecycle of the + * entire application. + */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope diff --git a/app/src/main/java/slowscript/warpinator/core/data/ManualConnectionResult.kt b/app/src/main/java/slowscript/warpinator/core/data/ManualConnectionResult.kt new file mode 100644 index 00000000..82c6af46 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/ManualConnectionResult.kt @@ -0,0 +1,12 @@ +package slowscript.warpinator.core.data + +/** + * Represents the possible outcomes of a manual connection attempt between peers. + */ +sealed interface ManualConnectionResult { + data object Success : ManualConnectionResult + data object NotOnSameSubnet : ManualConnectionResult + data object AlreadyConnected : ManualConnectionResult + data object RemoteDoesNotSupportManualConnect : ManualConnectionResult + data class Error(val message: String) : ManualConnectionResult +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/data/NetworkState.kt b/app/src/main/java/slowscript/warpinator/core/data/NetworkState.kt new file mode 100644 index 00000000..26cd2c0e --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/NetworkState.kt @@ -0,0 +1,12 @@ +package slowscript.warpinator.core.data + +/** + * Represents the current connectivity status of the device for Warpinator usage. + */ +data class NetworkState( + val isConnected: Boolean = false, + val isHotspot: Boolean = false +) { + val isOnline: Boolean + get() = isConnected || isHotspot +} diff --git a/app/src/main/java/slowscript/warpinator/core/data/ServiceState.kt b/app/src/main/java/slowscript/warpinator/core/data/ServiceState.kt new file mode 100644 index 00000000..7d7a3754 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/ServiceState.kt @@ -0,0 +1,12 @@ +package slowscript.warpinator.core.data + +/** + * Represents the various states of the background service. + */ +sealed interface ServiceState { + data object Ok : ServiceState + data object Starting : ServiceState + data object Stopping : ServiceState + data object NetworkChangeRestart : ServiceState + data class InitializationFailed(val interfaces: String?, val exception: String) : ServiceState +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/data/ThemeViewModel.kt b/app/src/main/java/slowscript/warpinator/core/data/ThemeViewModel.kt new file mode 100644 index 00000000..f9c6b81b --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/ThemeViewModel.kt @@ -0,0 +1,44 @@ +package slowscript.warpinator.core.data + +import android.content.SharedPreferences +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import slowscript.warpinator.core.system.PreferenceManager +import javax.inject.Inject + +@HiltViewModel +class ThemeViewModel @Inject constructor( + private val preferenceManager: PreferenceManager, +) : ViewModel() { + private val _theme = mutableStateOf(preferenceManager.theme) + val theme: State = _theme + val themeLightKey = PreferenceManager.VAL_THEME_LIGHT + val themeDarkKey = PreferenceManager.VAL_THEME_DARK + + + private val _dynamicColors = mutableStateOf(preferenceManager.dynamicColors) + val dynamicColors: State = _dynamicColors + + private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + PreferenceManager.KEY_THEME -> { + _theme.value = preferenceManager.theme + } + + PreferenceManager.KEY_DYNAMIC_COLORS -> { + _dynamicColors.value = preferenceManager.dynamicColors + } + } + } + + init { + preferenceManager.prefs.registerOnSharedPreferenceChangeListener(preferenceListener) + } + + override fun onCleared() { + super.onCleared() + preferenceManager.prefs.unregisterOnSharedPreferenceChangeListener(preferenceListener) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/data/WarpinatorRepository.kt b/app/src/main/java/slowscript/warpinator/core/data/WarpinatorRepository.kt new file mode 100644 index 00000000..73474289 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/WarpinatorRepository.kt @@ -0,0 +1,247 @@ +package slowscript.warpinator.core.data + +import android.content.Context +import android.graphics.Bitmap +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Remote.RemoteStatus +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.service.RemotesManager +import slowscript.warpinator.core.service.TransfersManager +import slowscript.warpinator.core.system.PreferenceManager +import slowscript.warpinator.core.utils.Utils.IPInfo +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WarpinatorRepository @Inject constructor( + val remotesManager: dagger.Lazy, + val transfersManager: dagger.Lazy, + @param:ApplicationContext val appContext: Context, + @param:ApplicationScope val applicationScope: CoroutineScope, +) { + // States + private val _remoteListState = MutableStateFlow>(emptyList()) + private val _serviceState = MutableStateFlow(ServiceState.Starting) + private val _networkState = MutableStateFlow(NetworkState()) + private val _refreshing = MutableStateFlow(false) + private val _uiMessages = Channel(Channel.BUFFERED) + private val _seenMarkers = mutableSetOf() + + // Observables + val remoteListState = _remoteListState.asStateFlow() + val serviceState = _serviceState.asStateFlow() + val networkState = _networkState.asStateFlow() + val refreshingState = _refreshing.asStateFlow() + val uiMessages = _uiMessages.receiveAsFlow() + + val prefs = PreferenceManager(appContext) + + // Service variables + var currentIPInfo: IPInfo? = null + val currentIPStr: String? + get() = currentIPInfo?.address?.hostAddress + + init { + prefs.loadSettings() + } + + fun updateServiceState(newState: ServiceState) { + _serviceState.value = newState + } + + fun updateNetworkState(newState: NetworkState) { + _networkState.value = newState + } + + fun updateNetworkState(function: (NetworkState) -> NetworkState) { + _networkState.update(function) + } + + fun setRefresh(refreshing: Boolean) { + _refreshing.value = refreshing + } + + fun emitMessage(message: UiMessage, oneTime: Boolean = false) { + if (oneTime) { + if (_seenMarkers.contains(message.id)) return + _seenMarkers.add(message.id!!) + } + applicationScope.launch { + _uiMessages.send(message) + } + } + + + // Remotes methods + fun getRemoteFlow(uuid: String): Flow { + return _remoteListState.map { list -> + list.find { it.uuid == uuid } + }.distinctUntilChanged() + } + + fun getRemoteStatus(uuid: String): RemoteStatus { + return _remoteListState.value.find { it.uuid == uuid }?.status ?: RemoteStatus.Disconnected + } + + + fun addOrUpdateRemote(newRemote: Remote) { + _remoteListState.update { currentList -> + val index = currentList.indexOfFirst { it.uuid == newRemote.uuid } + val newList = if (index != -1) { + // Preserve existing transfers + val existing = currentList[index] + val merged = newRemote.copy( + transfers = existing.transfers, + status = existing.status, + isFavorite = existing.isFavorite, + ) + currentList.toMutableList().apply { set(index, merged) } + } else { + currentList + newRemote + } + sortRemotes(newList) + } + } + + fun updateRemoteStatus(uuid: String, status: RemoteStatus) { + updateRemote(uuid) { it.copy(status = status) } + } + + fun updateRemotePicture(uuid: String, picture: Bitmap?) { + updateRemote(uuid) { it.copy(picture = picture) } + } + + fun clearRemotes() { + _remoteListState.value = emptyList() + } + + fun toggleFavorite(uuid: String) { + updateRemote(uuid) { + it.copy(isFavorite = prefs.toggleFavorite(uuid)) + } + } + + fun updateRemote(uuid: String, transform: (Remote) -> Remote) { + _remoteListState.update { currentList -> + val newList = currentList.map { + if (it.uuid == uuid) transform(it) else it + } + sortRemotes(newList) + } + } + + private fun sortRemotes(list: List): List { + return list.sortedWith( + compareByDescending { it.isFavorite }.thenBy { + it.displayName ?: it.hostname ?: "" + }, + ) + } + + // Transfers methods + fun addTransfer(remoteUuid: String, transfer: Transfer) { + updateRemote(remoteUuid) { remote -> + val existingTransfers = remote.transfers.filterNot { it.uid == transfer.uid } + remote.copy(transfers = listOf(transfer) + existingTransfers) + } + } + + fun updateTransfer(remoteUuid: String, updatedTransfer: Transfer) { + _remoteListState.update { currentList -> + val remoteIndex = currentList.indexOfFirst { it.uuid == remoteUuid } + if (remoteIndex == -1) return@update currentList + + val remote = currentList[remoteIndex] + val newTransfers = remote.transfers.map { + if (it.uid == updatedTransfer.uid) updatedTransfer else it + } + + val newRemote = remote.copy(transfers = newTransfers) + + val newList = currentList.toMutableList() + newList[remoteIndex] = newRemote + newList + } + } + + fun acceptTransfer(remoteUuid: String, transfer: Transfer) { + transfersManager.get().getWorker(remoteUuid, transfer.startTime)?.startReceive() + } + + fun declineTransfer(remoteUuid: String, transfer: Transfer) { + transfersManager.get().getWorker(remoteUuid, transfer.startTime)?.declineTransfer() + + } + + fun cancelTransfer(remoteUuid: String, transfer: Transfer) { + val worker = transfersManager.get().getWorker(remoteUuid, transfer.startTime) + worker?.stop(error = false) ?: run { + updateTransfer(remoteUuid, transfer.copy(status = Transfer.Status.Stopped)) + } + } + + fun retryTransfer(transfer: Transfer) { + if (transfer.direction == Transfer.Direction.Send) { + applicationScope.launch { + transfersManager.get().retrySend( + transfer, isDir = false, + ) + } + } + } + + fun clearTransfer(remoteUuid: String, transferUid: String) { + updateRemote(remoteUuid) { remote -> + remote.copy(transfers = remote.transfers.filterNot { it.uid == transferUid }) + } + val transfer = + remoteListState.value.find { it.uuid == remoteUuid }?.transfers?.find { it.uid == transferUid } + if (transfer != null) { + transfersManager.get().removeWorker(remoteUuid, transfer.startTime) + } + } + + fun clearMessage(remoteUuid: String, timestamp: Long) { + updateRemote(remoteUuid) { remote -> + remote.copy(messages = remote.messages.filterNot { it.timestamp == timestamp }) + } + } + + fun clearAllFinishedTransfers(remoteUuid: String) { + updateRemote(remoteUuid) { remote -> + val activeTransfers = remote.transfers.filter { + it.status is Transfer.Status.Transferring || it.status is Transfer.Status.WaitingPermission || it.status is Transfer.Status.Initializing + } + val removedTransfers = remote.transfers - activeTransfers.toSet() + removedTransfers.forEach { t -> + transfersManager.get().removeWorker(remoteUuid, t.startTime) + } + + remote.copy(transfers = activeTransfers, messages = listOf()) + } + } + + // Message methods + fun addRemoteMessage(remoteUuid: String, message: Message, markUnread: Boolean = false) { + updateRemote(remoteUuid) { remote -> + val existingMessages = remote.messages.filterNot { it.timestamp == message.timestamp } + remote.copy( + messages = listOf(message) + existingMessages, + hasUnreadMessages = remote.hasUnreadMessages || markUnread, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/data/WarpinatorViewModel.kt b/app/src/main/java/slowscript/warpinator/core/data/WarpinatorViewModel.kt new file mode 100644 index 00000000..d8056b99 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/data/WarpinatorViewModel.kt @@ -0,0 +1,239 @@ +package slowscript.warpinator.core.data + +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.system.PreferenceManager +import slowscript.warpinator.core.utils.LogFileWriter +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class WarpinatorViewModel @Inject constructor( + val repository: WarpinatorRepository, private val server: Server, + private val preferenceManager: PreferenceManager, +) : ViewModel() { + // UI States + val remoteListState = repository.remoteListState.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList(), + ) + + val serviceState = repository.serviceState.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), ServiceState.Starting, + ) + val networkState = repository.networkState.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), + NetworkState( + // Set isConnected to true so the UI doesn't show the disconnected state before the Server actually cheks connection + isConnected = true, isHotspot = false, + ), + ) + + val refreshState = repository.refreshingState.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), false, + ) + + val integrateMessages get() = preferenceManager.integrateMessages + + val address: String + get() { + return "${repository.currentIPStr}:${server.authPort}" + } + + val uiMessages = repository.uiMessages + + // Remotes + + fun getRemote(uuid: String?): Flow { + if (uuid == null) return flowOf(null) + return repository.getRemoteFlow(uuid) + } + + fun toggleFavorite(uuid: String) { + viewModelScope.launch { + repository.toggleFavorite(uuid) + } + } + + fun reconnect(remote: Remote) { + viewModelScope.launch { + repository.remotesManager.get().getWorker(remote.uuid)?.connect(remote) + } + } + + + fun rescan() { + server.rescan() + } + + fun reannounce() { + server.reannounce() + } + + suspend fun connectToRemoteHost(address: String): ManualConnectionResult { + return server.tryRegisterWithHost(address) + } + + // Transfers + + fun sendUris(remote: Remote, uris: List, isDir: Boolean) { + repository.applicationScope.launch { + val contentResolver = repository.appContext.contentResolver + + for (uri in uris) { + try { + contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch ( + _: SecurityException, ) { + // Silent catch situations where the Uri provider didn't give a persistable uri + } + + } + repository.transfersManager.get().initiateSend(remote, uris, isDir) + } + } + + fun acceptTransfer(transfer: Transfer) { + repository.acceptTransfer(transfer.remoteUuid, transfer) + } + + fun declineTransfer(transfer: Transfer) { + repository.declineTransfer(transfer.remoteUuid, transfer) + } + + fun cancelTransfer(transfer: Transfer) { + repository.cancelTransfer(transfer.remoteUuid, transfer) + } + + fun retryTransfer(transfer: Transfer) { + repository.retryTransfer(transfer) + } + + fun clearTransfer(transfer: Transfer) { + repository.clearTransfer(transfer.remoteUuid, transfer.uid) + } + + fun clearMessage(message: Message) { + repository.clearMessage(message.remoteUuid, message.timestamp) + } + + fun clearAllFinished(remoteUuid: String) { + repository.clearAllFinishedTransfers(remoteUuid) + } + + + /** + * Attempts to open the file or directory associated with a completed transfer. + * + * If the transfer consists of multiple files, it attempts to open the download directory. + * If it is a single file, it attempts to open the file using its MIME type. + * + * @return `true` if the intent was successfully started, `false` otherwise (e.g., if the + * transfer is an outgoing one, the file doesn't exist, or no compatible app is found). + */ + fun openTransfer(transfer: Transfer): Boolean { + if (transfer.direction == Transfer.Direction.Send) return false + + val downloadUriStr = server.downloadDirUri ?: return false + + try { + val intent = Intent(Intent.ACTION_VIEW) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (transfer.fileCount > 1) { + if (downloadUriStr.startsWith("content:")) { + intent.setDataAndType( + downloadUriStr.toUri(), DocumentsContract.Document.MIME_TYPE_DIR, + ) + } else { + File(downloadUriStr) + intent.setDataAndType(downloadUriStr.toUri(), "resource/folder") + } + } else { + val filename = transfer.singleFileName!! + + if (downloadUriStr.startsWith("content:")) { + val treeUri = downloadUriStr.toUri() + val fileDoc = slowscript.warpinator.core.utils.Utils.getChildFromTree( + repository.appContext, treeUri, filename, + ) + + if (fileDoc.exists()) { + intent.setDataAndType(fileDoc.uri, transfer.singleMimeType) + } else { + return false + } + } else { + // File System + val file = File(downloadUriStr, filename) + if (file.exists()) { + val uri = androidx.core.content.FileProvider.getUriForFile( + repository.appContext, + "${repository.appContext.packageName}.provider", + file, + ) + intent.setDataAndType(uri, transfer.singleMimeType) + } else { + return false + } + } + } + + repository.appContext.startActivity(intent) + return true + + } catch (_: Exception) { + try { + val dirIntent = Intent(Intent.ACTION_VIEW) + dirIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + dirIntent.setDataAndType( + downloadUriStr.toUri(), DocumentsContract.Document.MIME_TYPE_DIR, + ) + repository.appContext.startActivity(dirIntent) + return true + } catch (_: Exception) { + return false + } + } + } + + fun sendTextMessage(remote: Remote, message: String) { + repository.applicationScope.launch { + + repository.remotesManager.get().getWorker(remote.uuid)?.sendTextMessage(message) + } + } + + fun markTextMessagesAsRead(remote: Remote) { + repository.applicationScope.launch { + repository.updateRemote( + remote.uuid, + ) { remote -> remote.copy(hasUnreadMessages = false) } + } + } + + // Utils + + fun saveLog(uri: Uri) = LogFileWriter.writeLog( + uri, + repository.applicationScope, + repository.appContext, + repository::emitMessage, + ) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/DynamicAvatarCircle.kt b/app/src/main/java/slowscript/warpinator/core/design/components/DynamicAvatarCircle.kt new file mode 100644 index 00000000..4093f907 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/DynamicAvatarCircle.kt @@ -0,0 +1,175 @@ +package slowscript.warpinator.core.design.components + +import android.graphics.Bitmap +import android.graphics.Matrix +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.transformed +import kotlinx.coroutines.isActive +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.utils.ProfilePicturePainter + +// Slightly sized down loading shape to prevent revealing outside of the bitmap +private val matrix = Matrix().apply { + setScale(0.95f, 0.95f) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private val LoadingShape = MaterialShapes.Cookie4Sided.transformed(matrix) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun DynamicAvatarCircle( + bitmap: Bitmap? = null, + isFavorite: Boolean = false, + hasError: Boolean = false, + isDisabled: Boolean = false, + isLoading: Boolean = false, + size: Dp = 42.dp, +) { + val rotation = remember { Animatable(0f) } + + LaunchedEffect(isLoading) { + if (!isLoading) { + rotation.snapTo(0f) + return@LaunchedEffect + } + while (this.isActive) { + rotation.animateTo( + targetValue = 180f, animationSpec = spring( + dampingRatio = 0.7f, stiffness = 80f, visibilityThreshold = 1.0f + ) + ) + rotation.snapTo(0f) + } + } + + val backgroundColor = if (hasError) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + + val iconColor = if (hasError) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + + val errorBorder = if (hasError) { + BorderStroke(2.dp, MaterialTheme.colorScheme.error) + } else null + + + val containerShape = when { + isLoading -> LoadingShape.toShape() + isFavorite -> MaterialShapes.Cookie7Sided.toShape() + else -> CircleShape + } + + + Surface( + modifier = Modifier + .size(size) + .padding(1.dp) + .alpha(if (isDisabled) 0.5f else 1f) + .rotate(rotation.value), + shape = containerShape, + color = backgroundColor, + border = errorBorder + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .rotate(-rotation.value) + ) { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + Icons.Rounded.Person, contentDescription = null, tint = iconColor + ) + } + } + } +} + +@PreviewDynamicColors() +@PreviewLightDark +@Composable +fun DynamicAvatarCirclePreview() { + val bitmap = ProfilePicturePainter.getProfilePicture("1", LocalContext.current) + + WarpinatorTheme { + Surface( + color = MaterialTheme.colorScheme.surface, modifier = Modifier.safeContentPadding() + ) { + FlowRow( + modifier = Modifier + .padding(12.dp) + .width((44 * 6).dp) + ) { + DynamicAvatarCircle() + DynamicAvatarCircle(isLoading = true) + DynamicAvatarCircle(isFavorite = true) + DynamicAvatarCircle(hasError = true) + DynamicAvatarCircle(hasError = true, isFavorite = true) + DynamicAvatarCircle(isDisabled = true) + + // With bitmaps + DynamicAvatarCircle(bitmap = bitmap) + DynamicAvatarCircle(bitmap = bitmap, isLoading = true) + DynamicAvatarCircle(bitmap = bitmap, isFavorite = true) + DynamicAvatarCircle(bitmap = bitmap, hasError = true) + DynamicAvatarCircle(bitmap = bitmap, hasError = true, isFavorite = true) + DynamicAvatarCircle(bitmap = bitmap, isDisabled = true) + + // Increased size + DynamicAvatarCircle(size = 84.dp) + DynamicAvatarCircle(size = 84.dp, isLoading = true) + DynamicAvatarCircle(size = 84.dp, isFavorite = true) + DynamicAvatarCircle(size = 84.dp, hasError = true) + DynamicAvatarCircle(size = 84.dp, hasError = true, isFavorite = true) + DynamicAvatarCircle(size = 84.dp, isDisabled = true) + } + } + } +} diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/ExpandableSegmentedListItem.kt b/app/src/main/java/slowscript/warpinator/core/design/components/ExpandableSegmentedListItem.kt new file mode 100644 index 00000000..d45669c1 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/ExpandableSegmentedListItem.kt @@ -0,0 +1,189 @@ +package slowscript.warpinator.core.design.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import slowscript.warpinator.R +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes +import slowscript.warpinator.core.design.theme.WarpinatorTheme + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ExpandableSegmentedListItem( + isExpanded: Boolean, + toggleExpand: () -> Unit, + modifier: Modifier = Modifier, + listItemModifier: Modifier = Modifier, + colors: ListItemColors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + itemIndex: Int, + listItemCount: Int, + subItemCount: Int, + // Slots + content: @Composable () -> Unit, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + subItemBuilder: @Composable ( + subItemIndex: Int, containerColor: Color, shape: Shape, + ) -> Unit, + interactionSource: MutableInteractionSource? = null, +) { + val motionScheme = MaterialTheme.motionScheme + + val headerShape = + ListItemDefaults.segmentedDynamicShapes(index = itemIndex, count = listItemCount).copy( + selectedShape = ListItemDefaults.segmentedDynamicShapes( + index = 0, count = subItemCount + 1, + ).shape, + ) + + Column( + modifier = modifier, + ) { + + val stateDescription = + if (isExpanded) stringResource(R.string.expanded_state) else stringResource( + R.string.collapsed_state, + ) + + val actionLabel = + if (isExpanded) stringResource(R.string.collapse_action) else stringResource( + R.string.expand_details_action, + ) + + + SegmentedListItem( + onClick = toggleExpand, + modifier = listItemModifier.semantics { + this.stateDescription = stateDescription + onClick(label = actionLabel, action = null) + + role = Role.Button + }, + colors = colors, + selected = isExpanded, + shapes = headerShape, + leadingContent = leadingContent, + trailingContent = { + trailingContent?.let { trailingContent -> + AnimatedVisibility( + visible = !isExpanded, + enter = expandHorizontally(motionScheme.fastSpatialSpec()), + exit = shrinkHorizontally(motionScheme.fastSpatialSpec()), + label = "TrailingContentAnimatedVisibility", + ) { + trailingContent() + } + } + }, + content = content, + supportingContent = supportingContent, + interactionSource = interactionSource, + ) + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(motionScheme.fastSpatialSpec()), + exit = shrinkVertically(motionScheme.fastSpatialSpec()), + label = "DetailsContentAnimatedVisibility", + ) { + CompositionLocalProvider(LocalContentColor provides colors.selectedContentColor) { + Column( + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + modifier = Modifier.padding(top = ListItemDefaults.SegmentedGap), + ) { + repeat(subItemCount) { subItemIndex -> + val shape = ListItemDefaults.segmentedDynamicShapes( + index = subItemIndex + 1, count = subItemCount + 1, + ).shape + subItemBuilder(subItemIndex, colors.selectedContainerColor, shape) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Preview +@Composable +fun ExpandableListScreen() { + val items = List(5) { "Item $it" } + var expandedItemId by remember { mutableStateOf(items[1]) } + + WarpinatorTheme { + Scaffold { paddingValues -> + LazyColumn( + contentPadding = paddingValues, + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + itemsIndexed(items) { itemIndex, itemId -> + ExpandableSegmentedListItem( + isExpanded = expandedItemId == itemId, + toggleExpand = { + expandedItemId = if (expandedItemId == itemId) null else itemId + }, + content = { Text(text = "Title for $itemId") }, + supportingContent = { Text(text = "Subtitle details") }, + subItemBuilder = { subItemIndex, containerColor, shape -> + Surface( + color = containerColor, + shape = shape, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "This is a detail panel", + ) + if (subItemIndex == 1) Button(onClick = {}) { Text("Action") } + } + } + }, + subItemCount = 2, + itemIndex = itemIndex, + listItemCount = items.size, + ) + } + } + } + } +} + diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/FileDropTargetIndicator.kt b/app/src/main/java/slowscript/warpinator/core/design/components/FileDropTargetIndicator.kt new file mode 100644 index 00000000..6985ef88 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/FileDropTargetIndicator.kt @@ -0,0 +1,135 @@ +package slowscript.warpinator.core.design.components + +import android.net.Uri +import android.os.Build +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.toAndroidDragEvent + +enum class DragAndDropUiMode { + None, DragActive, DragHover, +} + +data class DropTargetState( + val target: DragAndDropTarget? = null, + val shouldStartDragAndDrop: (DragAndDropEvent) -> Boolean = { false }, + val uiMode: DragAndDropUiMode = DragAndDropUiMode.None, +) + +@Composable +fun rememberDropTargetState( + onUrisDropped: (List) -> Boolean, + shouldStartDragAndDrop: (DragAndDropEvent) -> Boolean = { true }, +): DropTargetState { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return DropTargetState() + } + + var fileDropTargetState by remember { mutableStateOf(DragAndDropUiMode.None) } + val activity = LocalActivity.current + + val currentOnDrop by rememberUpdatedState(onUrisDropped) + val currentShouldStartDragAndDrop by rememberUpdatedState(shouldStartDragAndDrop) + + val fileDropTarget = remember(activity) { + object : DragAndDropTarget { + override fun onStarted(event: DragAndDropEvent) { + fileDropTargetState = DragAndDropUiMode.DragActive + } + + override fun onEntered(event: DragAndDropEvent) { + fileDropTargetState = DragAndDropUiMode.DragHover + } + + override fun onEnded(event: DragAndDropEvent) { + fileDropTargetState = DragAndDropUiMode.None + } + + override fun onExited(event: DragAndDropEvent) { + fileDropTargetState = DragAndDropUiMode.DragActive + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + fileDropTargetState = DragAndDropUiMode.None + + if (activity == null) return false + + activity.requestDragAndDropPermissions(event.toAndroidDragEvent()) + + val clipData = event.toAndroidDragEvent().clipData ?: return false + val uris = (0 until clipData.itemCount).mapNotNull { i -> + clipData.getItemAt(i).uri + } + + if (uris.isNotEmpty()) { + return currentOnDrop(uris) + } + return false + } + } + } + + return DropTargetState( + target = fileDropTarget, + uiMode = fileDropTargetState, + shouldStartDragAndDrop = currentShouldStartDragAndDrop, + ) +} + +fun Modifier.fileDropTarget(state: DropTargetState): Modifier { + if (state.target == null) return this + + return this.dragAndDropTarget( + shouldStartDragAndDrop = state.shouldStartDragAndDrop, + target = state.target, + ) +} + +@Composable +fun FileDropTargetIndicator( + dropMode: DragAndDropUiMode, + text: String, + modifier: Modifier = Modifier, +) { + if (dropMode == DragAndDropUiMode.None) return + + val textColor = when (dropMode) { + DragAndDropUiMode.DragHover -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + + Surface( + modifier = modifier, + color = when (dropMode) { + DragAndDropUiMode.DragHover -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainerHighest + }, + shape = MaterialTheme.shapes.large, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text, + style = MaterialTheme.typography.titleLarge, + color = textColor, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/MenuGroupsPopup.kt b/app/src/main/java/slowscript/warpinator/core/design/components/MenuGroupsPopup.kt new file mode 100644 index 00000000..73ccef93 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/MenuGroupsPopup.kt @@ -0,0 +1,119 @@ +package slowscript.warpinator.core.design.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.window.PopupProperties + +data class MenuAction( + val title: String, + val trailingIcon: ImageVector? = null, + val leadingIcon: ImageVector? = null, + val shortcutKeyCode: Int? = null, + val shortcutKeyCtrl: Boolean = false, + val shortcutKeyShift: Boolean = false, + val shortcutKeyAlt: Boolean = false, + val onClick: () -> Unit, +) + +data class MenuGroup(val actions: List, val errorGroup: Boolean = false) + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun MenuGroupsPopup( + menuOpen: Boolean, + menuGroups: List, + groupInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onDismiss: () -> Unit = {}, + offset: DpOffset = DpOffset.Zero, + properties: PopupProperties = PopupProperties(), + minWidth: Dp? = null, +) { + DropdownMenuPopup( + expanded = menuOpen, + onDismissRequest = onDismiss, + offset = offset, + properties = properties, + ) { + val colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + val errorColors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.onErrorContainer, + leadingIconColor = MaterialTheme.colorScheme.onErrorContainer, + trailingIconColor = MaterialTheme.colorScheme.onErrorContainer, + ) + + + menuGroups.forEachIndexed { index, group -> + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(index, menuGroups.size), + interactionSource = groupInteractionSource, + containerColor = if (group.errorGroup) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer, + ) { + group.actions.forEach { action -> + DropdownMenuItem( + text = { + Column { + Text(action.title) + if (action.shortcutKeyCode != null) { + ShortcutLabel( + action.shortcutKeyCode, + action.shortcutKeyCtrl, + action.shortcutKeyShift, + action.shortcutKeyAlt, + ) + } + } + }, + leadingIcon = action.leadingIcon?.let { leadingIcon -> + { + Icon( + leadingIcon, contentDescription = null, + ) + } + }, + trailingIcon = action.trailingIcon?.let { trailingIcon -> + { + Icon( + trailingIcon, contentDescription = null, + ) + } + }, + onClick = { + action.onClick() + onDismiss() + }, + colors = if (group.errorGroup) errorColors else colors, + modifier = minWidth?.let { + Modifier.widthIn(min = it) + } ?: Modifier, + ) + } + + } + if (index != menuGroups.size - 1) { + Spacer(Modifier.height(MenuDefaults.GroupSpacing)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/MessagesHandlerEffect.kt b/app/src/main/java/slowscript/warpinator/core/design/components/MessagesHandlerEffect.kt new file mode 100644 index 00000000..4562d69c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/MessagesHandlerEffect.kt @@ -0,0 +1,53 @@ +package slowscript.warpinator.core.design.components + +import android.content.Context +import android.widget.Toast +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import slowscript.warpinator.core.model.ui.MessageType +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +@Composable +fun MessagesHandlerEffect( + messageProvider: Flow, + snackbarHostState: SnackbarHostState? = null, +) { + val context = LocalContext.current + + val abstractMessage by messageProvider.collectAsStateWithLifecycle(initialValue = null) + val resolvedState = abstractMessage?.getState() + + LaunchedEffect(abstractMessage, resolvedState) { + resolvedState?.let { state -> + handleMessage(state, snackbarHostState, context) + } + } +} + +private suspend fun handleMessage( + state: UiMessageState, + snackbarHost: SnackbarHostState? = null, + context: Context? = null, +) { + if (state.type == MessageType.Snackbar) { + snackbarHost?.showSnackbar( + message = state.message, + duration = state.duration, + withDismissAction = state.duration == SnackbarDuration.Indefinite, + ) + } else { + if (context == null) return + Toast.makeText( + context, + state.message, + if (state.duration == SnackbarDuration.Long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT, + ).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/ShortcutLabel.kt b/app/src/main/java/slowscript/warpinator/core/design/components/ShortcutLabel.kt new file mode 100644 index 00000000..0e0e31cc --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/ShortcutLabel.kt @@ -0,0 +1,80 @@ +package slowscript.warpinator.core.design.components + +import android.content.res.Configuration +import android.view.KeyCharacterMap +import android.view.KeyEvent +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.TextStyle + +@Composable +fun rememberHasPhysicalKeyboard(): Boolean { + val configuration = LocalConfiguration.current + return remember { + configuration.keyboard == Configuration.KEYBOARD_QWERTY + } +} + +private fun getKeyLabel(keyCode: Int): String { + val deviceId = KeyCharacterMap.VIRTUAL_KEYBOARD + val map = KeyCharacterMap.load(deviceId) + val char = map.get(keyCode, 0) + return if (char != 0) char.toChar().uppercaseChar().toString() else "" +} + +private fun buildString( + keyCode: Int, + ctrl: Boolean = false, + shift: Boolean = false, + alt: Boolean = false, +): String { + return buildString { + if (ctrl) append("Ctrl+") + if (shift) append("Shift+") + if (alt) append("Alt+") + when (keyCode) { + KeyEvent.KEYCODE_DEL -> append("Del") + KeyEvent.KEYCODE_ENTER -> append("Enter") + KeyEvent.KEYCODE_F1 -> append("F1") + else -> append(getKeyLabel(keyCode)) + } + } +} + +@Composable +fun rememberShortcutLabelText( + keyCode: Int, + ctrl: Boolean = false, + shift: Boolean = false, + alt: Boolean = false, + text: String, +): String { + val hasKeyboard = rememberHasPhysicalKeyboard() + if (!hasKeyboard) return text + + return "$text (${buildString(keyCode, ctrl, shift, alt)})" +} + +@Composable +fun ShortcutLabel( + keyCode: Int, ctrl: Boolean = false, shift: Boolean = false, alt: Boolean = false, + style: TextStyle = MaterialTheme.typography.labelSmall, + color: Color = LocalContentColor.current.copy(alpha = 0.6f), +) { + + val hasKeyboard = rememberHasPhysicalKeyboard() + if (!hasKeyboard) return + + val label = buildString(keyCode, ctrl, shift, alt) + + Text( + text = label, + style = style, + color = color, + ) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/components/TooltipIconButton.kt b/app/src/main/java/slowscript/warpinator/core/design/components/TooltipIconButton.kt new file mode 100644 index 00000000..e63b8d2d --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/components/TooltipIconButton.kt @@ -0,0 +1,60 @@ +package slowscript.warpinator.core.design.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TooltipIconButton( + description: String, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource? = null, + enabled: Boolean = true, + onClick: () -> Unit, + icon: ImageVector, + tint: Color = if (enabled) LocalContentColor.current else LocalContentColor.current.copy(alpha = 0.38f), + addBadge: Boolean = false, +) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Below), + tooltip = { PlainTooltip { Text(description) } }, + state = rememberTooltipState(), + ) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + interactionSource = interactionSource, + ) { + BadgedBox( + badge = { + if (addBadge) { + Badge(containerColor = MaterialTheme.colorScheme.tertiary) + } + }, + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = tint, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/shapes/ListItemSegmentedShapes.kt b/app/src/main/java/slowscript/warpinator/core/design/shapes/ListItemSegmentedShapes.kt new file mode 100644 index 00000000..d77e7016 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/shapes/ListItemSegmentedShapes.kt @@ -0,0 +1,136 @@ +package slowscript.warpinator.core.design.shapes + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.IconButtonShapes +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@ExperimentalMaterial3ExpressiveApi +@Composable +fun ListItemDefaults.segmentedDynamicShapes( + index: Int, + count: Int, + defaultShapes: ListItemShapes = shapes(), + overrideShape: CornerBasedShape = MaterialTheme.shapes.large, +): ListItemShapes { + return remember(index, count, defaultShapes, overrideShape) { + when { + count == 1 -> { + val defaultBaseShape = defaultShapes.shape + if (defaultBaseShape is CornerBasedShape) { + defaultShapes.copy( + shape = defaultBaseShape.copy( + topStart = overrideShape.topStart, + topEnd = overrideShape.topEnd, + bottomStart = overrideShape.bottomStart, + bottomEnd = overrideShape.bottomEnd, + ), + ) + } else { + defaultShapes + } + } + + index == 0 -> { + val defaultBaseShape = defaultShapes.shape + if (defaultBaseShape is CornerBasedShape) { + defaultShapes.copy( + shape = defaultBaseShape.copy( + topStart = overrideShape.topStart, + topEnd = overrideShape.topEnd, + ), + ) + } else { + defaultShapes + } + } + + index == count - 1 -> { + val defaultBaseShape = defaultShapes.shape + if (defaultBaseShape is CornerBasedShape) { + defaultShapes.copy( + shape = defaultBaseShape.copy( + bottomStart = overrideShape.bottomStart, + bottomEnd = overrideShape.bottomEnd, + ), + ) + } else { + defaultShapes + } + } + + else -> defaultShapes + } + } +} + +@ExperimentalMaterial3ExpressiveApi +@Composable +fun ListItemDefaults.segmentedHorizontalDynamicShapes( + index: Int, + count: Int, + defaultShapes: ListItemShapes = shapes(), + overrideShape: CornerBasedShape = MaterialTheme.shapes.large, +): ListItemShapes { + return remember(index, count, defaultShapes, overrideShape) { + when { + count == 1 -> { + val defaultBaseShape = defaultShapes.shape + if (defaultBaseShape is CornerBasedShape) { + defaultShapes.copy( + shape = defaultBaseShape.copy( + topStart = overrideShape.topStart, + topEnd = overrideShape.topEnd, + bottomStart = overrideShape.bottomStart, + bottomEnd = overrideShape.bottomEnd, + ), + ) + } else { + defaultShapes + } + } + + index == 0 -> { + val defaultBaseShape = defaultShapes.shape + if (defaultBaseShape is CornerBasedShape) { + defaultShapes.copy( + shape = defaultBaseShape.copy( + topStart = overrideShape.topStart, + bottomStart = overrideShape.topEnd, + ), + ) + } else { + defaultShapes + } + } + + index == count - 1 -> { + val defaultBaseShape = defaultShapes.shape + if (defaultBaseShape is CornerBasedShape) { + defaultShapes.copy( + shape = defaultBaseShape.copy( + topEnd = overrideShape.bottomStart, + bottomEnd = overrideShape.bottomEnd, + ), + ) + } else { + defaultShapes + } + } + + else -> defaultShapes + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun ListItemShapes.toIconButtonShapes(): IconButtonShapes { + return IconButtonShapes( + this.shape, + this.pressedShape, + ) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/shapes/WarpinatorRoundedIconOutlineShape.kt b/app/src/main/java/slowscript/warpinator/core/design/shapes/WarpinatorRoundedIconOutlineShape.kt new file mode 100644 index 00000000..918558ea --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/shapes/WarpinatorRoundedIconOutlineShape.kt @@ -0,0 +1,41 @@ +package slowscript.warpinator.core.design.shapes + +import androidx.graphics.shapes.CornerRounding +import androidx.graphics.shapes.RoundedPolygon + +private const val ICON_CORNER_RADIUS = 8f +private const val ICON_CENTER_X = 0.5f +private const val ICON_CENTER_Y = 0.5f + +val WarpinatorRoundedIconOutlineShape by lazy { + val vertices = floatArrayOf( + // x, y + 0.409f, 0.623f, + 0.011f, 0.689f, + 0.000f, 0.531f, + 0.363f, 0.506f, + 0.415f, 0.414f, + 0.202f, 0.148f, + 0.334f, 0.060f, + 0.494f, 0.350f, + 0.629f, 0.354f, + 0.775f, 0.000f, + 0.997f, 0.100f, + 0.741f, 0.453f, + 0.734f, 0.638f, + 1.000f, 0.815f, + 0.875f, 1.000f, + 0.663f, 0.704f, + 0.532f, 0.738f, + 0.457f, 0.957f, + 0.330f, 0.887f, + 0.442f, 0.692f, + ) + + RoundedPolygon( + vertices = vertices, + rounding = CornerRounding(radius = ICON_CORNER_RADIUS), + centerX = ICON_CENTER_X, + centerY = ICON_CENTER_Y, + ) +} diff --git a/app/src/main/java/slowscript/warpinator/core/design/theme/Color.kt b/app/src/main/java/slowscript/warpinator/core/design/theme/Color.kt new file mode 100644 index 00000000..fb4c8a89 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/theme/Color.kt @@ -0,0 +1,77 @@ +package slowscript.warpinator.core.design.theme + +import androidx.compose.ui.graphics.Color + +// Generated by the Material Theme Builder + +val primaryLight = Color(0xFF5D5791) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFE4DFFF) +val onPrimaryContainerLight = Color(0xFF454078) +val secondaryLight = Color(0xFF5F5C71) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFE4DFF9) +val onSecondaryContainerLight = Color(0xFF474459) +val tertiaryLight = Color(0xFF7B5266) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFFD8E8) +val onTertiaryContainerLight = Color(0xFF613B4E) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFFCF8FF) +val onBackgroundLight = Color(0xFF1C1B20) +val surfaceLight = Color(0xFFFCF8FF) +val onSurfaceLight = Color(0xFF1C1B20) +val surfaceVariantLight = Color(0xFFE5E1EC) +val onSurfaceVariantLight = Color(0xFF47464F) +val outlineLight = Color(0xFF787680) +val outlineVariantLight = Color(0xFFC9C5D0) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF313036) +val inverseOnSurfaceLight = Color(0xFFF3EFF7) +val inversePrimaryLight = Color(0xFFC6BFFF) +val surfaceDimLight = Color(0xFFDCD8E0) +val surfaceBrightLight = Color(0xFFFCF8FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF6F2FA) +val surfaceContainerLight = Color(0xFFF1ECF4) +val surfaceContainerHighLight = Color(0xFFEBE6EF) +val surfaceContainerHighestLight = Color(0xFFE5E1E9) + +val primaryDark = Color(0xFFC6BFFF) +val onPrimaryDark = Color(0xFF2E295F) +val primaryContainerDark = Color(0xFF454078) +val onPrimaryContainerDark = Color(0xFFE4DFFF) +val secondaryDark = Color(0xFFC8C3DC) +val onSecondaryDark = Color(0xFF302E41) +val secondaryContainerDark = Color(0xFF474459) +val onSecondaryContainerDark = Color(0xFFE4DFF9) +val tertiaryDark = Color(0xFFEBB8CF) +val onTertiaryDark = Color(0xFF482537) +val tertiaryContainerDark = Color(0xFF613B4E) +val onTertiaryContainerDark = Color(0xFFFFD8E8) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF131318) +val onBackgroundDark = Color(0xFFE5E1E9) +val surfaceDark = Color(0xFF131318) +val onSurfaceDark = Color(0xFFE5E1E9) +val surfaceVariantDark = Color(0xFF47464F) +val onSurfaceVariantDark = Color(0xFFC9C5D0) +val outlineDark = Color(0xFF928F99) +val outlineVariantDark = Color(0xFF47464F) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE5E1E9) +val inverseOnSurfaceDark = Color(0xFF313036) +val inversePrimaryDark = Color(0xFF5D5791) +val surfaceDimDark = Color(0xFF131318) +val surfaceBrightDark = Color(0xFF3A383E) +val surfaceContainerLowestDark = Color(0xFF0E0E13) +val surfaceContainerLowDark = Color(0xFF1C1B20) +val surfaceContainerDark = Color(0xFF201F25) +val surfaceContainerHighDark = Color(0xFF2A292F) +val surfaceContainerHighestDark = Color(0xFF35343A) \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/design/theme/Theme.kt b/app/src/main/java/slowscript/warpinator/core/design/theme/Theme.kt new file mode 100644 index 00000000..037ce928 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/theme/Theme.kt @@ -0,0 +1,167 @@ +package slowscript.warpinator.core.design.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp + +private val lightColorScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkColorScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun WarpinatorTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkColorScheme + else -> lightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), + typography = AppTypography, + content = content, + ) +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +fun WarpinatorThemePreview() { + @Composable + fun colorPreview(color: Color, colorLabel: String) { + Surface( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), color = color, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Text(colorLabel) + } + } + } + + WarpinatorTheme { + Surface(color = MaterialTheme.colorScheme.surface) { + Column { + + colorPreview(MaterialTheme.colorScheme.surface, "Surface") + colorPreview(MaterialTheme.colorScheme.primary, "Primary") + colorPreview(MaterialTheme.colorScheme.primaryContainer, "Primary container") + colorPreview(MaterialTheme.colorScheme.secondary, "Secondary") + colorPreview( + MaterialTheme.colorScheme.secondaryContainer, "Secondary container", + ) + colorPreview(MaterialTheme.colorScheme.tertiary, "Tertiary") + colorPreview(MaterialTheme.colorScheme.tertiaryContainer, "Tertiary container") + colorPreview(MaterialTheme.colorScheme.error, "Error") + colorPreview(MaterialTheme.colorScheme.errorContainer, "Error container") + + } + } + } +} diff --git a/app/src/main/java/slowscript/warpinator/core/design/theme/Type.kt b/app/src/main/java/slowscript/warpinator/core/design/theme/Type.kt new file mode 100644 index 00000000..dc5efa7c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/design/theme/Type.kt @@ -0,0 +1,8 @@ +package slowscript.warpinator.core.design.theme + +import androidx.compose.material3.Typography + +// TODO(raresvanca): get Google Sans Flex to work + +val AppTypography = Typography() + diff --git a/app/src/main/java/slowscript/warpinator/core/model/Message.kt b/app/src/main/java/slowscript/warpinator/core/model/Message.kt new file mode 100644 index 00000000..9da8b906 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/Message.kt @@ -0,0 +1,10 @@ +package slowscript.warpinator.core.model + +import slowscript.warpinator.core.model.Transfer.Direction + +data class Message( + val remoteUuid: String, + val direction: Direction, + val timestamp: Long, + val text: String, +) \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/Remote.kt b/app/src/main/java/slowscript/warpinator/core/model/Remote.kt new file mode 100644 index 00000000..3b6bb2de --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/Remote.kt @@ -0,0 +1,51 @@ +package slowscript.warpinator.core.model + +import android.graphics.Bitmap +import java.net.InetAddress + +data class Remote( + val uuid: String, + val address: InetAddress? = null, + val port: Int = 0, + val authPort: Int = 0, + val api: Int = 1, + val serviceName: String? = null, + val userName: String = "", + val hostname: String? = null, + val displayName: String? = null, + val picture: Bitmap? = null, + + val status: RemoteStatus = RemoteStatus.Disconnected, + val serviceAvailable: Boolean = false, + val staticService: Boolean = false, + val hasErrorGroupCode: Boolean = false, + val hasErrorReceiveCert: Boolean = false, + + val transfers: List = emptyList(), + val messages: List = emptyList(), + + val supportsTextMessages: Boolean = false, + val hasUnreadMessages: Boolean = false, + + val isFavorite: Boolean, +) { + sealed interface RemoteStatus { + data object Connected : RemoteStatus + data object Disconnected : RemoteStatus + data object Connecting : RemoteStatus + data class Error( + val message: String = "", + val hasSslException: Boolean = false, + val hasGroupCodeException: Boolean = false, + val isCertificateUnreceived: Boolean = false, + val isDuplexFailed: Boolean = false, + val hasUsernameException: Boolean = false, + ) : RemoteStatus + + data object AwaitingDuplex : RemoteStatus + } + + object RemoteFeatures { + const val TEXT_MESSAGES = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/Transfer.kt b/app/src/main/java/slowscript/warpinator/core/model/Transfer.kt new file mode 100644 index 00000000..a4515b37 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/Transfer.kt @@ -0,0 +1,66 @@ +package slowscript.warpinator.core.model + +import android.net.Uri +import java.util.UUID + +data class Transfer( + val uid: String = UUID.randomUUID().toString(), + val remoteUuid: String, + val direction: Direction, + val status: Status = Status.Initializing, + + // Time + val startTime: Long = System.currentTimeMillis(), + + // Progress + val totalSize: Long = 0, + val bytesTransferred: Long = 0, + val bytesPerSecond: Long = 0, + + // File Details + val fileCount: Long = 0, + val singleFileName: String? = null, // Used if fileCount == 1 or for the top directory + val singleMimeType: String? = null, + val overwriteWarning: Boolean = false, + val topDirBaseNames: List = emptyList(), + + + // For Logic + val useCompression: Boolean = false, + val uris: List = emptyList() +) { + enum class Direction { + Send, Receive + } + + sealed interface Status { + data object Initializing : Status + data object WaitingPermission : Status + data object Transferring : Status + data object Paused : Status + data object Stopped : Status + data object Finished : Status + data object Declined : Status + data class Failed( + val error: Error, val isRecoverable: Boolean + ) : Status + + data class FinishedWithErrors( + val errors: List + ) : Status + } + + sealed interface Error { + data class Generic(val message: String) : Error + data class ConnectionLost(val details: String?) : Error + data object StorageFull : Error + data class FileNotFound(val filename: String) : Error + data class PermissionDenied(val path: String?) : Error + data object DownloadDirectoryNotSet : Error + data object SymlinksNotSupported : Error + } + + enum class FileType(val value: Int) { + File(1), Directory(2), Symlink(3) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/preferences/RecentRemote.kt b/app/src/main/java/slowscript/warpinator/core/model/preferences/RecentRemote.kt new file mode 100644 index 00000000..498b772b --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/preferences/RecentRemote.kt @@ -0,0 +1,16 @@ +package slowscript.warpinator.core.model.preferences + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class RecentRemote(val host: String, val hostname: String) + +fun List.toJson(): String { + return Json.encodeToString(this) +} + + +fun recentRemotesFromJson(json: String): List { + return Json.decodeFromString(json) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/preferences/SavedFavourite.kt b/app/src/main/java/slowscript/warpinator/core/model/preferences/SavedFavourite.kt new file mode 100644 index 00000000..6bfad482 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/preferences/SavedFavourite.kt @@ -0,0 +1,16 @@ +package slowscript.warpinator.core.model.preferences + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class SavedFavourite(val uuid: String) + + +fun Set.toJson(): String { + return Json.encodeToString(this) +} + +fun savedFavouritesFromJson(json: String): Set { + return Json.decodeFromString(json) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/preferences/ThemeOptions.kt b/app/src/main/java/slowscript/warpinator/core/model/preferences/ThemeOptions.kt new file mode 100644 index 00000000..d4a5f0d8 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/preferences/ThemeOptions.kt @@ -0,0 +1,22 @@ +package slowscript.warpinator.core.model.preferences + +import androidx.annotation.StringRes +import slowscript.warpinator.R +import slowscript.warpinator.core.system.PreferenceManager + +/** + * Represents the available application theme modes. + */ +enum class ThemeOptions(val key: String, @param:StringRes val label: Int) { + SYSTEM_DEFAULT(PreferenceManager.VAL_THEME_DEFAULT, R.string.system_default_theme), LIGHT_THEME( + PreferenceManager.VAL_THEME_LIGHT, + R.string.light_theme, + ), + DARK_THEME(PreferenceManager.VAL_THEME_DARK, R.string.dark_theme); + + companion object { + fun fromKey(key: String?): ThemeOptions { + return entries.find { it.key == key } ?: SYSTEM_DEFAULT + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/ui/RemoteRoute.kt b/app/src/main/java/slowscript/warpinator/core/model/ui/RemoteRoute.kt new file mode 100644 index 00000000..4998f3d9 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/ui/RemoteRoute.kt @@ -0,0 +1,7 @@ +package slowscript.warpinator.core.model.ui + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RemoteRoute(val uuid: String) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/model/ui/UiMessage.kt b/app/src/main/java/slowscript/warpinator/core/model/ui/UiMessage.kt new file mode 100644 index 00000000..6ff069dd --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/model/ui/UiMessage.kt @@ -0,0 +1,20 @@ +package slowscript.warpinator.core.model.ui + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable + +data class UiMessageState( + val message: String, + val duration: SnackbarDuration = SnackbarDuration.Short, + val type: MessageType = MessageType.Snackbar, +) + +enum class MessageType { + Snackbar, Toast +} + +abstract class UiMessage { + @Composable + abstract fun getState(): UiMessageState + open val id: Any? = null +} diff --git a/app/src/main/java/slowscript/warpinator/core/network/Authenticator.kt b/app/src/main/java/slowscript/warpinator/core/network/Authenticator.kt new file mode 100644 index 00000000..38430589 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/Authenticator.kt @@ -0,0 +1,259 @@ +package slowscript.warpinator.core.network + +import android.util.Base64 +import android.util.Log +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.asn1.x509.Time +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.bouncycastle.util.io.pem.PemReader +import org.openjax.security.nacl.TweetNaclFast +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.utils.Utils +import slowscript.warpinator.core.utils.Utils.readAllBytes +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.FileReader +import java.io.IOException +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.security.Security +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory + +@Singleton +class Authenticator @Inject constructor( + private val repository: WarpinatorRepository +) { + companion object { + private const val TAG = "AUTH" + const val DEFAULT_GROUP_CODE = "Warpinator" + private const val DAY: Long = 1000L * 60L * 60L * 24 + private const val EXPIRATION_DELTA: Long = 30L * DAY + const val CERTIFICATE_HEADER: String = "-----BEGIN CERTIFICATE-----\n" + const val CERTIFICATE_FOOTER: String = "-----END CERTIFICATE-----" + } + + var groupCode: String = DEFAULT_GROUP_CODE + + var certException: Exception? = null + + val boxedCertificate: ByteArray + get() { + var bytes = ByteArray(0) + try { + val md = MessageDigest.getInstance("SHA-256") + + val key = md.digest( + groupCode.toByteArray(StandardCharsets.UTF_8) + ) + val box = TweetNaclFast.SecretBox(key) + val nonce = TweetNaclFast.makeSecretBoxNonce() + val res = box.box( + serverCertificate, nonce + ) + + bytes = ByteArray(24 + res.size) + System.arraycopy(nonce, 0, bytes, 0, 24) + System.arraycopy(res, 0, bytes, 24, res.size) + } catch (e: Exception) { + Log.wtf( + TAG, "WADUHEK", e + ) + } //This shouldn't fail + + return bytes + } + + val serverCertificate: ByteArray? + get() { + val serverIP: String? = repository.currentIPStr + //Try loading it first + try { + Log.d( + TAG, "Loading server certificate..." + ) + certException = null + val f = getCertificateFile(".self") + val cert = getX509fromFile(f) + cert.checkValidity() //Will throw if expired (and we generate a new one) + val ip = + (cert.subjectAlternativeNames.toTypedArray()[0] as MutableList<*>)[1] as String + if (ip != serverIP) throw Exception() //Throw if IPs don't match (and regenerate cert) + + + return readAllBytes(f) + } catch (_: Exception) { + } + + //Create new one if doesn't exist yet + return createCertificate( + Utils.getDeviceName(repository.appContext), serverIP + ) + } + + fun getCertificateFile(hostname: String): File { + val certsDir: File = Utils.certsDir(repository.appContext) + return File(certsDir, "$hostname.pem") + } + + fun createCertificate(hostname: String, ip: String?): ByteArray? { + var hostname = hostname + try { + Log.d(TAG, "Creating new server certificate...") + + Security.addProvider(BouncyCastleProvider()) + //Create KeyPair + val kp = createKeyPair() + + val now = System.currentTimeMillis() + + //Only allowed chars + hostname = hostname.replace("[^a-zA-Z0-9]".toRegex(), "") + if (hostname.trim { it <= ' ' }.isEmpty()) hostname = "android" + //Build certificate + val name = X500Name("CN=$hostname") + val serial = BigInteger(now.toString()) //Use current time as serial num + val notBefore = Time(Date(now - DAY), Locale.ENGLISH) + val notAfter = Time(Date(now + EXPIRATION_DELTA), Locale.ENGLISH) + + val builder = JcaX509v3CertificateBuilder( + name, serial, notBefore, notAfter, name, kp.public + ) + builder.addExtension( + Extension.subjectAlternativeName, + true, + GeneralNames(GeneralName(GeneralName.iPAddress, ip)) + ) + + //Sign certificate + val signer = JcaContentSignerBuilder("SHA256withRSA").build(kp.private) + val cert = builder.build(signer) + + //Save private key + val privateKeyBytes = kp.private.encoded + saveCertOrKey(".self.key-pem", privateKeyBytes, true) + + //Save cert + val certBytes = cert.encoded + saveCertOrKey(".self.pem", certBytes, false) + + return certBytes + } catch (e: Exception) { + Log.e(TAG, "Failed to create certificate", e) + certException = e + return null + } + } + + fun saveBoxedCert(bytes: ByteArray, remoteUuid: String?): Boolean { + try { + val md = MessageDigest.getInstance("SHA-256") + + val key = md.digest(groupCode.toByteArray(charset("UTF-8"))) + val box = TweetNaclFast.SecretBox(key) + val nonce = ByteArray(24) + val cipher = ByteArray(bytes.size - 24) + System.arraycopy(bytes, 0, nonce, 0, 24) + System.arraycopy(bytes, 24, cipher, 0, bytes.size - 24) + val cert = box.open(cipher, nonce) + if (cert == null) { + Log.w(TAG, "Failed to unbox cert. Wrong group code?") + return false + } + + saveCertOrKey("$remoteUuid.pem", cert, false) + return true + } catch (e: Exception) { + Log.e(TAG, "Failed to unbox and save certificate", e) + return false + } + } + + private fun saveCertOrKey(filename: String, bytes: ByteArray?, isPrivateKey: Boolean) { + val certsDir: File = Utils.certsDir(repository.appContext) + if (!certsDir.exists()) certsDir.mkdir() + val cert = File(certsDir, filename) + + var begin = CERTIFICATE_HEADER + var end = CERTIFICATE_FOOTER + if (isPrivateKey) { + begin = "-----BEGIN PRIVATE KEY-----\n" + end = "-----END PRIVATE KEY-----" + } + val cert64 = Base64.encodeToString(bytes, Base64.DEFAULT) + val certString = begin + cert64 + end + try { + FileOutputStream(cert, false).use { stream -> + stream.write(certString.toByteArray()) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to save certificate or private key: $filename", e) + } + } + + @Throws(GeneralSecurityException::class, IOException::class) + private fun getX509fromFile(f: File?): X509Certificate { + val fileReader = FileReader(f) + val pemReader = PemReader(fileReader) + val obj = pemReader.readPemObject() + pemReader.close() + val result: X509Certificate + ByteArrayInputStream(obj.content).use { `in` -> + result = + CertificateFactory.getInstance("X.509").generateCertificate(`in`) as X509Certificate + } + return result + } + + @Throws(NoSuchAlgorithmException::class) + private fun createKeyPair(algorithm: String = "RSA", bitCount: Int = 2048): KeyPair { + val keyPairGenerator = KeyPairGenerator.getInstance(algorithm) + keyPairGenerator.initialize(bitCount, SecureRandom()) + + return keyPairGenerator.genKeyPair() + } + + @Throws(GeneralSecurityException::class, IOException::class) + fun createSSLSocketFactory(name: String): SSLSocketFactory? { + val crtFile = getCertificateFile(name) + + val sslContext = SSLContext.getInstance("SSL") + + val trustStore = KeyStore.getInstance(KeyStore.getDefaultType()) + trustStore.load(null, null) + + // Read the certificate from disk + val cert = getX509fromFile(crtFile) + + // Add it to the trust store + trustStore.setCertificateEntry(crtFile.name, cert) + + // Convert the trust store to trust managers + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(trustStore) + val trustManagers = tmf.trustManagers + + sslContext.init(null, trustManagers, null) + return sslContext.socketFactory + } +} diff --git a/app/src/main/java/slowscript/warpinator/core/network/CertServer.kt b/app/src/main/java/slowscript/warpinator/core/network/CertServer.kt new file mode 100644 index 00000000..8de8f623 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/CertServer.kt @@ -0,0 +1,75 @@ +package slowscript.warpinator.core.network + +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CertServer @Inject constructor( + var authenticator: Authenticator +) { + var port: Int = 0 + var serverSocket: DatagramSocket? = null + var running: AtomicBoolean = AtomicBoolean(false) + + suspend fun start(port: Int) = withContext(Dispatchers.IO) { + this@CertServer.port = port + stop() + + running.set(true) + + try { + serverSocket = DatagramSocket(port) + Log.d(TAG, "CertServer started on port $port") + } catch (e: Exception) { + Log.e(TAG, "Failed to start certificate server", e) + running.set(false) + return@withContext + } + + val cert = authenticator.boxedCertificate + val sendData = Base64.encode(cert, Base64.DEFAULT) + val receiveData = ByteArray(1024) + + while (running.get() && isActive) { + try { + val receivePacket = DatagramPacket(receiveData, receiveData.size) + serverSocket?.receive(receivePacket) + + val received = receivePacket.data.copyOfRange(0, receivePacket.length) + val request = String(received) + + if (request == REQUEST) { + val address = receivePacket.address + val clientPort = receivePacket.port + val sendPacket = DatagramPacket(sendData, sendData.size, address, clientPort) + serverSocket?.send(sendPacket) + Log.d(TAG, "Certificate sent to $address") + } + } catch (e: Exception) { + if (running.get()) { + Log.w(TAG, "Error in CertServer loop: ${e.message}") + } + } + } + } + + fun stop() { + running.set(false) + serverSocket?.close() + serverSocket = null + } + + companion object { + var TAG: String = "CertServer" + var REQUEST: String = "REQUEST" + + } +} diff --git a/app/src/main/java/slowscript/warpinator/core/network/Server.kt b/app/src/main/java/slowscript/warpinator/core/network/Server.kt new file mode 100644 index 00000000..bbe2cf7c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/Server.kt @@ -0,0 +1,580 @@ +package slowscript.warpinator.core.network + +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.graphics.Bitmap +import android.util.Log +import com.google.common.net.InetAddresses +import com.google.protobuf.ByteString +import io.grpc.ManagedChannel +import io.grpc.Server +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.grpc.netty.GrpcSslContexts +import io.grpc.netty.NettyServerBuilder +import io.grpc.okhttp.OkHttpChannelBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.conscrypt.Conscrypt +import slowscript.warpinator.WarpProto.ServiceRegistration +import slowscript.warpinator.WarpRegistrationGrpc +import slowscript.warpinator.core.data.ManualConnectionResult +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Remote.RemoteStatus +import slowscript.warpinator.core.model.preferences.SavedFavourite +import slowscript.warpinator.core.network.messages.FailedToReannounce +import slowscript.warpinator.core.network.messages.FailedToRescan +import slowscript.warpinator.core.network.messages.FailedToStartGRPC +import slowscript.warpinator.core.network.messages.FailedToStartJmDNS +import slowscript.warpinator.core.network.messages.FailedToStartTLSError +import slowscript.warpinator.core.network.messages.FailedToStartV2 +import slowscript.warpinator.core.network.messages.FailedToUnregister +import slowscript.warpinator.core.service.GrpcService +import slowscript.warpinator.core.service.RegistrationService +import slowscript.warpinator.core.service.RemotesManager +import slowscript.warpinator.core.service.TransfersManager +import slowscript.warpinator.core.utils.ProfilePicturePainter +import slowscript.warpinator.core.utils.Utils +import slowscript.warpinator.core.utils.Utils.generateServiceName +import slowscript.warpinator.core.utils.Utils.isSameSubnet +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.net.Inet4Address +import java.net.InetAddress +import java.security.cert.CertificateException +import java.util.Collections +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import javax.jmdns.JmDNS +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceInfo +import javax.jmdns.ServiceListener +import javax.jmdns.impl.DNSIncoming +import javax.jmdns.impl.DNSOutgoing +import javax.jmdns.impl.DNSQuestion +import javax.jmdns.impl.DNSRecord +import javax.jmdns.impl.JmDNSImpl +import javax.jmdns.impl.ServiceInfoImpl +import javax.jmdns.impl.constants.DNSConstants +import javax.jmdns.impl.constants.DNSRecordClass +import javax.jmdns.impl.constants.DNSRecordType +import javax.jmdns.impl.tasks.resolver.ServiceResolver + +@Singleton +class Server @Inject constructor( + val certServer: CertServer, + val authenticator: Authenticator, + val repository: WarpinatorRepository, + val remotesManager: dagger.Lazy, + val transfersManager: dagger.Lazy, +) { + var displayName: String? = null + var port: Int = 0 + var authPort: Int = 0 + var uuid: String? = null + var profilePicture: String? = null + var networkInterface: String? = null + + var allowOverwrite: Boolean = false + + var notifyIncoming: Boolean = false + + + var downloadDirUri: String? = null + var running: Boolean = false + + var useCompression: Boolean = false + + var jmdns: JmDNS? = null + private var serviceInfo: ServiceInfo? = null + private val serviceListener: ServiceListener + private val preferenceChangeListener: OnSharedPreferenceChangeListener + private var gServer: Server? = null + private var regServer: Server? = null + private var apiVersion = 2 + + + init { + loadSettings() + + serviceListener = newServiceListener() + preferenceChangeListener = + OnSharedPreferenceChangeListener { _: SharedPreferences?, _: String? -> loadSettings() } + } + + fun start() { + Log.i(TAG, "--- Starting server") + running = true + repository.applicationScope.launch(Dispatchers.IO) { + startGrpcServer() + startRegistrationServer() + launch { certServer.start(port) } + startMDNS() + } + repository.prefs.prefs?.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + suspend fun stop() = withContext(Dispatchers.IO + NonCancellable) { + running = false + certServer.stop() + stopMDNS() + repository.prefs.prefs?.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + if (gServer != null) gServer!!.shutdownNow() + if (regServer != null) regServer!!.shutdownNow() + try { + gServer?.awaitTermination() + } catch (_: InterruptedException) { + } + //LocalBroadcasts.updateNetworkState(svc); + Log.i(TAG, "--- Server stopped") + } + + private suspend fun startMDNS() = withContext(Dispatchers.IO) { + try { + val address: InetAddress = repository.currentIPInfo!!.address + Log.d(TAG, "Starting mDNS on $address") + jmdns = JmDNS.create(address) + + registerService(false) + delay(500) + + //Start looking for others + jmdns!!.addServiceListener(SERVICE_TYPE, serviceListener) + } catch (e: Exception) { + running = false + Log.e(TAG, "Failed to init JmDNS", e) + repository.emitMessage(FailedToStartJmDNS()) + } + } + + private suspend fun stopMDNS() = withContext(Dispatchers.IO) { + if (jmdns != null) { + try { + jmdns!!.unregisterAllServices() + jmdns!!.removeServiceListener(SERVICE_TYPE, serviceListener) + jmdns!!.close() + } catch (e: Exception) { + Log.w(TAG, "Failed to close JmDNS", e) + } + } + } + + fun loadSettings() { + val prefs = repository.prefs + + if (!prefs.loadedPreferences) return + + if (prefs.serviceUuid == null) prefs.saveServiceUuid(generateServiceName(repository.appContext)) + else uuid = prefs.serviceUuid + displayName = prefs.displayName + port = prefs.port + authPort = prefs.authPort + networkInterface = prefs.networkInterface + authenticator.groupCode = prefs.groupCode + allowOverwrite = prefs.allowOverwrite + notifyIncoming = prefs.notifyIncoming + downloadDirUri = prefs.downloadDirUri + useCompression = prefs.useCompression + profilePicture = prefs.profilePicture + } + + fun startGrpcServer() { + try { + val cert = File(Utils.certsDir(repository.appContext), ".self.pem") + val key = File(Utils.certsDir(repository.appContext), ".self.key-pem") + val ssl = + GrpcSslContexts.forServer(cert, key).sslContextProvider(Conscrypt.newProvider()) + gServer = NettyServerBuilder.forPort(port).sslContext(ssl.build()).addService( + GrpcService( + repository, + this, + remotesManager.get(), + transfersManager.get(), + ), + ).permitKeepAliveWithoutCalls(true).permitKeepAliveTime(5, TimeUnit.SECONDS).build() + gServer!!.start() + Log.d(TAG, "GRPC server started") + } catch (e: Exception) { + running = false + if (e.cause is CertificateException) { + Log.e(TAG, "Failed to initialize SSL context", e) + repository.emitMessage(FailedToStartTLSError()) + return + } + Log.e(TAG, "Failed to start GRPC server.", e) + repository.emitMessage(FailedToStartGRPC()) + } + } + + fun startRegistrationServer() { + try { + regServer = NettyServerBuilder.forPort(authPort).addService( + RegistrationService( + repository = repository, + server = this, + remotesManager = remotesManager.get(), + authenticator = authenticator, + ), + ).build() + regServer!!.start() + Log.d(TAG, "Registration server started") + } catch (e: Exception) { + apiVersion = 1 + Log.w(TAG, "Failed to start V2 registration service.", e) + repository.emitMessage(FailedToStartV2()) + } + } + + suspend fun registerService(flush: Boolean) = withContext(Dispatchers.IO) { + serviceInfo = ServiceInfo.create(SERVICE_TYPE, uuid, port, "") + + val props: MutableMap = HashMap() + props["hostname"] = Utils.getDeviceName(repository.appContext) + val type = if (flush) "flush" else "real" + props["type"] = type + props["api-version"] = apiVersion.toString() + props["auth-port"] = authPort.toString() + serviceInfo!!.setText(props) + + // Unregister possibly leftover service info + // -> Announcement will trigger "new service" behavior and reconnect on other clients + unregister() //Safe if fails + delay(250) + try { + Log.d(TAG, "Registering as $uuid") + jmdns!!.registerService(serviceInfo) + } catch (e: IOException) { + Log.e(TAG, "Failed to register service.", e) + } + } + + val serviceRegistrationMsg: ServiceRegistration + get() = ServiceRegistration.newBuilder().setServiceId(uuid).setIp(repository.currentIPStr) + .setPort(port).setHostname(Utils.getDeviceName(repository.appContext)) + .setApiVersion(apiVersion).setAuthPort(authPort).build() + + suspend fun tryRegisterWithHost(host: String): ManualConnectionResult = + withContext(Dispatchers.IO) { + Log.d(TAG, "Registering with host $host") + var channel: ManagedChannel? = null + var flagNotOnSameSubnet = false + + try { + val sep = host.lastIndexOf(':') + // Use ip and authPort as specified by user + val ip = host.take(sep) + val authPort = host.substring(sep + 1).toInt() + val ia = InetAddress.getByName(ip) + flagNotOnSameSubnet = !isSameSubnet( + ia, repository.currentIPInfo!!.address, repository.currentIPInfo!!.prefixLength, + ) + channel = OkHttpChannelBuilder.forTarget(host).usePlaintext().build() + val resp = WarpRegistrationGrpc.newBlockingStub(channel) + .registerService(serviceRegistrationMsg) + Log.d(TAG, "registerWithHost: registration sent") + repository.prefs.addRecentRemote(host, resp.hostname) + + var r: Remote? = repository.remoteListState.value.find { it.uuid == resp.serviceId } + val newRemote = r == null + + if (r == null) { + r = Remote( + uuid = resp.serviceId, + address = null, + isFavorite = repository.prefs.favourites.contains( + SavedFavourite(resp.serviceId), + ), + ) + } else if (r.status == RemoteStatus.Connected) { + return@withContext ManualConnectionResult.AlreadyConnected + } + + val updatedRemote = r.copy( + address = InetAddresses.forString(ip), + authPort = authPort, + hostname = resp.hostname, + api = resp.apiVersion, + port = resp.port, + serviceName = resp.serviceId, + staticService = true, + ) + + var connected = false + + if (newRemote) { + connected = addRemote(updatedRemote) + } else { + if (updatedRemote.status == RemoteStatus.Disconnected || updatedRemote.status is RemoteStatus.Error) { + connected = remotesManager.get().getWorker(updatedRemote.uuid)?.connect( + updatedRemote.hostname, + updatedRemote.address, + updatedRemote.authPort, + updatedRemote.port, + updatedRemote.api, + ) ?: false + } else { + repository.updateRemote(updatedRemote.uuid) { updatedRemote } + } + } + + if (!connected) { + throw Exception("Connection failed") + } + + // Success! + return@withContext ManualConnectionResult.Success + } catch (e: Exception) { + if (e is StatusRuntimeException && e.status === Status.Code.UNIMPLEMENTED.toStatus()) { + Log.e(TAG, "Host $host does not support manual connect -- $e") + return@withContext ManualConnectionResult.RemoteDoesNotSupportManualConnect + } else if (flagNotOnSameSubnet) { + Log.e(TAG, "Failed to connect to $host", e) + return@withContext ManualConnectionResult.NotOnSameSubnet + } else { + Log.e(TAG, "Failed to connect to $host", e) + return@withContext ManualConnectionResult.Error(e.toString()) + } + } finally { + channel?.shutdown() + } + } + + fun reannounce() { + repository.applicationScope.launch(Dispatchers.IO) { + Log.d(TAG, "Reannouncing") + try { + var out = DNSOutgoing(DNSConstants.FLAGS_QR_RESPONSE or DNSConstants.FLAGS_AA) + for (answer in (jmdns as JmDNSImpl).localHost.answers( + DNSRecordClass.CLASS_ANY, true, DNSConstants.DNS_TTL, + )) { + out = dnsAddAnswer(out, null, answer) + } + for (answer in (serviceInfo as ServiceInfoImpl).answers( + DNSRecordClass.CLASS_ANY, + true, + DNSConstants.DNS_TTL, + (jmdns as JmDNSImpl).localHost, + )) { + out = dnsAddAnswer(out, null, answer) + } + (jmdns as JmDNSImpl).send(out) + } catch (e: Exception) { + Log.e(TAG, "Reannounce failed", e) + repository.emitMessage(FailedToReannounce(e)) + } + } + } + + fun rescan() { + Log.d(TAG, "Rescanning") + repository.applicationScope.launch(Dispatchers.IO) { + repository.setRefresh(true) + //Need a new one every time since it can only run three times + try { + val impl = jmdns as? JmDNSImpl + if (impl == null) { + Log.e(TAG, "JmDNS instance is null or invalid") + return@launch + } + val resolver = ServiceResolver(impl, SERVICE_TYPE) + val out = DNSOutgoing(DNSConstants.FLAGS_QR_QUERY).apply { + val question = DNSQuestion.newQuestion( + SERVICE_TYPE, DNSRecordType.TYPE_PTR, DNSRecordClass.CLASS_IN, false, + ) + resolver.addQuestion(this, question) + } + impl.send(out) + // A delay to prevent UI flickering + delay(1000) + } catch (e: Exception) { + Log.e(TAG, "Rescan failed", e) + repository.emitMessage(FailedToRescan(e)) + } finally { + repository.setRefresh(false) + } + } + } + + suspend fun unregister() = withContext(Dispatchers.IO) { + Log.d(TAG, "Unregistering") + try { + var out = DNSOutgoing(DNSConstants.FLAGS_QR_RESPONSE or DNSConstants.FLAGS_AA) + for (answer in (serviceInfo as ServiceInfoImpl).answers( + DNSRecordClass.CLASS_ANY, true, 0, (jmdns as JmDNSImpl).localHost, + )) { + out = dnsAddAnswer(out, null, answer) + } + (jmdns as JmDNSImpl).send(out) + } catch (e: Exception) { + Log.e(TAG, "Unregistering failed", e) + repository.emitMessage(FailedToUnregister(e)) + } + } + + + fun dnsAddAnswer(out: DNSOutgoing, `in`: DNSIncoming?, rec: DNSRecord?): DNSOutgoing { + var newOut = out + try { + newOut.addAnswer(`in`, rec) + } catch (_: IOException) { + val flags = newOut.flags + newOut.flags = flags or DNSConstants.FLAGS_TC + (jmdns as JmDNSImpl).send(newOut) + + newOut = DNSOutgoing(flags, newOut.isMulticast, newOut.maxUDPPayload) + newOut.addAnswer(`in`, rec) + } + return newOut + } + + suspend fun addRemote(remote: Remote): Boolean { + val worker = remotesManager.get().onRemoteDiscovered(remote) ?: return false + + return worker.connect( + remote.hostname, + remote.address, + remote.authPort, + remote.port, + remote.api + ) + } + + fun newServiceListener(): ServiceListener { + return object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + Log.d(TAG, "Service found: " + event.info) + } + + override fun serviceRemoved(event: ServiceEvent) { + val svcName = event.info.name + Log.v(TAG, "Service lost: $svcName") + repository.updateRemote(svcName) { + it.copy(serviceAvailable = false) + } + } + + override fun serviceResolved(event: ServiceEvent) { + val info = event.info + Log.d(TAG, "*** Service resolved: " + info.name) + Log.d(TAG, "Details: $info") + if (info.name == uuid) { + Log.v(TAG, "That's me. Ignoring.") + return + } + + //TODO: Same subnet check + + //Ignore flush registration + val props = Collections.list(info.propertyNames) + if (props.contains("type") && "flush" == info.getPropertyString("type")) { + Log.v(TAG, "Ignoring \"flush\" registration") + return + } + if (!props.contains("hostname")) { + Log.d( + TAG, + "Ignoring incomplete service info. (no hostname, might be resolved later)", + ) + return + } + + val svcName = info.name + val matchedRemote = repository.remoteListState.value.find { it.uuid == svcName } + if (matchedRemote != null) { + Log.d(TAG, "Service already known. Status: " + matchedRemote.status) + val newHostname = info.getPropertyString("hostname") + val newAuthPort = + if (props.contains("auth-port")) info.getPropertyString("auth-port") + .toInt() else matchedRemote.authPort + + val newAddress = getIPv4Address(info.inetAddresses) ?: matchedRemote.address + val newPort = info.port + + val newRemote = matchedRemote.copy( + hostname = newHostname, + authPort = newAuthPort, + address = newAddress, + port = newPort, + serviceAvailable = true, + ) + + if ((newRemote.status === RemoteStatus.Disconnected) || (newRemote.status is RemoteStatus.Error)) { + Log.d(TAG, "Reconnecting to " + matchedRemote.hostname) + repository.addOrUpdateRemote(newRemote) + + // Launch connection + repository.applicationScope.launch(Dispatchers.IO) { + remotesManager.get().onRemoteDiscovered(newRemote)?.connect(newRemote) + } + } else { + repository.addOrUpdateRemote(newRemote) + } + + return + } + + val newAddress = getIPv4Address(info.inetAddresses) ?: run { + Log.w( + TAG, + "Service resolved with no IPv4 address. Most implementations don't properly support IPv6.", + ) + return@serviceResolved + } + val newHostname = info.getPropertyString("hostname") + val newApiVersion = + if (props.contains("api-version")) info.getPropertyString("api-version") + .toInt() else 1 + val newAuthPort = + if (props.contains("auth-port")) info.getPropertyString("auth-port") + .toInt() else 0 + val newPort = info.port + + val remote = Remote( + uuid = svcName, + address = newAddress, + hostname = newHostname, + authPort = newAuthPort, + port = newPort, + api = newApiVersion, + serviceAvailable = true, + serviceName = svcName, + isFavorite = repository.prefs.favourites.contains(SavedFavourite(svcName)), + status = RemoteStatus.Disconnected, + ) + + repository.applicationScope.launch(Dispatchers.IO) { addRemote(remote) } + } + } + } + + + val profilePictureBytes: ByteString + get() { + val os = ByteArrayOutputStream() + val bmp = ProfilePicturePainter.getProfilePicture( + profilePicture!!, repository.appContext, false, + ) + bmp.compress(Bitmap.CompressFormat.PNG, 90, os) + return ByteString.copyFrom(os.toByteArray()) + } + + private fun getIPv4Address(addresses: Array): InetAddress? { + for (a in addresses) { + if (a is Inet4Address) return a + } + return null + } + + companion object { + private const val TAG = "SRV" + const val SERVICE_TYPE: String = "_warpinator._tcp.local." + const val NETWORK_INTERFACE_AUTO: String = "auto" + const val SERVER_FEATURES = Remote.RemoteFeatures.TEXT_MESSAGES + } +} diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToReannounce.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToReannounce.kt new file mode 100644 index 00000000..d6044b91 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToReannounce.kt @@ -0,0 +1,21 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToReannounce(val exception: Exception) : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource( + R.string.reannounce_failed_message, + exception.message ?: stringResource(R.string.unknown_error), + ), + duration = SnackbarDuration.Long, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToRescan.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToRescan.kt new file mode 100644 index 00000000..6cf45253 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToRescan.kt @@ -0,0 +1,21 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToRescan(val exception: Exception) : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource( + R.string.rescan_failed_message, + exception.message ?: stringResource(R.string.unknown_error), + ), + duration = SnackbarDuration.Long, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartGRPC.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartGRPC.kt new file mode 100644 index 00000000..7e14bfeb --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartGRPC.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToStartGRPC : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.failed_to_start_grpc_server_message), + duration = SnackbarDuration.Indefinite, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartJmDNS.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartJmDNS.kt new file mode 100644 index 00000000..f23cb37f --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartJmDNS.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToStartJmDNS : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.discovery_service_failed_message), + duration = SnackbarDuration.Indefinite, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartTLSError.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartTLSError.kt new file mode 100644 index 00000000..de00a971 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartTLSError.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToStartTLSError : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.a_security_error_occurred_message), + duration = SnackbarDuration.Indefinite, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartV2.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartV2.kt new file mode 100644 index 00000000..9d901f57 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToStartV2.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToStartV2 : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.running_in_legacy_mode_message), + duration = SnackbarDuration.Indefinite, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToUnregister.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToUnregister.kt new file mode 100644 index 00000000..09c805f4 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FailedToUnregister.kt @@ -0,0 +1,21 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToUnregister(val exception: Exception) : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource( + R.string.unregistering_failed_message, + exception.message ?: stringResource(R.string.unknown_error), + ), + duration = SnackbarDuration.Long, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/messages/FoundGroupCodeError.kt b/app/src/main/java/slowscript/warpinator/core/network/messages/FoundGroupCodeError.kt new file mode 100644 index 00000000..86098ebb --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/messages/FoundGroupCodeError.kt @@ -0,0 +1,20 @@ +package slowscript.warpinator.core.network.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FoundGroupCodeError : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.found_devices_using_different_group_codes_message), + duration = SnackbarDuration.Indefinite, + ) + } + + override val id = "FoundGroupCodeError" +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/worker/RemoteWorker.kt b/app/src/main/java/slowscript/warpinator/core/network/worker/RemoteWorker.kt new file mode 100644 index 00000000..a8e42e97 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/worker/RemoteWorker.kt @@ -0,0 +1,472 @@ +package slowscript.warpinator.core.network.worker + +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log +import io.grpc.ConnectivityState +import io.grpc.ManagedChannel +import io.grpc.StatusRuntimeException +import io.grpc.okhttp.OkHttpChannelBuilder +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import slowscript.warpinator.WarpGrpc +import slowscript.warpinator.WarpGrpcKt +import slowscript.warpinator.WarpProto +import slowscript.warpinator.WarpRegistrationGrpc +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.network.Authenticator +import slowscript.warpinator.core.network.CertServer +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.network.messages.FoundGroupCodeError +import slowscript.warpinator.core.notification.WarpinatorNotificationManager +import slowscript.warpinator.core.utils.Utils +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException + +class RemoteWorker( + private val uuid: String, + private var repository: WarpinatorRepository, + private var notificationManager: WarpinatorNotificationManager, + private var server: Server, + private var authenticator: Authenticator, +) { + + private var errorReceiveCert: Boolean = false + private var hasGroupCodeException: Boolean = false + private var channel: ManagedChannel? = null + private var coroutineStub: WarpGrpcKt.WarpCoroutineStub? = null + private var asyncStub: WarpGrpc.WarpStub? = null + private var blockingStub: WarpGrpc.WarpBlockingStub? = null + + /** Returns false if connection failed. Overload to take in a remote for easier parameter passing */ + suspend fun connect(remote: Remote) = + connect(remote.hostname, remote.address, remote.authPort, remote.port, remote.api) + + /** Returns false if connection failed */ + suspend fun connect( + hostname: String?, address: InetAddress?, authPort: Int, port: Int, api: Int, + ): Boolean = withContext( + Dispatchers.IO, + ) { + Log.i(TAG, "Connecting to $hostname, api $api") + + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.Connecting) + + if (!receiveCertificate( + api = api, port = port, hostname = hostname, address = address, authPort = authPort, + ) + ) { + if (hasGroupCodeException) { + repository.updateRemote( + uuid, + ) { + it.copy( + hasErrorGroupCode = true, + status = Remote.RemoteStatus.Error(hasGroupCodeException = true), + ) + } + repository.emitMessage(FoundGroupCodeError(), true) + } else { + repository.updateRemote( + uuid, + ) { + it.copy( + hasErrorGroupCode = false, + status = Remote.RemoteStatus.Error(isCertificateUnreceived = true), + ) + } + } + return@withContext false + } + + Log.d(TAG, "Certificate for $hostname received and saved") + + try { + val safeAddress = address ?: throw IllegalStateException("Address is null") + + val builder = OkHttpChannelBuilder.forAddress(safeAddress.hostAddress, port) + .sslSocketFactory(authenticator.createSSLSocketFactory(uuid)) + .flowControlWindow(1280 * 1024) + + if (api >= 2) { + builder.keepAliveWithoutCalls(true).keepAliveTime(11, TimeUnit.SECONDS) + .keepAliveTimeout(5, TimeUnit.SECONDS) + } + + channel?.takeIf { !it.isShutdown }?.shutdown() + + val newChannel = builder.build() + channel = newChannel + + if (api >= 2) { + newChannel.notifyWhenStateChanged(newChannel.getState(true)) { + this@RemoteWorker.onChannelStateChanged(hostname) + } + } + + coroutineStub = WarpGrpcKt.WarpCoroutineStub(newChannel) + blockingStub = WarpGrpc.newBlockingStub(newChannel) + asyncStub = WarpGrpc.newStub(newChannel) + + withTimeoutOrNull(5000) { + coroutineStub?.ping( + WarpProto.LookupName.newBuilder().setId(server.uuid).build(), + ) + } ?: throw Exception("Ping timeout") + } catch (c: CancellationException) { + throw c + } catch (e: SSLException) { + Log.e(TAG, "Authentication with remote $hostname failed: ${e.message}", e) + repository.updateRemoteStatus( + uuid, Remote.RemoteStatus.Error(e.localizedMessage ?: "", hasSslException = true), + ) + return@withContext false + } catch (e: Exception) { + Log.e(TAG, "Failed to connect to remote $hostname. ${e.message}", e) + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.Error(e.toString())) + return@withContext false + } finally { + // Clean up channel on failure + if (channel != null && (repository.getRemoteStatus(uuid) is Remote.RemoteStatus.Error)) { + channel?.shutdownNow() + } + } + + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.AwaitingDuplex) + + if (!waitForDuplex(api = api)) { + Log.e(TAG, "Couldn't establish duplex with $hostname") + repository.updateRemoteStatus( + uuid, Remote.RemoteStatus.Error(isDuplexFailed = true), + ) + channel?.shutdown() + return@withContext false + } + + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.Connected) + + try { + val info = + coroutineStub?.getRemoteMachineInfo(WarpProto.LookupName.getDefaultInstance()) + if (info != null) { + repository.updateRemote(uuid) { remote -> + remote.copy( + displayName = info.displayName, userName = info.userName, + supportsTextMessages = (info.featureFlags and Remote.RemoteFeatures.TEXT_MESSAGES) != 0, + ) + } + } + } catch (ex: StatusRuntimeException) { + repository.updateRemoteStatus( + uuid, + Remote.RemoteStatus.Error( + ex.localizedMessage ?: "", hasUsernameException = true, + ), + ) + Log.e(TAG, "connect: cannot get name: connection broken?", ex) + channel?.shutdown() + return@withContext false + } + + // Get avatar + try { + val flow = + coroutineStub?.getRemoteMachineAvatar(WarpProto.LookupName.getDefaultInstance()) + val bs = com.google.protobuf.ByteString.newOutput() + + flow?.collect { chunk -> + chunk.avatarChunk.writeTo(bs) + } + + val bytes = bs.toByteString().toByteArray() + if (bytes.isNotEmpty()) { + repository.updateRemotePicture( + uuid, BitmapFactory.decodeByteArray(bytes, 0, bytes.size), + ) + } + } catch (_: Exception) { + repository.updateRemotePicture(uuid, null) + } + + Log.i(TAG, "Connection established with $hostname") + + return@withContext true + } + + fun disconnect(hostname: String?) { + Log.i(TAG, "Disconnecting $hostname") + try { + channel?.shutdownNow() + } catch (_: Exception) { + } + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.Disconnected) + } + + private fun onChannelStateChanged(hostname: String?) { + val currentChannel = channel ?: return + val state = currentChannel.getState(false) + Log.d(TAG, "onChannelStateChanged: $hostname -> $state") + if (state == ConnectivityState.TRANSIENT_FAILURE || state == ConnectivityState.IDLE) { + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.Disconnected) + currentChannel.shutdown() //Dispose of channel so it can be recreated if device comes back + } + currentChannel.notifyWhenStateChanged(state) { this.onChannelStateChanged(hostname) } + } + + suspend fun ping() { + try { + coroutineStub?.ping( + WarpProto.LookupName.newBuilder().setId(server.uuid).build(), + ) + } catch (e: Exception) { + repository.updateRemoteStatus(uuid, Remote.RemoteStatus.Disconnected) + channel?.shutdown() + } + } + + + fun sameSubnetWarning(address: InetAddress?, status: Remote.RemoteStatus): Boolean { + if (status == Remote.RemoteStatus.Connected) return false + val ipInfo = repository.currentIPInfo ?: return false + val currentAddress = address ?: return false + + return !Utils.isSameSubnet(currentAddress, ipInfo.address, ipInfo.prefixLength) + } + + + fun startSendTransfer(t: Transfer) { + repository.addTransfer(uuid, t) + + val topDirBaseNames = t.topDirBaseNames + + val info = WarpProto.OpInfo.newBuilder().setIdent(server.uuid).setTimestamp(t.startTime) + .setReadableName(Utils.getDeviceName(repository.appContext)) + .setUseCompression(t.useCompression).build() + + val op = WarpProto.TransferOpRequest.newBuilder().setInfo(info).setSenderName("Android") + .setReceiver(uuid).setSize(t.totalSize).setCount(t.fileCount) + .setNameIfSingle(t.singleFileName).setMimeIfSingle(t.singleMimeType) + .addAllTopDirBasenames(topDirBaseNames).build() + + repository.applicationScope.launch { + try { + coroutineStub?.processTransferOpRequest(op) + } catch (e: Exception) { + Log.e(TAG, "Failed to send transfer request", e) + } + } + } + + suspend fun connectForReceive(tData: Transfer): Flow { + val info = WarpProto.OpInfo.newBuilder().setIdent(server.uuid).setTimestamp(tData.startTime) + .setReadableName(Utils.getDeviceName(repository.appContext)) + .setUseCompression(tData.useCompression).build() + + // This might throw, caller (TransferWorker) must handle exceptions + return coroutineStub!!.startTransfer(info) + } + + fun sendTextMessage(text: String) { + val message = WarpProto.TextMessage.newBuilder().setIdent(server.uuid) + .setTimestamp(System.currentTimeMillis()).setMessage(text).build() + + repository.addRemoteMessage( + uuid, + Message(uuid, Transfer.Direction.Send, System.currentTimeMillis(), text), + ) + + repository.applicationScope.launch { + try { + coroutineStub?.sendTextMessage(message) + } catch (e: Exception) { + Log.e(TAG, "Failed to send message", e) + } + } + } + + fun onReceiveMessage(message: Message) { + repository.addRemoteMessage(uuid, message, true) + repository.applicationScope.launch { + val remote = repository.getRemoteFlow(uuid).firstOrNull() ?: return@launch + notificationManager.showMessage( + remoteName = remote.displayName, + remoteUuid = uuid, + remoteBitmap = remote.picture, + message = message.text, + messageTimestamp = message.timestamp, + ) + } + } + + + fun declineTransfer(t: Transfer) { + val info = WarpProto.OpInfo.newBuilder().setIdent(server.uuid).setTimestamp(t.startTime) + .setReadableName(Utils.getDeviceName(repository.appContext)).build() + asyncStub?.cancelTransferOpRequest(info, Utils.VoidObserver()) + } + + fun stopTransfer(t: Transfer, error: Boolean) { + val i = WarpProto.OpInfo.newBuilder().setIdent(server.uuid).setTimestamp(t.startTime) + .setReadableName(Utils.getDeviceName(repository.appContext)).build() + val info = WarpProto.StopInfo.newBuilder().setError(error).setInfo(i).build() + asyncStub?.stopTransfer(info, Utils.VoidObserver()) + } + + // Private helpers + private fun receiveCertificate( + hostname: String?, address: InetAddress?, authPort: Int, port: Int, api: Int, + ): Boolean { + hasGroupCodeException = false + if (api == 2) { + if (receiveCertificateV2( + hostname = hostname, address = address, authPort = authPort, + ) + ) return true + else if (hasGroupCodeException) return false + else Log.d(TAG, "Falling back to receiveCertificateV1") + } + return receiveCertificateV1( + port = port, hostname = hostname, address = address, + ) + } + + private fun receiveCertificateV1(hostname: String?, address: InetAddress?, port: Int): Boolean { + var received: ByteArray? = null + var tryCount = 0 + + loop@ while (tryCount < 3) { + try { + DatagramSocket().use { sock -> + Log.v(TAG, "Receiving certificate from $address, try $tryCount") + sock.soTimeout = 1500 + + val req = CertServer.REQUEST.toByteArray() + val p = DatagramPacket(req, req.size, address, port) + sock.send(p) + + val receiveData = ByteArray(2000) + val receivePacket = DatagramPacket(receiveData, receiveData.size) + sock.receive(receivePacket) + + if (receivePacket.address == address && receivePacket.port == port) { + received = receivePacket.data.copyOfRange(0, receivePacket.length) + break@loop + } + } + } catch (e: Exception) { + tryCount++ + Log.d(TAG, "receiveCertificate: attempt $tryCount failed: ${e.message}") + } + } + + if (tryCount == 3) { + Log.e(TAG, "Failed to receive certificate from $hostname") + errorReceiveCert = true + return false + } + + val safeReceived = received ?: return false + val decoded = Base64.decode(safeReceived, Base64.DEFAULT) + hasGroupCodeException = !authenticator.saveBoxedCert(decoded, uuid) + + if (hasGroupCodeException) return false + errorReceiveCert = false + return true + } + + private fun receiveCertificateV2( + hostname: String?, address: InetAddress?, authPort: Int, + ): Boolean { + Log.v(TAG, "Receiving certificate (V2) from $hostname at $address") + var authChannel: ManagedChannel? = null + try { + val safeAddress = address ?: return false + + authChannel = + OkHttpChannelBuilder.forAddress(safeAddress.hostAddress, authPort).usePlaintext() + .build() + + val resp = WarpRegistrationGrpc.newBlockingStub(authChannel).withWaitForReady() + .withDeadlineAfter(8, TimeUnit.SECONDS).requestCertificate( + WarpProto.RegRequest.newBuilder() + .setHostname(Utils.getDeviceName(repository.appContext)) + .setIp(repository.currentIPStr ?: "").build(), + ) + + val lockedCert = resp.lockedCertBytes.toByteArray() + val decoded = Base64.decode(lockedCert, Base64.DEFAULT) + + hasGroupCodeException = !authenticator.saveBoxedCert(decoded, uuid) + if (hasGroupCodeException) return false + + errorReceiveCert = false + return true + + } catch (e: Exception) { + Log.w(TAG, "Could not receive certificate from $hostname", e) + errorReceiveCert = true + } finally { + authChannel?.shutdownNow() + } + return false + } + + private suspend fun waitForDuplex(api: Int): Boolean { + return if (api == 2) waitForDuplexV2() else waitForDuplexV1() + } + + private suspend fun waitForDuplexV1(): Boolean { + Log.d(TAG, "Waiting for duplex - V1") + var tries = 0 + while (tries < 10) { + try { + val haveDuplex = coroutineStub?.checkDuplexConnection( + WarpProto.LookupName.newBuilder().setId(server.uuid).setReadableName("Android") + .build(), + )?.response ?: false + + if (haveDuplex) return true + + } catch (e: Exception) { + Log.d(TAG, "Error while checking duplex (Attempt $tries)", e) + } + + Log.d(TAG, "Attempt $tries: No duplex") + + delay(3000) + tries++ + } + return false + } + + private suspend fun waitForDuplexV2(): Boolean { + Log.d(TAG, "Waiting for duplex - V2") + return try { + withTimeoutOrNull(10000) { + coroutineStub?.waitingForDuplex( + WarpProto.LookupName.newBuilder().setId(server.uuid) + .setReadableName(Utils.getDeviceName(repository.appContext)).build(), + ) + }?.response ?: false + } catch (e: Exception) { + Log.d(TAG, "Error while waiting for duplex", e) + false + } + } + + companion object { + const val TAG = "Remote" + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/network/worker/TransferWorker.kt b/app/src/main/java/slowscript/warpinator/core/network/worker/TransferWorker.kt new file mode 100644 index 00000000..5bcb4392 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/network/worker/TransferWorker.kt @@ -0,0 +1,840 @@ +package slowscript.warpinator.core.network.worker + +import android.content.Intent +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.util.Log +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.google.common.io.Files +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import slowscript.warpinator.BuildConfig +import slowscript.warpinator.WarpProto +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.model.Transfer.Direction +import slowscript.warpinator.core.model.Transfer.Error +import slowscript.warpinator.core.model.Transfer.FileType +import slowscript.warpinator.core.model.Transfer.Status +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.notification.WarpinatorNotificationManager +import slowscript.warpinator.core.service.RemotesManager +import slowscript.warpinator.core.system.WarpinatorPowerManager +import slowscript.warpinator.core.utils.Utils +import slowscript.warpinator.core.utils.ZlibCompressor +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URLConnection +import java.util.concurrent.atomic.AtomicReference +import java.util.zip.Deflater.DEFAULT_COMPRESSION +import kotlin.math.max + +class TransferWorker( + initialTransfer: Transfer, + private val repository: WarpinatorRepository, + private val server: Server, + private val remotesManager: RemotesManager, + private val powerManager: WarpinatorPowerManager, + private val notificationManager: WarpinatorNotificationManager, +) { + + // We keep a local mutable copy for logic, but expose updates via Repository + private var transferData: Transfer = initialTransfer + private val status = AtomicReference(initialTransfer.status) + + // Internal lists for logic + private val files = ArrayList() + private val dirs = ArrayList() + private val topDirBaseNames = ArrayList() + private val receivedPaths = ArrayList() + private val errorList = ArrayList() + + // State variables + private var currentRelativePath: String? = null + private var currentLastMod: Long = -1 + private var currentUri: Uri? = null + private var currentFile: File? = null + private var currentStream: OutputStream? = null + private var cancelled = false + private var safeOverwriteFlag = false + + // Progress + private val transferSpeed = TransferSpeed(24) + private var actualStartTime: Long = 0 + private var lastBytes: Long = 0 + private var lastMillis: Long = 0 + private var lastUiUpdate: Long = 0 + + fun stop(error: Boolean) { + Log.i(TAG, "Transfer stopped") + remotesManager.getWorker(transferData.remoteUuid)?.stopTransfer(transferData, error) + onStopped(error) + } + + fun onStopped(error: Boolean) { + Log.v(TAG, "Stopping transfer") + if (!error) setStatus(Status.Stopped) + if (transferData.direction == Direction.Receive) stopReceiving() + else stopSending() + updateRepo() + } + + fun makeDeclined() { + setStatus(Status.Declined) + updateRepo() + } + + private fun updateRepo() { + val now = System.currentTimeMillis() + val currentStatus = getStatus() + + // Rate limiting updates + if (currentStatus == Status.Transferring && (now - lastUiUpdate) < UI_UPDATE_LIMIT) return + + if (transferData.direction == Direction.Send) { + val bps = ((transferData.bytesTransferred - lastBytes) / ((max( + 1, now - lastUiUpdate, + )) / 1000f)).toLong() + transferSpeed.add(bps) + transferData = transferData.copy( + bytesPerSecond = transferSpeed.getMovingAverage(), + ) + lastBytes = transferData.bytesTransferred + } + + lastUiUpdate = now + + transferData = transferData.copy(status = currentStatus) + + repository.updateTransfer(transferData.remoteUuid, transferData) + } + + private fun setStatus(s: Status) { + status.set(s) + transferData = transferData.copy(status = s) + } + + private fun getStatus(): Status { + return status.get() + } + + suspend fun prepareSend(isDir: Boolean): Transfer = withContext(Dispatchers.IO) { + val calculatedTopDirNames = ArrayList() + files.clear() + dirs.clear() + + for (u in transferData.uris) { + val name = Utils.getNameFromUri(repository.appContext, u) ?: "unknown" + calculatedTopDirNames.add(name) + + if (isDir) { + val docId = DocumentsContract.getTreeDocumentId(u) + val topdir = MFile().apply { + this.name = name + this.relPath = name + this.isDirectory = true + } + dirs.add(topdir) + resolveTreeUri(u, docId, name) + } else { + files.addAll(resolveUri(u)) + } + } + + val fileCount = (files.size + dirs.size).toLong() + var singleName = "" + var singleMime = "" + + if (fileCount == 1L) { + singleName = calculatedTopDirNames.getOrElse(0) { "" } + singleMime = repository.appContext.contentResolver.getType(transferData.uris[0]) ?: "" + } + + setStatus(Status.WaitingPermission) + + transferData = transferData.copy( + fileCount = fileCount, + singleFileName = singleName, + singleMimeType = singleMime, + totalSize = getTotalSendSize(), + topDirBaseNames = calculatedTopDirNames, + ) + + updateRepo() + return@withContext transferData + } + + // Gets all children of a document and adds them to files and dirs + private fun resolveTreeUri(rootUri: Uri, docId: String, parent: String) { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, docId) + val items = resolveUri(childrenUri) + for (f in items) { + if (f.documentID == null) break + f.uri = DocumentsContract.buildDocumentUriUsingTree(rootUri, f.documentID) + f.relPath = parent + "/" + f.name + if (f.isDirectory) { + dirs.add(f) + resolveTreeUri(rootUri, f.documentID!!, f.relPath!!) + } else { + files.add(f) + } + } + } + + // Get info about all documents represented by uri + private fun resolveUri(u: Uri): ArrayList { + val mfs = ArrayList() + try { + repository.appContext.contentResolver.query(u, null, null, null, null).use { c -> + if (c == null) { + Log.w(TAG, "Could not resolve uri: $u") + return mfs + } + val idCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) + val mTimeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + val sizeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE) + + while (c.moveToNext()) { + val f = MFile() + if (idCol != -1) f.documentID = c.getString(idCol) + else Log.w(TAG, "Could not get document ID") + + f.name = c.getString(nameCol) + + if (mimeCol != -1) f.mime = c.getString(mimeCol) + + if (mimeCol == -1 || f.mime == null) { + Log.w(TAG, "Could not get MIME type") + f.mime = "application/octet-stream" + } + + if (mTimeCol != -1) f.lastMod = c.getLong(mTimeCol) + else f.lastMod = -1 + + f.length = c.getLong(sizeCol) + f.isDirectory = f.mime!!.endsWith("directory") + f.uri = u + f.relPath = f.name + mfs.add(f) + } + } + } catch (e: Exception) { + Log.e(TAG, "Could not query resolver: ", e) + } + return mfs + } + + fun generateFileFlow(): Flow = flow { + setStatus(Status.Transferring) + Log.d(TAG, "Sending Flow started. Compression: ${transferData.useCompression}") + + // Reset counters + transferData = transferData.copy(bytesTransferred = 0) + lastBytes = 0 + updateRepo() + + powerManager.acquire() + + var i = 0 + var iDir = 0 + var inputStream: InputStream? = null + val chunkBuffer = ByteArray(CHUNK_SIZE) + var firstChunkOfFile = true + + try { + // Send Directories + while (iDir < dirs.size && currentCoroutineContext().isActive) { + val dir = dirs[iDir] + val fc = WarpProto.FileChunk.newBuilder().setRelativePath(dir.relPath) + .setFileType(FileType.Directory.value).setFileMode(0x1ED) // 0755 + .build() + emit(fc) + iDir++ + } + + // Send Files + while (i < files.size && currentCoroutineContext().isActive) { + if (inputStream == null) { + inputStream = + repository.appContext.contentResolver.openInputStream(files[i].uri!!) + firstChunkOfFile = true + } + + val read = inputStream!!.read(chunkBuffer) + + // End of current file + if (read < 1) { + inputStream.close() + inputStream = null + i++ + continue + } + + // Prepare metadata for first chunk + var fileTime = WarpProto.FileTime.getDefaultInstance() + if (firstChunkOfFile) { + firstChunkOfFile = false + val lastMod = files[i].lastMod + if (lastMod > 0) { + fileTime = WarpProto.FileTime.newBuilder().setMtime(lastMod / 1000) + .setMtimeUsec((lastMod % 1000).toInt() * 1000).build() + } + } + + // Compress if needed + var dataToSend = chunkBuffer + var dataLen = read + if (transferData.useCompression) { + dataToSend = ZlibCompressor.compress(chunkBuffer, read, DEFAULT_COMPRESSION) + dataLen = dataToSend.size + } + + // Emit Chunk + val fc = WarpProto.FileChunk.newBuilder().setRelativePath(files[i].relPath) + .setFileType(FileType.File.value) + .setChunk(ByteString.copyFrom(dataToSend, 0, dataLen)) + .setFileMode(0x1A4) // 0644 + .setTime(fileTime).build() + + emit(fc) + + // Update Progress + transferData = + transferData.copy(bytesTransferred = transferData.bytesTransferred + read) + updateRepo() + } + + setStatus(Status.Finished) + unpersistUris() + updateRepo() + + } catch (e: FileNotFoundException) { + Log.e(TAG, "File not found during send", e) + val err = Error.FileNotFound(e.message ?: "Unknown file") + setStatus(Status.Failed(err, false)) + updateRepo() + // Re-throw to cancel gRPC stream + throw e + } catch (e: Exception) { + Log.e(TAG, "Error sending files", e) + val err = Error.Generic(e.message ?: "Unknown Error") + setStatus(Status.Failed(err, false)) + updateRepo() + throw e + } finally { + inputStream?.close() + powerManager.release() + } + }.flowOn(Dispatchers.IO) + + suspend fun processFileFlow(chunkFlow: Flow) = + withContext(Dispatchers.IO) { + Log.i(TAG, "Starting Receive Flow. Compression: ${transferData.useCompression}") + setStatus(Status.Transferring) + receivedPaths.clear() + updateRepo() + + powerManager.acquire() + + try { + chunkFlow.collect { chunk -> + if (!processSingleChunk(chunk)) { + throw Exception("Processing failed or cancelled") + } + } + + finishReceive() + + } catch (e: Exception) { + Log.e(TAG, "Receive flow error", e) + if (getStatus() == Status.Transferring) { + failReceive(Error.ConnectionLost(e.message)) + } + } finally { + powerManager.release() + } + } + + private fun processSingleChunk(chunk: WarpProto.FileChunk): Boolean { + var chunkSize: Long = 0 + + if (chunk.relativePath != currentRelativePath) { + closeStream() + if (currentLastMod != -1L) { + setLastModified() + currentLastMod = -1 + } + + finishSafeOverwrite() + + currentRelativePath = chunk.relativePath + if (server.downloadDirUri.isNullOrEmpty()) { + failReceive(Error.DownloadDirectoryNotSet) + return false + } + + val sanitizedName = currentRelativePath!!.replace("[\\\\<>*|?:\"]".toRegex(), "_") + + when (chunk.fileType) { + FileType.Directory.value -> { + createDirectory(sanitizedName) + } + + FileType.Symlink.value -> { + errorList.add("Symlinks not supported: $sanitizedName") + } + + else -> { + // New File + if (chunk.hasTime()) { + val ft = chunk.time + currentLastMod = ft.mtime * 1000 + ft.mtimeUsec / 1000 + } + try { + currentStream = openFileStream(sanitizedName) + var data = chunk.chunk.toByteArray() + if (transferData.useCompression) { + data = ZlibCompressor.decompress(data) + } + currentStream!!.write(data) + chunkSize = data.size.toLong() + } catch (e: Exception) { + Log.e(TAG, "Failed to open file: $currentRelativePath", e) + failReceive(Error.PermissionDenied(currentRelativePath)) + return false + } + } + } + } else { + try { + var data = chunk.chunk.toByteArray() + if (transferData.useCompression) { + data = ZlibCompressor.decompress(data) + } + currentStream!!.write(data) + chunkSize = data.size.toLong() + } catch (e: Exception) { + Log.e(TAG, "Write error: $currentRelativePath", e) + failReceive(Error.Generic("Write error: ${e.message}")) + return false + } + } + + transferData = + transferData.copy(bytesTransferred = transferData.bytesTransferred + chunkSize) + + val now = System.currentTimeMillis() + val bps = (chunkSize / (max(1, now - lastMillis) / 1000f)).toLong() + transferSpeed.add(bps) + transferData = transferData.copy(bytesPerSecond = transferSpeed.getMovingAverage()) + lastMillis = now + + updateRepo() + + return getStatus() == Status.Transferring + } + + private fun stopSending() { + cancelled = true + } + + private fun unpersistUris() { + val parsedUris = transferData.uris + for (u in parsedUris) { + repository.appContext.contentResolver.releasePersistableUriPermission( + u, Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + } + + private fun getTotalSendSize(): Long { + var size: Long = 0 + for (f in files) { + size += f.length + } + return size + } + + fun prepareReceive() { + if (BuildConfig.DEBUG && transferData.direction != Direction.Receive) { + throw AssertionError("Assertion failed") + } + if (server.allowOverwrite) { + for (file in transferData.topDirBaseNames) { + if (checkWillOverwrite(file)) { + transferData = transferData.copy(overwriteWarning = true) + updateRepo() + break + } + } + } + + val autoAccept = repository.prefs.autoAccept + + if (repository.prefs.notifyIncoming && !autoAccept) { + val remoteName = + repository.remoteListState.value.find { it.uuid == transferData.remoteUuid }?.displayName + + notificationManager.showIncomingTransfer( + remoteName = remoteName, + remoteUuid = transferData.remoteUuid, + transferUuid = transferData.uid, + fileCount = transferData.fileCount, + singleFileName = transferData.singleFileName, + ) + } + + if (autoAccept) this.startReceive() + } + + fun startReceive() { + Log.i(TAG, "Transfer accepted, compression ${transferData.useCompression}") + setStatus(Status.Transferring) + actualStartTime = System.currentTimeMillis() + receivedPaths.clear() + updateRepo() + + repository.applicationScope.launch(Dispatchers.IO) { + val remoteWorker = remotesManager.getWorker(transferData.remoteUuid) + + if (remoteWorker == null) { + Log.e(TAG, "Remote worker not found for ${transferData.remoteUuid}") + failReceive(Error.ConnectionLost("Remote disconnected")) + return@launch + } + + try { + val chunkFlow = remoteWorker.connectForReceive(transferData) + + processFileFlow(chunkFlow) + } catch (e: Exception) { + Log.e(TAG, "Failed to start receive stream", e) + failReceive(Error.ConnectionLost(e.message)) + } + } + } + + fun declineTransfer() { + Log.i(TAG, "Transfer declined") + val worker = remotesManager.getWorker(transferData.remoteUuid) + if (worker != null) worker.declineTransfer(transferData) + else Log.w(TAG, "Transfer was from an unknown remote") + makeDeclined() + } + + fun finishReceive() { + Log.d(TAG, "Finalizing transfer") + if (errorList.isNotEmpty()) setStatus( + Status.FinishedWithErrors( + errorList.map { + Error.Generic( + it, + ) + }, + ), + ) + else setStatus(Status.Finished) + + closeStream() + + if (currentLastMod > 0) setLastModified() + + finishSafeOverwrite() + + if (repository.prefs.downloadDirUri?.startsWith("content:") == false) MediaScannerConnection.scanFile( + repository.appContext, receivedPaths.toTypedArray(), null, null, + ) + updateRepo() + } + + private fun stopReceiving() { + Log.v(TAG, "Stopping receiving") + closeStream() + //Delete incomplete file + try { + if (repository.prefs.downloadDirUri!!.startsWith("content:")) { + val f = DocumentFile.fromSingleUri(repository.appContext, currentUri!!) + f?.delete() + } else { + currentFile!!.delete() + } + } catch (e: Exception) { + Log.w(TAG, "Could not delete incomplete file", e) + } + } + + private fun failReceive(specificError: Error? = null) { + //Don't overwrite other reason for stopping + if (getStatus() == Status.Transferring) { + Log.v(TAG, "Receiving failed") + setStatus(Status.Failed(specificError ?: Error.Generic("Unknown"), false)) + stop(true) //Calls stopReceiving for us + } + } + + private fun closeStream() { + if (currentStream != null) { + try { + currentStream!!.close() + currentStream = null + } catch (_: Exception) { + } + } + } + + private fun setLastModified() { + //This is apparently not possible with SAF + if (repository.prefs.downloadDirUri?.startsWith("content:") == false) { + Log.d(TAG, "Setting lastMod: $currentLastMod") + currentFile!!.setLastModified(currentLastMod) + } + } + + private fun checkWillOverwrite(relPath: String): Boolean { + if (repository.prefs.downloadDirUri!!.startsWith("content:")) { + val treeRoot = repository.prefs.downloadDirUri!!.toUri() + return Utils.pathExistsInTree(repository.appContext, treeRoot, relPath) + } else { + return File(repository.prefs.downloadDirUri, relPath).exists() + } + } + + private fun handleFileExists(f: File): File { + var file = f + Log.d(TAG, "File exists: " + file.absolutePath) + if (repository.prefs.allowOverwrite) { + file = File(file.parentFile, file.name + TMP_FILE_SUFFIX) + Log.v(TAG, "Writing to temp file $file") + safeOverwriteFlag = true + } else { + val name = file.parent!! + "/" + Files.getNameWithoutExtension(file.absolutePath) + val ext = Files.getFileExtension(file.absolutePath) + var i = 1 + while (file.exists()) file = File("$name(${i++}).$ext") + Log.d(TAG, "Renamed to " + file.absolutePath) + } + return file + } + + private fun handleUriExists(path: String): String { + var p = path + val root = repository.prefs.downloadDirUri!!.toUri() + val f = Utils.getChildFromTree(repository.appContext, root, p) + Log.d(TAG, "File exists: " + f.uri) + if (repository.prefs.allowOverwrite) { + Log.v(TAG, "Overwriting") + f.delete() + } else { + val dir = p.take(p.lastIndexOf("/") + 1) + val fileName = p.substring(p.lastIndexOf("/") + 1) + + var name = fileName + var ext = "" + if (fileName.contains(".")) { + name = fileName.substringBefore(".") + ext = fileName.substringAfter(".") + } + var i = 1 + while (Utils.pathExistsInTree(repository.appContext, root, p)) { + p = "$dir$name($i)$ext" + i++ + } + Log.d(TAG, "Renamed to $p") + } + return p + } + + private fun createDirectory(path: String) { + if (repository.prefs.downloadDirUri!!.startsWith("content:")) { + val rootUri = repository.prefs.downloadDirUri!!.toUri() + val root = DocumentFile.fromTreeUri(repository.appContext, rootUri) + createDirectories(root, path, null) // Note: .. segment is created as (invalid) + } else { + val dir = File(repository.prefs.downloadDirUri, path) + if (!validateFile(dir)) throw IllegalArgumentException("The dir path leads outside download dir") + if (!dir.mkdirs()) { + errorList.add("Failed to create directory $path") + Log.e(TAG, "Failed to create directory $path") + } + } + } + + private fun createDirectories(parent: DocumentFile?, path: String, done: String?) { + var dir = path + var rest: String? = null + if (path.contains("/")) { + dir = path.substringBefore("/") + rest = path.substring(path.indexOf("/") + 1) + } + val absDir = + if (done == null) dir else "$done/$dir" //Path from rootUri - just to check existence + var newDir = DocumentFile.fromTreeUri( + repository.appContext, + Utils.getChildUri(server.downloadDirUri!!.toUri(), absDir), + ) + if (newDir?.exists() == false) { + newDir = parent!!.createDirectory(dir) + if (newDir == null) { + errorList.add("Failed to create directory $absDir") + Log.e(TAG, "Failed to create directory $absDir") + return + } + } + if (rest != null) createDirectories(newDir, rest, absDir) + } + + @Throws(FileNotFoundException::class) + private fun openFileStream(fileName: String): OutputStream { + var fName = fileName + if (server.downloadDirUri!!.startsWith("content:")) { + val rootUri = repository.prefs.downloadDirUri!!.toUri() + val root = DocumentFile.fromTreeUri(repository.appContext, rootUri) + if (Utils.pathExistsInTree(repository.appContext, rootUri, fName)) { + fName = handleUriExists(fName) + } + //Get parent - createFile will substitute / with _ and checks if parent is descendant of tree root + var parent = root + if (fName.contains("/")) { + val parentRelPath = fName.take(fName.lastIndexOf("/")) + fName = fName.substring(fName.lastIndexOf("/") + 1) + val dirUri = Utils.getChildUri(rootUri, parentRelPath) + parent = DocumentFile.fromTreeUri(repository.appContext, dirUri) + } + //Create file + val mime = guessMimeType(fName) + val file = parent!!.createFile(mime, fName) + currentUri = file!!.uri + return repository.appContext.contentResolver.openOutputStream(currentUri!!)!! + } else { + currentFile = File(server.downloadDirUri, fName) + if (currentFile!!.exists()) { + currentFile = handleFileExists(currentFile!!) + } + if (!validateFile(currentFile!!)) throw IllegalArgumentException("The file name leads to a file outside download dir") + + + if (!safeOverwriteFlag) { + receivedPaths.add(currentFile!!.absolutePath) + } else { + // If it is a temp file, we want to scan the final name later, not the .warpinatortmp + val realPath = currentFile!!.absolutePath.removeSuffix(TMP_FILE_SUFFIX) + receivedPaths.add(realPath) + } + + + return FileOutputStream(currentFile, false) + } + } + + private fun finishSafeOverwrite() { + if (safeOverwriteFlag) { + safeOverwriteFlag = false + val tempPath = currentFile?.path ?: return + + // Sanity check + if (!tempPath.endsWith(TMP_FILE_SUFFIX)) return + + val targetPath = tempPath.dropLast(TMP_FILE_SUFFIX.length) + Log.d(TAG, "Renaming tempfile to $targetPath") + + try { + val targetFile = File(targetPath) + val tempFileObj = File(tempPath) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + java.nio.file.Files.move( + tempFileObj.toPath(), + java.nio.file.Paths.get(targetPath), + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + ) + } else { + // Fallback for older Android versions + // Delete target if it exists + if (targetFile.exists()) targetFile.delete() + + if (!tempFileObj.renameTo(targetFile)) { + throw IOException("Could not rename temp file to target name") + } + } + } catch (e: IOException) { + Log.e(TAG, "Could not replace target file with temp file", e) + errorList.add("Failed to overwrite $currentRelativePath") + } + } + } + + private fun validateFile(f: File): Boolean { + var res = false + try { + res = (f.canonicalPath + "/").startsWith(server.downloadDirUri!!) + } catch (e: Exception) { + Log.w( + TAG, "Could not resolve canonical path for " + f.absolutePath + ": " + e.message, + ) + } + return res + } + + private fun guessMimeType(name: String): String { + //We don't care about knowing the EXACT mime type + //This is only to prevent fail on some devices that reject empty mime type + var mime = URLConnection.guessContentTypeFromName(name) + if (mime == null) mime = "application/octet-stream" + return mime + } + + class MFile { + var documentID: String? = null + var name: String? = null + var mime: String? = null + var relPath: String? = null + var uri: Uri? = null + var length: Long = 0 + var lastMod: Long = 0 + var isDirectory: Boolean = false + } + + class TransferSpeed(private val historyLength: Int) { + private val history: LongArray = LongArray(historyLength) + private var idx = 0 + private var count = 0 + + fun add(bps: Long) { + history[idx] = bps + idx = (idx + 1) % historyLength + if (count < historyLength) count++ + } + + fun getMovingAverage(): Long { + if (count == 0) return 0 + else if (count == 1) return history[0] + + var sum: Long = 0 + for (i in 0 until count) sum += history[i] + return sum / count + } + } + + companion object { + private const val TAG = "TRANSFER" + private const val CHUNK_SIZE = 1024 * 512 //512 kB + private const val UI_UPDATE_LIMIT: Long = 250 + private const val TMP_FILE_SUFFIX = ".warpinatortmp" + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/notification/NotificationManager.kt b/app/src/main/java/slowscript/warpinator/core/notification/NotificationManager.kt new file mode 100644 index 00000000..f32fec6d --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/notification/NotificationManager.kt @@ -0,0 +1,321 @@ +package slowscript.warpinator.core.notification + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.media.RingtoneManager +import android.os.Build +import android.os.Bundle +import android.text.format.Formatter +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import slowscript.warpinator.R +import slowscript.warpinator.app.MainActivity +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.service.MainService +import slowscript.warpinator.core.service.StopSvcReceiver +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WarpinatorNotificationManager @Inject constructor( + @param:ApplicationContext private val context: Context, +) { + + private val notificationMgr: NotificationManagerCompat = NotificationManagerCompat.from(context) + private var progressBuilder: NotificationCompat.Builder? = null + + var ignoredRemoteTransferUuid: String? = null + set(value) { + field = value + if (value != null) { + notificationMgr.activeNotifications.forEach { statusBarNotification -> + val notification = statusBarNotification.notification + + if (notification.extras.getString("remote") == value && !notification.extras.getBoolean( + "message", + false, + ) + ) { + notificationMgr.cancel(statusBarNotification.tag, statusBarNotification.id) + } + } + } + } + var ignoredRemoteMessageUuid: String? = null + set(value) { + field = value + if (value != null) { + notificationMgr.activeNotifications.forEach { statusBarNotification -> + val notification = statusBarNotification.notification + + if (notification.extras.getString("remote") == value && notification.extras.getBoolean( + "message", + false, + ) + ) { + notificationMgr.cancel(statusBarNotification.tag, statusBarNotification.id) + } + } + } + } + + init { + createNotificationChannels() + } + + fun createForegroundNotification(): Notification { + val openIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val immutable = PendingIntent.FLAG_IMMUTABLE + val pendingIntent = PendingIntent.getActivity(context, 0, openIntent, immutable) + + val stopIntent = Intent(context, StopSvcReceiver::class.java).apply { + action = MainService.ACTION_STOP + } + val stopPendingIntent = PendingIntent.getBroadcast(context, 0, stopIntent, immutable) + + return NotificationCompat.Builder(context, CHANNEL_SERVICE) + .setContentTitle(context.getString(R.string.warpinator_notification_title)) + .setContentText(context.getString(R.string.warpinator_notification_subtitle)) + .setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).addAction( + 0, + context.getString(R.string.warpinator_notification_button), + stopPendingIntent, + ).setPriority(NotificationCompat.PRIORITY_LOW).setShowWhen(false).setOngoing(true) + .build() + } + + @SuppressLint("MissingPermission") + fun showIncomingTransfer( + remoteName: String?, + remoteUuid: String, + fileCount: Long, + singleFileName: String?, + transferUuid: String, + ) { + if (!hasPermission() || ignoredRemoteTransferUuid == remoteUuid) return + + val intent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("remote", remoteUuid) + } + + val notificationId = remoteUuid.hashCode() xor transferUuid.hashCode() + + val pendingIntent = PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_IMMUTABLE, + ) + + val contentText = if (fileCount == 1L) singleFileName + else context.resources.getQuantityString( + R.plurals.notification_file_count, + fileCount.toInt(), fileCount, + ) + + val notification = + NotificationCompat.Builder(context, CHANNEL_INCOMING_TRANSFERS).setContentTitle( + context.getString( + R.string.incoming_transfer_notification, + remoteName ?: context.getString( + R.string.unknown, + ), + ), + ).setContentText(contentText).setSmallIcon(android.R.drawable.stat_sys_download_done) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setContentIntent(pendingIntent).setAutoCancel(true).setGroup(GROUP_INCOMING) + .setExtras( + Bundle().apply { + putString("remote", remoteUuid) + }, + ).build() + + notificationMgr.notify(notificationId, notification) + } + + @SuppressLint("MissingPermission") + fun showMessage( + remoteName: String?, + remoteBitmap: Bitmap?, + remoteUuid: String, + message: String, + messageTimestamp: Long, + ) { + if (!hasPermission() || ignoredRemoteMessageUuid == remoteUuid) return + + val intent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("remote", remoteUuid) + putExtra("messages", true) + } + + val notificationId = + remoteUuid.hashCode() xor message.hashCode() xor messageTimestamp.hashCode() + + val pendingIntent = PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES).setContentTitle( + context.getString( + R.string.new_message_from, + remoteName ?: context.getString(R.string.unknown), + ), + ).setContentText(message).setSmallIcon(android.R.drawable.stat_sys_download_done) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setContentIntent(pendingIntent).setAutoCancel(true).setGroup(GROUP_MESSAGES).setExtras( + Bundle().apply { + putString("remote", remoteUuid) + putBoolean("message", true) + }, + + ) + + if (remoteBitmap != null) { + notification.setLargeIcon(remoteBitmap) + } + + + notificationMgr.notify(notificationId, notification.build()) + } + + /** + * Updates the global progress notification. + * @return True if transfers are running, False if finished/idle. + */ + @SuppressLint("MissingPermission") + fun updateProgressNotification(remotes: List): Boolean { + if (!hasPermission()) return false + + if (progressBuilder == null) { + progressBuilder = NotificationCompat.Builder(context, CHANNEL_PROGRESS) + .setSmallIcon(R.drawable.ic_notification).setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW).setOnlyAlertOnce(true) + } + + var runningTransfers = 0 + var bytesDone: Long = 0 + var bytesTotal: Long = 0 + var bytesPerSecond: Long = 0 + + for (r in remotes) { + for (t in r.transfers) { + if (t.status is Transfer.Status.Transferring) { + runningTransfers++ + bytesDone += t.bytesTransferred + bytesTotal += t.totalSize + bytesPerSecond += t.bytesPerSecond + } + } + } + + val builder = progressBuilder!! + + if (runningTransfers > 0) { + val progress = + if (bytesTotal > 0) (bytesDone.toFloat() / bytesTotal * 1000f).toInt() else 0 + + builder.setOngoing(true) + builder.setProgress(1000, progress, false) + builder.setContentTitle( + String.format( + Locale.getDefault(), + context.getString(R.string.transfer_notification), + progress / 10f, + runningTransfers, + Formatter.formatFileSize(context, bytesPerSecond), + ), + ) + notificationMgr.notify(PROGRESS_NOTIFICATION_ID, builder.build()) + return true + } else { + // Transfers finished or stopped + notificationMgr.cancel(PROGRESS_NOTIFICATION_ID) + return false + } + } + + fun cancelAll() { + notificationMgr.cancelAll() + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + + val serviceChannel = NotificationChannel( + CHANNEL_SERVICE, + context.getString(R.string.service_running), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.notification_channel_description) + } + + val incomingChannel = NotificationChannel( + CHANNEL_INCOMING_TRANSFERS, + context.getString(R.string.incoming_transfer_channel), + NotificationManager.IMPORTANCE_HIGH, + ) + + val messagesChannel = NotificationChannel( + CHANNEL_MESSAGES, + context.getString(R.string.messages_channel), + NotificationManager.IMPORTANCE_HIGH, + ) + + + val progressChannel = NotificationChannel( + CHANNEL_PROGRESS, + context.getString(R.string.transfer_progress_channel), + NotificationManager.IMPORTANCE_LOW, + ) + + manager.createNotificationChannel(serviceChannel) + manager.createNotificationChannel(incomingChannel) + manager.createNotificationChannel(messagesChannel) + manager.createNotificationChannel(progressChannel) + } + } + + private fun hasPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + + companion object { + const val CHANNEL_SERVICE = "MainService" + const val CHANNEL_INCOMING_TRANSFERS = "IncomingTransfer" + const val CHANNEL_MESSAGES = "NewMessage" + const val CHANNEL_PROGRESS = "TransferProgress" + const val GROUP_INCOMING = "slowscript.warpinator.INCOMING_GROUP" + const val GROUP_MESSAGES = "slowscript.warpinator.MESSAGES_GROUP" + + const val FOREGROUND_SERVICE_ID = 1 + const val PROGRESS_NOTIFICATION_ID = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/notification/components/NotificationInhibitor.kt b/app/src/main/java/slowscript/warpinator/core/notification/components/NotificationInhibitor.kt new file mode 100644 index 00000000..a7962a6e --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/notification/components/NotificationInhibitor.kt @@ -0,0 +1,53 @@ +package slowscript.warpinator.core.notification.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import dagger.hilt.android.lifecycle.HiltViewModel +import slowscript.warpinator.core.notification.WarpinatorNotificationManager +import javax.inject.Inject + +@HiltViewModel +internal class NotificationInhibitorViewModel @Inject constructor( + val notificationManager: WarpinatorNotificationManager, +) : ViewModel() + +@Composable +fun NotificationInhibitor( + remoteUuid: String, + transfers: Boolean = false, + messages: Boolean = false, +) { + val viewModel: NotificationInhibitorViewModel = hiltViewModel() + val notificationManager = viewModel.notificationManager + + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect( + key1 = remoteUuid, + key2 = messages, + ) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (messages) notificationManager.ignoredRemoteMessageUuid = remoteUuid + if (transfers) notificationManager.ignoredRemoteTransferUuid = remoteUuid + } else if (event == Lifecycle.Event.ON_PAUSE) { + if (messages) notificationManager.ignoredRemoteMessageUuid = null + if (transfers) notificationManager.ignoredRemoteTransferUuid = null + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + + if (messages) notificationManager.ignoredRemoteMessageUuid = null + if (transfers) notificationManager.ignoredRemoteTransferUuid = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/service/GrpcService.kt b/app/src/main/java/slowscript/warpinator/core/service/GrpcService.kt new file mode 100644 index 00000000..84cab400 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/service/GrpcService.kt @@ -0,0 +1,216 @@ +package slowscript.warpinator.core.service + +import android.util.Log +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import slowscript.warpinator.WarpGrpcKt +import slowscript.warpinator.WarpProto +import slowscript.warpinator.WarpProto.HaveDuplex +import slowscript.warpinator.WarpProto.LookupName +import slowscript.warpinator.WarpProto.OpInfo +import slowscript.warpinator.WarpProto.RemoteMachineAvatar +import slowscript.warpinator.WarpProto.RemoteMachineInfo +import slowscript.warpinator.WarpProto.StopInfo +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.network.Server +import java.util.UUID + +class GrpcService( + val repository: WarpinatorRepository, + val server: Server, + val remotesManager: RemotesManager, + val transfersManager: TransfersManager, +) : WarpGrpcKt.WarpCoroutineImplBase() { + + val scope = repository.applicationScope + + override suspend fun checkDuplexConnection( + request: LookupName, + ): HaveDuplex { + val id = request.id + val r: Remote? = repository.remoteListState.value.find { it.uuid == id } + var haveDuplex = false + if (r != null) { + haveDuplex = + (r.status === Remote.RemoteStatus.Connected) || (r.status === Remote.RemoteStatus.AwaitingDuplex) + + if (r.status is Remote.RemoteStatus.Error || r.status === Remote.RemoteStatus.Disconnected) { + val serviceInfo = server.jmdns?.getServiceInfo(Server.SERVICE_TYPE, r.uuid) + if (serviceInfo != null && serviceInfo.inetAddresses.isNotEmpty()) { + val newIp = serviceInfo.inetAddresses[0] + val newPort = serviceInfo.port + repository.updateRemote(r.uuid) { it.copy(address = newIp, port = newPort) } + scope.launch { + remotesManager.getWorker(uuid = r.uuid) + ?.connect(r.hostname, newIp, r.authPort, newPort, r.api) + } + } + } + } + return HaveDuplex.newBuilder().setResponse(haveDuplex).build() + } + + override suspend fun waitingForDuplex( + request: LookupName, + ): HaveDuplex { + Log.d(TAG, "${request.readableName} is waiting for duplex...") + val id = request.id + val r: Remote? = repository.remoteListState.value.find { it.uuid == id } + + if (r != null && (r.status is Remote.RemoteStatus.Error || r.status === Remote.RemoteStatus.Disconnected)) { + scope.launch { remotesManager.getWorker(uuid = r.uuid)?.connect(r) } + } + + var i = 0 + var response = false + while (i < MAX_TRIES) { + val currentRemote = repository.remoteListState.value.find { it.uuid == id } + if (currentRemote != null) { + response = + currentRemote.status === Remote.RemoteStatus.AwaitingDuplex || currentRemote.status === Remote.RemoteStatus.Connected + } + if (response) break + i++ + if (i == 32) { + throw StatusException(Status.DEADLINE_EXCEEDED) + } + delay(250) + } + return HaveDuplex.newBuilder().setResponse(response).build() + } + + override suspend fun getRemoteMachineInfo(request: LookupName): RemoteMachineInfo { + return RemoteMachineInfo.newBuilder().setDisplayName(server.displayName) + .setUserName("android").setFeatureFlags(Server.SERVER_FEATURES).build() + } + + override fun getRemoteMachineAvatar(request: LookupName): Flow = flow { + val bytes = server.profilePictureBytes + emit(RemoteMachineAvatar.newBuilder().setAvatarChunk(bytes).build()) + } + + override suspend fun processTransferOpRequest(request: WarpProto.TransferOpRequest): WarpProto.VoidType { + val remoteUUID = request.info.ident + val r: Remote? = repository.remoteListState.value.find { it.uuid == remoteUUID } + + if (r == null) return WarpProto.VoidType.getDefaultInstance() + + Log.i(TAG, "Receiving transfer from " + r.userName) + if (r.hasErrorGroupCode) return WarpProto.VoidType.getDefaultInstance() + + // Create the Transfer object + val t = Transfer( + uid = UUID.randomUUID().toString(), + remoteUuid = remoteUUID, + direction = Transfer.Direction.Receive, + startTime = request.info.timestamp, + status = Transfer.Status.WaitingPermission, + totalSize = request.size, + fileCount = request.count, + singleMimeType = request.mimeIfSingle, + singleFileName = request.nameIfSingle, + topDirBaseNames = request.topDirBasenamesList, + useCompression = request.info.useCompression && server.useCompression, + ) + + transfersManager.onIncomingTransferRequest(t) + + return WarpProto.VoidType.getDefaultInstance() + } + + override suspend fun pauseTransferOp(request: OpInfo): WarpProto.VoidType { + return super.pauseTransferOp(request) + } + + override suspend fun sendTextMessage(request: WarpProto.TextMessage): WarpProto.VoidType { + Log.d(TAG, "sendTextMessage from " + request.getIdent() + ": " + request.getMessage()) + val message = Message( + remoteUuid = request.ident, + direction = Transfer.Direction.Receive, + timestamp = request.timestamp, + text = request.message, + ) + + remotesManager.getWorker(request.ident)?.onReceiveMessage(message) + return WarpProto.VoidType.getDefaultInstance() + } + + override fun startTransfer(request: OpInfo): Flow { + Log.d(TAG, "Transfer started by the other side") + + val t = getTransfer(request) ?: return flow { } + + // Update compression setting based on agreement + val updatedTransfer = t.copy( + useCompression = t.useCompression && request.useCompression, + ) + + repository.updateTransfer(t.remoteUuid, updatedTransfer) + + val worker = transfersManager.getWorker(t.remoteUuid, t.startTime) ?: return flow { + Log.e( + TAG, "Worker not found for active transfer", + ) + } + + return worker.generateFileFlow() + } + + override suspend fun cancelTransferOpRequest( + request: OpInfo, + ): WarpProto.VoidType { + Log.d(TAG, "Transfer cancelled by the other side") + val t = getTransfer(request) ?: return WarpProto.VoidType.getDefaultInstance() + + + val worker = transfersManager.getWorker(t.remoteUuid, t.startTime) + worker?.makeDeclined() + + return WarpProto.VoidType.getDefaultInstance() + } + + override suspend fun stopTransfer( + request: StopInfo, + ): WarpProto.VoidType { + Log.d(TAG, "Transfer stopped by the other side") + val t = getTransfer(request.info) ?: return WarpProto.VoidType.getDefaultInstance() + + val worker = transfersManager.getWorker(t.remoteUuid, t.startTime) + worker?.onStopped(request.error) + + return WarpProto.VoidType.getDefaultInstance() + } + + override suspend fun ping(request: LookupName): WarpProto.VoidType { + return WarpProto.VoidType.getDefaultInstance() + } + + private fun getTransfer(info: OpInfo): Transfer? { + val remoteUUID = info.ident + val r: Remote? = repository.remoteListState.value.find { it.uuid == remoteUUID } + + if (r == null) { + Log.w(TAG, "Could not find corresponding remote") + return null + } + + val t = r.transfers.find { it.startTime == info.timestamp } + + if (t == null) { + Log.w(TAG, "Could not find corresponding transfer for timestamp ${info.timestamp}") + } + return t + } + + companion object { + var TAG: String = "GRPC" + private const val MAX_TRIES = 32 // 8 sec + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/service/MainService.kt b/app/src/main/java/slowscript/warpinator/core/service/MainService.kt new file mode 100644 index 00000000..bd7988df --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/service/MainService.kt @@ -0,0 +1,405 @@ +package slowscript.warpinator.core.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.MulticastLock +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import slowscript.warpinator.core.data.ServiceState +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.network.Authenticator +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.notification.WarpinatorNotificationManager +import slowscript.warpinator.core.utils.Utils +import java.io.File +import java.util.Timer +import java.util.TimerTask +import javax.inject.Inject + +@AndroidEntryPoint +class MainService : LifecycleService() { + @Inject + lateinit var repository: WarpinatorRepository + + @Inject + lateinit var authenticator: Authenticator + + @Inject + lateinit var remotesManager: RemotesManager + + @Inject + lateinit var server: dagger.Lazy + + @Inject + lateinit var notificationManager: WarpinatorNotificationManager + + var runningTransfers: Int = 0 + var notificationMgr: NotificationManagerCompat? = null + + private var timer: Timer? = null + private var logcatProcess: Process? = null + private var lock: MulticastLock? = null + private var connMgr: ConnectivityManager? = null + private var networkCallback: NetworkCallback? = null + private var apStateChangeReceiver: BroadcastReceiver? = null + private var autoStopTask: TimerTask? = null + + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + return MainServiceBinder(this) + } + + override fun onUnbind(intent: Intent?): Boolean { + return false + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + notificationMgr = NotificationManagerCompat.from(this) + + // Start logging + if (repository.prefs.debugLog) { + logcatProcess = launchLogcat() + } + + // Acquire multicast lock for mDNS + acquireMulticastLock() + + // Server needs to load interface setting before this + repository.currentIPInfo = Utils.iPAddress(this, server.get().networkInterface) + Log.d(TAG, Utils.dumpInterfaces() ?: "No interfaces") + + // Sometimes fails. Maybe takes too long to get here? + startForeground( + WarpinatorNotificationManager.FOREGROUND_SERVICE_ID, + notificationManager.createForegroundNotification(), + ) + Log.v(TAG, "Entered foreground") + + // Actually start server if possible. This takes a long time so should be after startForeground() + if (repository.currentIPInfo != null) { + authenticator.serverCertificate // Generate cert on start if doesn't exist + + if (authenticator.certException != null) { + repository.updateServiceState( + ServiceState.InitializationFailed( + Utils.dumpInterfaces(), authenticator.certException.toString(), + ), + ) + Log.w(TAG, "Server will not start due to error") + } else { + server.get().start() + } + } + + startPeriodicTasks() + + listenOnNetworkChanges() + + // Consume active transfers + lifecycleScope.launch { + repository.remoteListState.collect { remotes -> + val isTransferring = notificationManager.updateProgressNotification(remotes) + + // Update local tracking for auto-stop logic + runningTransfers = if (isTransferring) 1 else 0 + } + } + + // Notify the tile service that MainService just started + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + TileMainService.requestListeningState(this) + } + + repository.updateServiceState(ServiceState.Ok) + + return START_STICKY + } + + private fun startPeriodicTasks() { + lifecycleScope.launch { + delay(5) + + while (isActive) { + try { + pingRemotes() + } catch (e: Exception) { + Log.e(TAG, "Ping failed", e) + } + delay(pingTime) + } + } + + lifecycleScope.launch { + delay(5) + while (isActive) { + try { + autoReconnect() + } catch (e: Exception) { + Log.e(TAG, "Auto reconnect failed", e) + } + delay(reconnectTime) + } + } + } + + override fun onDestroy() { + stopServer() + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(TAG, "Task removed") + if (runningTransfers == 0) // && autostop enabled + autoStop() + super.onTaskRemoved(rootIntent) + } + + override fun onTimeout(startId: Int, fgsType: Int) { + super.onTimeout(startId, fgsType) + Log.e(TAG, "Service has run out of time and must be stopped (Android 15+)") + stopSelf() + } + + private fun stopServer() { + repository.updateServiceState(ServiceState.Stopping) + // Create a copy of values to avoid concurrent modification during iteration + for (r in repository.remoteListState.value) { + if (r.status == Remote.RemoteStatus.Connected) { + remotesManager.getWorker(r.uuid)?.disconnect(r.hostname) + } + } + + repository.clearRemotes() + + notificationManager.cancelAll() + + lifecycleScope.launch { server.get().stop() } + notificationMgr?.cancelAll() + + if (connMgr != null && networkCallback != null) { + try { + connMgr?.unregisterNetworkCallback(networkCallback!!) + } catch (e: Exception) { + Log.e(TAG, "Error unregistering callback", e) + } + } + + if (apStateChangeReceiver != null) { + try { + unregisterReceiver(apStateChangeReceiver) + } catch (e: Exception) { + Log.e(TAG, "Error unregistering receiver", e) + } + } + + timer?.cancel() + + try { + lock?.release() + } catch (_: Exception) { + } + + logcatProcess?.destroy() + } + + private fun autoStop() { + if (!isAutoStopEnabled) return + Log.i(TAG, "Autostopping") + stopSelf() + autoStopTask = null + } + + private fun launchLogcat(): Process? { + val output = File(getExternalFilesDir(null), "latest.log") + var process: Process? = null + val cmd = "logcat -f ${output.absolutePath}\n" + try { + output.delete() // Delete original file + process = Runtime.getRuntime().exec(cmd) + Log.d(TAG, "---- Logcat started ----") + } catch (e: Exception) { + Log.e(TAG, "Failed to start logging to file", e) + } + return process + } + + private fun listenOnNetworkChanges() { + connMgr = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + + val nr = NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED).build() + + networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Log.d(TAG, "New network") + repository.updateNetworkState { it.copy(isConnected = true) } + onNetworkChanged() + } + + override fun onLost(network: Network) { + Log.d(TAG, "Network lost") + repository.updateNetworkState { it.copy(isConnected = true) } + onNetworkLost() + } + + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + Log.d(TAG, "Link properties changed") + onNetworkChanged() + } + } + + apStateChangeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val apState = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0) + if (apState % 10 == WifiManager.WIFI_STATE_ENABLED) { + Log.d(TAG, "AP was enabled") + repository.updateNetworkState { it.copy(isHotspot = true) } + onNetworkChanged() + } else if (apState % 10 == WifiManager.WIFI_STATE_DISABLED) { + Log.d(TAG, "AP was disabled") + repository.updateNetworkState { it.copy(isHotspot = true) } + onNetworkLost() + } + } + } + + // Manually get state, some devices don't fire broadcast when registered + repository.updateNetworkState { it.copy(isHotspot = isHotspotOn) } + registerReceiver( + apStateChangeReceiver, IntentFilter("android.net.wifi.WIFI_AP_STATE_CHANGED"), + ) + + networkCallback?.let { + connMgr?.registerNetworkCallback(nr, it) + } + } + + private val isHotspotOn: Boolean + get() { + val manager = + applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager ?: return false + try { + val method = manager.javaClass.getDeclaredMethod("isWifiApEnabled") + method.isAccessible = true + return method.invoke(manager) as? Boolean ?: false + } catch (e: Exception) { + Log.e(TAG, "Failed to get hotspot state", e) + } + return false + } + + fun gotNetwork() = repository.networkState.value.isOnline + + private fun onNetworkLost() { + if (!gotNetwork()) repository.currentIPInfo = + null // Rebind even if we reconnected to the same net + } + + private fun onNetworkChanged() { + val newInfo = Utils.iPAddress(this, server.get().networkInterface) + if (newInfo == null) { + Log.w(TAG, "Network changed, but we do not have an IP") + repository.currentIPInfo = null + return + } + + val newIP = newInfo.address + val oldIP = repository.currentIPInfo?.address + + if (newIP != oldIP) { + Log.d(TAG, ":: Restarting. New IP: $newIP") + repository.updateServiceState(ServiceState.NetworkChangeRestart) + repository.currentIPInfo = newInfo + + // Regenerate cert + authenticator.serverCertificate + + // Restart server + lifecycleScope.launch { + server.get().stop() + + if (authenticator.certException == null) { + server.get().start() + } else { + Log.w(TAG, "No cert. Server not started.") + } + } + } + } + + private suspend fun pingRemotes() = withContext(Dispatchers.IO) { + try { + for (r in repository.remoteListState.value) { + if ((r.api == 1) && (r.status == Remote.RemoteStatus.Connected)) { + remotesManager.getWorker(r.uuid)?.ping() + } + } + } catch (_: Exception) { + } + } + + private suspend fun autoReconnect() = withContext(Dispatchers.IO) { + if (!gotNetwork()) return@withContext + try { + for (r in repository.remoteListState.value) { + if ((r.status == Remote.RemoteStatus.Disconnected || r.status is Remote.RemoteStatus.Error) && r.serviceAvailable && !r.hasErrorGroupCode) { + // Try reconnecting + Log.d(TAG, "Automatically reconnecting to ${r.hostname}") + remotesManager.getWorker(r.uuid) + ?.connect(r.hostname, r.address, r.authPort, r.port, r.api) + } + } + } catch (_: Exception) { + } + } + + + private fun acquireMulticastLock() { + val wifi = applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager + if (wifi != null) { + lock = wifi.createMulticastLock("WarpMDNSLock") + lock?.setReferenceCounted(true) + lock?.acquire() + Log.d(TAG, "Multicast lock acquired") + } + } + + private val isAutoStopEnabled: Boolean + get() = repository.prefs.autoStop && !repository.prefs.bootStart + + fun notifyDeviceCountUpdate() { + // TODO(raresvanca): remove this function and subscribe to the repos client manager for updates + } + + companion object { + private const val TAG = "SERVICE" + + const val ACTION_STOP: String = "StopSvc" + + var pingTime: Long = 10000 + var reconnectTime: Long = 40000 + } +} + diff --git a/app/src/main/java/slowscript/warpinator/MainServiceBinder.java b/app/src/main/java/slowscript/warpinator/core/service/MainServiceBinder.java similarity index 86% rename from app/src/main/java/slowscript/warpinator/MainServiceBinder.java rename to app/src/main/java/slowscript/warpinator/core/service/MainServiceBinder.java index 146eda25..6326f68c 100644 --- a/app/src/main/java/slowscript/warpinator/MainServiceBinder.java +++ b/app/src/main/java/slowscript/warpinator/core/service/MainServiceBinder.java @@ -1,4 +1,4 @@ -package slowscript.warpinator; +package slowscript.warpinator.core.service; import android.os.Binder; diff --git a/app/src/main/java/slowscript/warpinator/core/service/RegistrationService.kt b/app/src/main/java/slowscript/warpinator/core/service/RegistrationService.kt new file mode 100644 index 00000000..a85bdfb8 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/service/RegistrationService.kt @@ -0,0 +1,96 @@ +package slowscript.warpinator.core.service + +import android.util.Base64 +import android.util.Log +import com.google.common.net.InetAddresses +import com.google.protobuf.ByteString +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.launch +import slowscript.warpinator.WarpProto.RegRequest +import slowscript.warpinator.WarpProto.RegResponse +import slowscript.warpinator.WarpProto.ServiceRegistration +import slowscript.warpinator.WarpRegistrationGrpc.WarpRegistrationImplBase +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.preferences.SavedFavourite +import slowscript.warpinator.core.network.Authenticator +import slowscript.warpinator.core.network.Server + +class RegistrationService( + var repository: WarpinatorRepository, + var server: Server, + var remotesManager: RemotesManager, + var authenticator: Authenticator, +) : WarpRegistrationImplBase() { + + + val scope = repository.applicationScope + + + override fun requestCertificate( + request: RegRequest, responseObserver: StreamObserver, + ) { + val cert = authenticator.boxedCertificate + val sendData = Base64.encode(cert, Base64.DEFAULT) + Log.v( + TAG, "Sending certificate to " + request.getHostname() + " ; IP=" + request.getIp(), + ) // IP can by mine (Linux impl) or remote's + responseObserver.onNext( + RegResponse.newBuilder().setLockedCertBytes(ByteString.copyFrom(sendData)).build(), + ) + responseObserver.onCompleted() + } + + override fun registerService( + req: ServiceRegistration, responseObserver: StreamObserver, + ) { + val id = req.getServiceId() + var r: Remote? = repository.remoteListState.value.find { it.uuid == id } + Log.i(TAG, "Service registration from " + req.getServiceId()) + if (r != null) { + if (r.status !== Remote.RemoteStatus.Connected) { + r = r.copy( + address = InetAddresses.forString(req.getIp()), + authPort = req.authPort, + hostname = req.hostname, + api = req.apiVersion, + port = req.port, + serviceName = req.serviceId, + staticService = true, + ) + if (r.status === Remote.RemoteStatus.Disconnected || r.status is Remote.RemoteStatus.Error) { + scope.launch { + remotesManager.getWorker(r.uuid)?.connect( + hostname = r.hostname, + address = r.address, + authPort = r.authPort, + port = r.port, + api = r.api, + ) + } + } else { + repository.addOrUpdateRemote(r) + } + } else Log.w("REG_V2", "Attempted registration from already connected remote") + } else { + r = Remote( + uuid = req.serviceId, + address = InetAddresses.forString(req.getIp()), + authPort = req.authPort, + hostname = req.hostname, + api = req.apiVersion, + port = req.port, + serviceName = req.serviceId, + staticService = true, + isFavorite = repository.prefs.favourites.contains(SavedFavourite(req.serviceId)), + ) + scope.launch { server.addRemote(r) } + } + responseObserver.onNext(server.serviceRegistrationMsg) + responseObserver.onCompleted() + } + + companion object { + private const val TAG = "REG_V2" + } +} diff --git a/app/src/main/java/slowscript/warpinator/core/service/RemotesManager.kt b/app/src/main/java/slowscript/warpinator/core/service/RemotesManager.kt new file mode 100644 index 00000000..312f442a --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/service/RemotesManager.kt @@ -0,0 +1,41 @@ +package slowscript.warpinator.core.service + +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.network.Authenticator +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.network.worker.RemoteWorker +import slowscript.warpinator.core.notification.WarpinatorNotificationManager +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemotesManager @Inject constructor( + private val repository: WarpinatorRepository, + private val notificationManager: WarpinatorNotificationManager, + private val server: Server, + private val authenticator: Authenticator, +) { + private val workers = ConcurrentHashMap() + + fun onRemoteDiscovered(remote: Remote): RemoteWorker? { + repository.addOrUpdateRemote(remote) + + if (!workers.containsKey(remote.uuid)) { + val newWorker = RemoteWorker( + uuid = remote.uuid, + repository = repository, + notificationManager = notificationManager, + authenticator = authenticator, + server = server, + ) + workers[remote.uuid] = newWorker + return newWorker + } + + return null + } + + fun getWorker(uuid: String): RemoteWorker? = workers[uuid] +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/service/StopSvcReceiver.kt b/app/src/main/java/slowscript/warpinator/core/service/StopSvcReceiver.kt new file mode 100644 index 00000000..789a1542 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/service/StopSvcReceiver.kt @@ -0,0 +1,21 @@ +package slowscript.warpinator.core.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import slowscript.warpinator.core.data.ServiceState +import slowscript.warpinator.core.data.WarpinatorRepository +import javax.inject.Inject + +@AndroidEntryPoint +class StopSvcReceiver() : BroadcastReceiver() { + @Inject + lateinit var repository: WarpinatorRepository + override fun onReceive(context: Context, intent: Intent) { + if (MainService.ACTION_STOP == intent.action) { + context.stopService(Intent(context, MainService::class.java)) + repository.updateServiceState(ServiceState.Stopping) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/TileMainService.java b/app/src/main/java/slowscript/warpinator/core/service/TileMainService.java similarity index 71% rename from app/src/main/java/slowscript/warpinator/TileMainService.java rename to app/src/main/java/slowscript/warpinator/core/service/TileMainService.java index 90c6ad30..8f923f8c 100644 --- a/app/src/main/java/slowscript/warpinator/TileMainService.java +++ b/app/src/main/java/slowscript/warpinator/core/service/TileMainService.java @@ -1,4 +1,4 @@ -package slowscript.warpinator; +package slowscript.warpinator.core.service; import android.content.ComponentName; import android.content.Context; @@ -13,12 +13,20 @@ import androidx.annotation.RequiresApi; +import dagger.hilt.android.AndroidEntryPoint; +import slowscript.warpinator.R; +import slowscript.warpinator.core.utils.Utils; + @RequiresApi(api = Build.VERSION_CODES.N) -public class TileMainService extends TileService implements MainService.RemoteCountObserver { +@AndroidEntryPoint +public class TileMainService extends TileService { private Intent serviceIntent; private ServiceConnection serviceConnection; private MainServiceBinder binder; - private MainService.DisposableRemoteCountObserver unregisterObserver = null; + + public static void requestListeningState(Context context) { + requestListeningState(context, new ComponentName(context, TileMainService.class)); + } @Override public void onCreate() { @@ -29,7 +37,7 @@ public void onCreate() { @Override public void onStartListening() { super.onStartListening(); - boolean isRunning = Utils.isMyServiceRunning(this, MainService.class); + boolean isRunning = Utils.INSTANCE.isMyServiceRunning(this, MainService.class); updateTile(isRunning); if (isRunning && binder == null) { joinService(); @@ -42,10 +50,10 @@ public void onDestroy() { unbindService(serviceConnection); serviceConnection = null; } - if (unregisterObserver != null) { - unregisterObserver.dispose(); - unregisterObserver = null; - } +// if (unregisterObserver != null) { +// unregisterObserver.dispose(); +// unregisterObserver = null; +// } binder = null; super.onDestroy(); } @@ -81,13 +89,13 @@ private void updateBoundService(MainServiceBinder binder) { this.binder = binder; if (binder == null) { serviceConnection = null; - if (unregisterObserver != null) { - unregisterObserver.dispose(); - unregisterObserver = null; - } +// if (unregisterObserver != null) { +// unregisterObserver.dispose(); +// unregisterObserver = null; +// } updateTile(false); } else { - unregisterObserver = this.binder.getService().observeDeviceCount(this); +// unregisterObserver = this.binder.getService().observeDeviceCount(this); updateTile(true); } } @@ -100,8 +108,7 @@ private void startService() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { Log.e("Tile", "Cannot start main service from background on Android 14+", e); Toast.makeText(this, "Cannot Warpinator from tile on Android 14+", Toast.LENGTH_LONG).show(); - } - else Log.e("Tile", "Cannot start main service (Android <14)", e); + } else Log.e("Tile", "Cannot start main service (Android <14)", e); return; } } else { @@ -127,27 +134,8 @@ public void onServiceDisconnected(ComponentName componentName) { private void stopService() { updateTile(false); updateBoundService(null); - Intent stopIntent = new Intent(this, MainService.StopSvcReceiver.class); + Intent stopIntent = new Intent(this, StopSvcReceiver.class); stopIntent.setAction(MainService.ACTION_STOP); sendBroadcast(stopIntent); } - - @Override - public void onDeviceCountChange(int newCount) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Tile tile = getQsTile(); - if (newCount > 0 && tile.getState() == Tile.STATE_ACTIVE) { - tile.setSubtitle(getString(R.string.qs_discovered_count, newCount)); - } else { - // If there are no discovered remotes, don't mention them. - // This should fall back to displaying the state of the tile ("On"). - tile.setSubtitle(null); - } - tile.updateTile(); - } - } - - public static void requestListeningState(Context context) { - requestListeningState(context, new ComponentName(context, TileMainService.class)); - } } diff --git a/app/src/main/java/slowscript/warpinator/core/service/TransfersManager.kt b/app/src/main/java/slowscript/warpinator/core/service/TransfersManager.kt new file mode 100644 index 00000000..191ae579 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/service/TransfersManager.kt @@ -0,0 +1,94 @@ +package slowscript.warpinator.core.service + + +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import slowscript.warpinator.core.data.WarpinatorRepository +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.network.worker.TransferWorker +import slowscript.warpinator.core.notification.WarpinatorNotificationManager +import slowscript.warpinator.core.system.WarpinatorPowerManager +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TransfersManager @Inject constructor( + private val repository: WarpinatorRepository, + private val server: Server, + private val remotesManager: RemotesManager, + private val powerManager: WarpinatorPowerManager, + private val notificationManager: WarpinatorNotificationManager, +) { + private val activeWorkers = ConcurrentHashMap() + + suspend fun initiateSend(remote: Remote, uris: List, isDir: Boolean) = + withContext(Dispatchers.IO) { + val initialTransfer = Transfer( + remoteUuid = remote.uuid, + direction = Transfer.Direction.Send, + uris = uris, + useCompression = server.useCompression, + ) + + startSendWorker(initialTransfer, isDir) + } + + suspend fun retrySend(existingTransfer: Transfer, isDir: Boolean) = + withContext(Dispatchers.IO) { + // Reset status and progress, but keep ID, timestamp, and URIs + val resetTransfer = existingTransfer.copy( + status = Transfer.Status.Initializing, + bytesTransferred = 0, + bytesPerSecond = 0, + useCompression = server.useCompression, + ) + startSendWorker(resetTransfer, isDir) + } + + private suspend fun startSendWorker(transfer: Transfer, isDir: Boolean) { + val worker = TransferWorker( + initialTransfer = transfer, + repository = repository, + server = server, + remotesManager = remotesManager, + powerManager = powerManager, + notificationManager = notificationManager, + ) + + val preparedTransfer = worker.prepareSend(isDir) + val key = getTransferKey(preparedTransfer.remoteUuid, preparedTransfer.startTime) + activeWorkers[key] = worker + remotesManager.getWorker(transfer.remoteUuid)?.startSendTransfer(preparedTransfer) + } + + fun onIncomingTransferRequest(transfer: Transfer) { + repository.addTransfer(transfer.remoteUuid, transfer) + + val worker = TransferWorker( + initialTransfer = transfer, + repository = repository, + server = server, + remotesManager = remotesManager, + powerManager = powerManager, + notificationManager = notificationManager, + ) + val key = getTransferKey(transfer.remoteUuid, transfer.startTime) + activeWorkers[key] = worker + + worker.prepareReceive() + } + + fun getWorker(remoteUuid: String, startTime: Long): TransferWorker? { + return activeWorkers[getTransferKey(remoteUuid, startTime)] + } + + fun removeWorker(remoteUuid: String, startTime: Long) { + activeWorkers.remove(getTransferKey(remoteUuid, startTime)) + } + + private fun getTransferKey(uuid: String, timestamp: Long): String = "${uuid}_$timestamp" +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/system/PowerManager.kt b/app/src/main/java/slowscript/warpinator/core/system/PowerManager.kt new file mode 100644 index 00000000..8f534237 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/system/PowerManager.kt @@ -0,0 +1,55 @@ +package slowscript.warpinator.core.system + +import android.content.Context +import android.os.PowerManager +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WarpinatorPowerManager @Inject constructor( + @param:ApplicationContext private val context: Context, +) { + private var wakeLock: PowerManager.WakeLock? = null + private var lockCount = 0 + + init { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + // PARTIAL_WAKE_LOCK ensures the CPU runs, but screen can turn off. + wakeLock = + powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$TAG::WakeLock").apply { + // Important: Reference counting allows multiple transfers to "hold" the lock. + // The CPU stays awake until the lock is released as many times as it was acquired. + setReferenceCounted(true) + } + } + + fun acquire() { + try { + // 10 minute timeout as a safety net per acquisition in case of logic bugs + wakeLock?.acquire(10 * 60 * 1000L) + lockCount++ + Log.v(TAG, "WakeLock acquired. Held count: $lockCount") + } catch (e: Exception) { + Log.e(TAG, "Failed to acquire WakeLock", e) + } + } + + fun release() { + try { + if (wakeLock?.isHeld == true) { + wakeLock?.release() + lockCount-- + Log.v(TAG, "WakeLock released. Held count: $lockCount") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to release WakeLock", e) + } + } + + + companion object { + private const val TAG = "WarpinatorPowerManager" + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/system/PreferenceManager.kt b/app/src/main/java/slowscript/warpinator/core/system/PreferenceManager.kt new file mode 100644 index 00000000..6f7e6af3 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/system/PreferenceManager.kt @@ -0,0 +1,288 @@ +package slowscript.warpinator.core.system + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.util.Log +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import dagger.hilt.android.qualifiers.ApplicationContext +import slowscript.warpinator.core.model.preferences.RecentRemote +import slowscript.warpinator.core.model.preferences.SavedFavourite +import slowscript.warpinator.core.model.preferences.ThemeOptions +import slowscript.warpinator.core.model.preferences.recentRemotesFromJson +import slowscript.warpinator.core.model.preferences.savedFavouritesFromJson +import slowscript.warpinator.core.model.preferences.toJson +import slowscript.warpinator.core.network.Server +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PreferenceManager @Inject constructor( + @param:ApplicationContext val context: Context, +) { + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + + var favourites: Set = setOf() + var recentRemotes: List = listOf() + + // Simple Properties (Read-only accessors for sync access) + var loadedPreferences = false + private set + + val debugLog: Boolean get() = prefs.getBoolean(KEY_DEBUG_LOG, false) + val autoStop: Boolean get() = prefs.getBoolean(KEY_AUTO_STOP, true) + val bootStart: Boolean get() = prefs.getBoolean(KEY_START_ON_BOOT, false) + val serviceUuid: String? get() = prefs.getString(KEY_UUID, null) + val displayName: String + get() = prefs.getString(KEY_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) ?: DEFAULT_DISPLAY_NAME + val port: Int get() = prefs.getString(KEY_PORT, DEFAULT_PORT)?.toIntOrNull() ?: 42000 + val authPort: Int + get() = prefs.getString(KEY_AUTH_PORT, DEFAULT_AUTH_PORT)?.toIntOrNull() ?: 42001 + val networkInterface: String + get() = prefs.getString( + KEY_NETWORK_INTERFACE, + Server.NETWORK_INTERFACE_AUTO, + ) ?: Server.NETWORK_INTERFACE_AUTO + val groupCode: String + get() = prefs.getString(KEY_GROUP_CODE, DEFAULT_GROUP_CODE) ?: DEFAULT_GROUP_CODE + val allowOverwrite: Boolean get() = prefs.getBoolean(KEY_ALLOW_OVERWRITE, false) + val autoAccept: Boolean get() = prefs.getBoolean(KEY_AUTO_ACCEPT, false) + val useCompression: Boolean get() = prefs.getBoolean(KEY_USE_COMPRESSION, false) + val notifyIncoming: Boolean get() = prefs.getBoolean(KEY_NOTIFY_INCOMING, true) + val downloadDirUri: String? get() = prefs.getString(KEY_DOWNLOAD_DIR, null) + val profilePicture: String? + get() = prefs.getString( + KEY_PROFILE_PICTURE, + DEFAULT_PROFILE_PICTURE, + ) + val theme: String get() = prefs.getString(KEY_THEME, VAL_THEME_DEFAULT) ?: VAL_THEME_DEFAULT + val integrateMessages: Boolean get() = prefs.getBoolean(KEY_INTEGRATE_MESSAGES, false) + val dynamicColors: Boolean get() = prefs.getBoolean(KEY_DYNAMIC_COLORS, true) + + init { + loadSettings() + } + + fun loadSettings() { + // Ensure defaults exist + if (!prefs.contains(KEY_PROFILE_PICTURE)) { + prefs.edit { putString(KEY_PROFILE_PICTURE, DEFAULT_PROFILE_PICTURE) } + } + + // Boot/AutoStop logic migration + if (bootStart && autoStop) { + prefs.edit { putBoolean(KEY_AUTO_STOP, false) } + } + + // Load Complex Data + loadFavorites() + loadRecentRemotes() + + loadedPreferences = true + } + + // --- Favorites Logic --- + + private fun loadFavorites() { + + try { + val json = prefs.getString(KEY_FAVORITES, null) ?: throw Exception("No favorites found") + favourites = savedFavouritesFromJson(json) + return + } catch (_: Exception) { + // Failed to find new string format, fall back to legacy + } + + // Legacy Fallback (StringSet) + val legacySet = prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet() + val migrated = legacySet.map { SavedFavourite(it) }.toSet() + + favourites = migrated + + // Save to new format immediately if we found legacy data + if (migrated.isNotEmpty()) { + saveFavorites(migrated) + } + } + + fun toggleFavorite(uuid: String): Boolean { + var newStatus = false + val current = favourites + val newItem = SavedFavourite(uuid) + val newSet = if (current.contains(newItem)) { + newStatus = false + + current - newItem + } else { + newStatus = true + + current + newItem + } + saveFavorites(newSet) + + return newStatus + } + + private fun saveFavorites(set: Set) { + favourites = set + val json = set.toJson() + prefs.edit { + putString(KEY_FAVORITES, json) + } + } + + // --- Recent Remotes Logic --- + + private fun loadRecentRemotes() { + val json = prefs.getString(KEY_RECENT_REMOTES, null) + if (json != null) { + try { + recentRemotes = recentRemotesFromJson(json) + return + } catch (_: Exception) { + // Failed to find new string format, fall back to legacy + } + } + + // Legacy Fallback (String with \n and | delimiters) + val legacyString = prefs.getString(KEY_RECENT_REMOTES, null) + if (legacyString != null) { + try { + val migrated = legacyString.split("\n").mapNotNull { line -> + val parts = line.split(" | ") + if (parts.isNotEmpty()) { + RecentRemote(parts[0], parts.getOrElse(1) { "Unknown" }) + } else null + } + recentRemotes = migrated + if (migrated.isNotEmpty()) { + saveRecentRemotes(migrated) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse legacy recent remotes", e) + } + } + } + + fun addRecentRemote(host: String, hostname: String) { + val newItem = RecentRemote(host, hostname) + val current = recentRemotes.toMutableList() + + // Remove existing if present (to move to top) + current.removeAll { it.host == host } + current.add(0, newItem) + + if (current.size > 10) { + current.removeAt(current.lastIndex) + } + + saveRecentRemotes(current) + } + + private fun saveRecentRemotes(list: List) { + recentRemotes = list + val json = list.toJson() + + + prefs.edit { + putString(KEY_RECENT_REMOTES, json) + } + } + + // --- Setters --- + + fun saveServiceUuid(uuid: String) = prefs.edit { putString(KEY_UUID, uuid) } + + fun setDisplayName(value: String) = prefs.edit { putString(KEY_DISPLAY_NAME, value) } + + fun setGroupCode(value: String) = prefs.edit { putString(KEY_GROUP_CODE, value) } + + fun setServerPort(value: String) = prefs.edit { putString(KEY_PORT, value) } + + fun setAuthPort(value: String) = prefs.edit { putString(KEY_AUTH_PORT, value) } + + fun setNetworkInterface(value: String) = prefs.edit { putString(KEY_NETWORK_INTERFACE, value) } + + fun setNotifyIncoming(value: Boolean) = prefs.edit { putBoolean(KEY_NOTIFY_INCOMING, value) } + + fun setAllowOverwrite(value: Boolean) = prefs.edit { putBoolean(KEY_ALLOW_OVERWRITE, value) } + + fun setAutoAccept(value: Boolean) = prefs.edit { putBoolean(KEY_AUTO_ACCEPT, value) } + + fun setUseCompression(value: Boolean) = prefs.edit { putBoolean(KEY_USE_COMPRESSION, value) } + + fun setStartOnBoot(value: Boolean) { + if (value) { + prefs.edit { + putBoolean(KEY_START_ON_BOOT, true) + putBoolean(KEY_AUTO_STOP, false) + } + } else { + prefs.edit { putBoolean(KEY_START_ON_BOOT, false) } + } + } + + fun setAutoStop(value: Boolean) = prefs.edit { putBoolean(KEY_AUTO_STOP, value) } + + fun setDebugLog(value: Boolean) = prefs.edit { putBoolean(KEY_DEBUG_LOG, value) } + + fun setDirectory(uri: Uri) = prefs.edit { putString(KEY_DOWNLOAD_DIR, uri.toString()) } + + fun resetDirectory() = prefs.edit { remove(KEY_DOWNLOAD_DIR) } + + fun setProfilePictureKey(key: String) = prefs.edit { putString(KEY_PROFILE_PICTURE, key) } + + fun setTheme(value: ThemeOptions) { + prefs.edit { putString(KEY_THEME, value.key) } + } + + fun setIntegrateMessages(value: Boolean) = + prefs.edit { putBoolean(KEY_INTEGRATE_MESSAGES, value) } + + fun setDynamicColors(value: Boolean) = prefs.edit { putBoolean(KEY_DYNAMIC_COLORS, value) } + + + companion object { + private const val TAG = "Prefs" + + const val KEY_UUID = "uuid" + const val KEY_DISPLAY_NAME = "displayName" + const val KEY_PROFILE_PICTURE = "profile" + const val KEY_DOWNLOAD_DIR = "downloadDir" + const val KEY_NOTIFY_INCOMING = "notifyIncoming" + const val KEY_ALLOW_OVERWRITE = "allowOverwrite" + const val KEY_AUTO_ACCEPT = "autoAccept" + const val KEY_USE_COMPRESSION = "useCompression" + const val KEY_START_ON_BOOT = "bootStart" + const val KEY_AUTO_STOP = "autoStop" + const val KEY_DEBUG_LOG = "debugLog" + const val KEY_GROUP_CODE = "groupCode" + const val KEY_PORT = "port" + const val KEY_AUTH_PORT = "authPort" + const val KEY_NETWORK_INTERFACE = "networkInterface" + const val KEY_THEME = "theme_setting" + const val KEY_INTEGRATE_MESSAGES = "integrateMessages" + const val KEY_DYNAMIC_COLORS = "dynamicColors" + + const val KEY_FAVORITES = "favorites" + const val KEY_RECENT_REMOTES = "recentRemotes" + + // Defaults + const val DEFAULT_DISPLAY_NAME = "Android" + const val DEFAULT_PORT = "42000" + const val DEFAULT_AUTH_PORT = "42001" + const val DEFAULT_GROUP_CODE = "Warpinator" + const val DEFAULT_PROFILE_PICTURE = "0" + const val DEFAULT_NETWORK_INTERFACE = "auto" + + // Theme Values + const val VAL_THEME_DEFAULT = "sysDefault" + const val VAL_THEME_LIGHT = "lightTheme" + const val VAL_THEME_DARK = "darkTheme" + + // File/Folder Names + const val DIR_NAME_WARPINATOR = "Warpinator" + const val FILE_PROFILE_PIC = "profilePic.png" + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/Autostart.java b/app/src/main/java/slowscript/warpinator/core/utils/Autostart.java similarity index 72% rename from app/src/main/java/slowscript/warpinator/Autostart.java rename to app/src/main/java/slowscript/warpinator/core/utils/Autostart.java index a3326a0f..3fc95de0 100644 --- a/app/src/main/java/slowscript/warpinator/Autostart.java +++ b/app/src/main/java/slowscript/warpinator/core/utils/Autostart.java @@ -1,4 +1,4 @@ -package slowscript.warpinator; +package slowscript.warpinator.core.utils; import android.content.BroadcastReceiver; import android.content.Context; @@ -9,6 +9,8 @@ import androidx.preference.PreferenceManager; +import slowscript.warpinator.core.service.MainService; + public class Autostart extends BroadcastReceiver { @Override @@ -16,10 +18,10 @@ public void onReceive(Context context, Intent intent) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (( - Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction()) || - Intent.ACTION_REBOOT.equals(intent.getAction()) || - Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction()) - ) && prefs.getBoolean("bootStart", false) + Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction()) || + Intent.ACTION_REBOOT.equals(intent.getAction()) || + Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction()) + ) && prefs.getBoolean("bootStart", false) ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { Log.e("Autostart", "Autostart not possible on Android 15+"); diff --git a/app/src/main/java/slowscript/warpinator/core/utils/KeyboardShortcuts.kt b/app/src/main/java/slowscript/warpinator/core/utils/KeyboardShortcuts.kt new file mode 100644 index 00000000..2cdecf9a --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/KeyboardShortcuts.kt @@ -0,0 +1,50 @@ +package slowscript.warpinator.core.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.input.key.KeyEvent + +typealias KeyShortcut = (KeyEvent) -> Boolean + +class KeyShortcutDispatcher { + private val handlers = mutableStateListOf() + + fun register(handler: KeyShortcut) { + handlers.add(handler) + } + + fun unregister(handler: KeyShortcut) { + handlers.remove(handler) + } + + fun dispatch(event: KeyEvent): Boolean { + // last registered wins (innermost screen takes priority) + return handlers.reversed().any { it(event) } + } +} + +val LocalKeyShortcutDispatcher = staticCompositionLocalOf { + error("No KeyShortcutDispatcher provided") +} + +@Composable +fun KeyboardShortcuts( + enabled: Boolean = true, + handler: (KeyEvent) -> Boolean, +) { + val dispatcher = LocalKeyShortcutDispatcher.current + val currentHandler by rememberUpdatedState(handler) + val currentEnabled by rememberUpdatedState(enabled) + + DisposableEffect(dispatcher) { + val wrappedHandler: KeyShortcut = { event -> + if (currentEnabled) currentHandler(event) else false + } + dispatcher.register(wrappedHandler) + onDispose { dispatcher.unregister(wrappedHandler) } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/LinkAnnotation.kt b/app/src/main/java/slowscript/warpinator/core/utils/LinkAnnotation.kt new file mode 100644 index 00000000..9a8c4dcf --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/LinkAnnotation.kt @@ -0,0 +1,68 @@ +package slowscript.warpinator.core.utils + +import android.content.Intent +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri + +@Composable +fun rememberAnnotatedLinkText(text: String, accentColor: Color): AnnotatedString { + val context = LocalContext.current + val configuration = LocalWindowInfo.current.containerDpSize + val isLargeScreen = configuration.width >= 600.dp + + return remember(text) { + buildAnnotatedString { + val urlRegex = + """((?:https?://|www\.)[^\s<>"'{}|\\^`\[\]]*[^\s<>"'{}|\\^`\[\].,;:!?)])""".toRegex() + var lastIndex = 0 + + urlRegex.findAll(text).forEach { match -> + append(text.substring(lastIndex, match.range.first)) + + withLink( + LinkAnnotation.Url( + url = match.value, + styles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + color = accentColor, + ), + ), + linkInteractionListener = { annotation -> + val url = (annotation as LinkAnnotation.Url).url + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isLargeScreen) { + intent.addFlags( + Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK, + ) + } else { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) + }, + ), + ) { + append(match.value) + } + + lastIndex = match.range.last + 1 + } + append(text.substring(lastIndex)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/LogFileWriter.kt b/app/src/main/java/slowscript/warpinator/core/utils/LogFileWriter.kt new file mode 100644 index 00000000..2d8cabe7 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/LogFileWriter.kt @@ -0,0 +1,58 @@ +package slowscript.warpinator.core.utils + +import android.content.Context +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.utils.messages.FailedToWriteLog +import slowscript.warpinator.core.utils.messages.SucceededToWriteLog +import java.io.File + +object LogFileWriter { + suspend fun generateDumpLog(context: Context): File? = withContext(Dispatchers.IO) { + Log.d(TAG, "Saving log...") + + val cacheDir = context.externalCacheDir ?: context.cacheDir + val output = File(cacheDir, "dump.log") + + try { + val cmd = arrayOf("logcat", "-d", "-f", output.absolutePath) + val process = Runtime.getRuntime().exec(cmd) + process.waitFor() + output + } catch (e: Exception) { + Log.e(TAG, "Failed to dump log", e) + null + } + } + + fun writeLog( + uri: Uri, + scope: CoroutineScope, + context: Context, + emitMessage: (UiMessage) -> Unit, + ) { + try { + scope.launch { + val file = generateDumpLog(context) + file?.let { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + file.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + emitMessage(SucceededToWriteLog()) + } + } + } catch (e: Exception) { + Log.e(TAG, "Could not save log to file", e) + emitMessage(FailedToWriteLog(e)) + } + } + + private const val TAG = "LogFileWriter" +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/ProfilePicturePainter.kt b/app/src/main/java/slowscript/warpinator/core/utils/ProfilePicturePainter.kt new file mode 100644 index 00000000..462a3641 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/ProfilePicturePainter.kt @@ -0,0 +1,106 @@ +package slowscript.warpinator.core.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ImageDecoder +import android.graphics.Paint +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.createBitmap +import androidx.core.net.toUri +import slowscript.warpinator.R + +/** + * A utility object responsible for generating and retrieving user profile pictures. + * + * This class handles various sources for profile pictures: + * - Legacy content URIs. + * - Local files (specifically "profilePic.png"). + * - Procedurally generated avatars based on an index (color selection) and an icon. + */ +internal object ProfilePicturePainter { + private const val TAG = "PicPainter" + fun generatePairs(steps: Int = 14, reversed: Boolean = false): List> { + val pairs = mutableListOf>() + val stepSize = 360f / (steps + 1) + + for (i in 0 until steps) { + val hue = i * stepSize + + val containerInt = Color.HSVToColor(floatArrayOf(hue, 0.20f, 0.90f)) + val onContainerInt = Color.HSVToColor(floatArrayOf(hue, 0.90f, 0.30f)) + + pairs.add( + if (reversed) onContainerInt to containerInt + else containerInt to onContainerInt + ) + } + return pairs + } + + val colors = generatePairs() + generatePairs(reversed = true) + val colorsLength = colors.size + + + fun getProfilePicture(picture: String, ctx: Context, highRes: Boolean = false): Bitmap { + var picture = picture + if (picture.startsWith("content")) { + // Legacy: load from persisted uri + try { + val uri = picture.toUri() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(ctx.contentResolver, uri) + ImageDecoder.decodeBitmap(source) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap( + ctx.contentResolver, picture.toUri() + ) + } + } catch (_: Exception) { + picture = "0" + } + } else if ("profilePic.png" == picture) { + try { + ctx.openFileInput("profilePic.png").use { inputStream -> + return BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + Log.e(TAG, "Could not load profile pic", e) + picture = "0" + } + } + val index = picture.toIntOrNull() ?: 0 + val safeIndex = index % colors.size + val pictureSize = if (highRes) 256 else 128 + + val foreground = + ResourcesCompat.getDrawable(ctx.resources, R.drawable.ic_warpinator, null)!! + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + foreground.colorFilter = + BlendModeColorFilter(colors[safeIndex].second, BlendMode.SRC_IN) + } else { + foreground.colorFilter = android.graphics.PorterDuffColorFilter( + colors[safeIndex].second, android.graphics.PorterDuff.Mode.SRC_IN + ) + } + val bmp = createBitmap(pictureSize, pictureSize) + val canvas = Canvas(bmp) + val paint = Paint() + paint.color = colors[safeIndex].first + + // Fill background + canvas.drawRect(0f, 0f, pictureSize.toFloat(), pictureSize.toFloat(), paint) + val padding = if (highRes) 32 else 16 + foreground.setBounds(padding, padding, pictureSize - padding, pictureSize - padding) + foreground.draw(canvas) + return bmp + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/QRCodeBitmaps.kt b/app/src/main/java/slowscript/warpinator/core/utils/QRCodeBitmaps.kt new file mode 100644 index 00000000..e3491a5c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/QRCodeBitmaps.kt @@ -0,0 +1,50 @@ +package slowscript.warpinator.core.utils + +import android.graphics.Bitmap +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.WriterException +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class QRCodeBitmaps { + companion object { + /** + * Generates a QR code bitmap from the provided [text]. + * + * This function uses the ZXING library to encode the string into a 128x128 + * [Bitmap.Config.ALPHA_8] bitmap. The operation is performed on the + * [Dispatchers.Default] coroutine dispatcher. + * + * @param text The string content to be encoded into the QR code. + * @return A [Bitmap] representing the QR code, or `null` if a [WriterException] occurs. + */ + suspend fun generate(text: String): Bitmap? = withContext(Dispatchers.Default) { + val writer = QRCodeWriter() + try { + val hints = mapOf( + EncodeHintType.MARGIN to 0 + ) + val bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, 128, 128, hints) + val width = bitMatrix.width + val height = bitMatrix.height + val bmp = createBitmap(width, height, Bitmap.Config.ALPHA_8) + for (x in 0.. { + normalizedDisplayName + } + + normalizedUserName != null -> { + normalizedUserName + } + + normalizedHostname != null && normalizedAddress != null -> { + normalizedHostname + } + + else -> { + "Unknown" + } + } + + if (normalizedDisplayName != null) { + if (normalizedUserName != null) { + subtitle = when { + normalizedHostname != null -> { + label = normalizedAddress + "$normalizedUserName@$normalizedHostname" + } + + normalizedAddress != null -> { + "$normalizedUserName@$normalizedAddress" + } + + else -> { + normalizedUserName + } + } + } else { + subtitle = when { + normalizedHostname != null -> { + label = normalizedAddress + normalizedHostname + } + + normalizedAddress != null -> { + normalizedAddress + } + + else -> { + normalizedDisplayName + } + } + } + } else if (normalizedUserName != null) { + subtitle = when { + normalizedHostname != null -> { + label = normalizedAddress + normalizedHostname + } + + normalizedAddress != null -> { + normalizedAddress + } + + else -> { + normalizedUserName + } + } + } else { + subtitle = when { + normalizedHostname != null && normalizedAddress != null -> { + normalizedAddress + } + + normalizedHostname != null -> { + normalizedHostname + } + + normalizedAddress != null -> { + normalizedAddress + } + + else -> { + "" + } + } + } + + return RemoteDisplayInfo(title, subtitle, label) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/Utils.kt b/app/src/main/java/slowscript/warpinator/core/utils/Utils.kt new file mode 100644 index 00000000..f54eb320 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/Utils.kt @@ -0,0 +1,414 @@ +package slowscript.warpinator.core.utils + +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.net.wifi.WifiManager +import android.os.Build +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.provider.Settings +import android.text.TextUtils +import android.util.Log +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.google.common.net.InetAddresses +import com.google.common.primitives.Ints +import io.grpc.stub.StreamObserver +import slowscript.warpinator.WarpProto +import slowscript.warpinator.core.network.Server +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException +import java.net.URLDecoder +import java.net.UnknownHostException +import java.text.CharacterIterator +import java.text.StringCharacterIterator +import java.util.Collections +import java.util.Random +import kotlin.math.abs +import kotlin.math.sign + +object Utils { + private const val TAG = "Utils" + + fun getDeviceName(context: Context): String { + var name: String? = null + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) name = + Settings.Global.getString( + context.contentResolver, Settings.Global.DEVICE_NAME + ) + if (name == null) name = Settings.Secure.getString( + context.contentResolver, "bluetooth_name" + ) + } catch (_: Exception) { + } + if (name == null) { + Log.v( + TAG, "Could not get device name - using default" + ) + name = "Android Phone" + } + return name + } + + fun iPAddress(context: Context, networkInterface: String?): IPInfo? { + networkInterface ?: return null + try { + if (!networkInterface.isEmpty() && (networkInterface != Server.NETWORK_INTERFACE_AUTO)) { + val ia = getIPForIfaceName(networkInterface) + if (ia != null) return ia + else Log.d( + TAG, "Preferred network interface is unavailable, falling back to automatic" + ) + } + var ip: IPInfo? + //Works for most cases + ip = networkIP(context) + if (ip == null) ip = wifiIP(context) + //Try figuring out what interface wifi has, fallback to wlan0 - in case of hotspot + if (ip == null) ip = getIPForIfaceName(wifiInterface) + //Get IP of an active interface (except loopback and data) + if (ip == null) { + val activeNi: NetworkInterface? = activeIface + if (activeNi != null) ip = getIPForIface(activeNi) + } + Log.v( + TAG, + "Got IP: " + (if (ip == null) "null" else (ip.address.hostAddress + "/" + ip.prefixLength)) + ) + return ip + } catch (ex: Exception) { + Log.e( + TAG, "Couldn't get IP address", ex + ) + return null + } + } + + @SuppressLint("WifiManagerPotentialLeak") + fun wifiIP(context: Context): IPInfo? { + val wifiManager = + context.getSystemService(Context.WIFI_SERVICE) as WifiManager? ?: return null + val ip = wifiManager.connectionInfo.ipAddress + if (ip == 0) return null + return try { + // No way to get prefix length, guess 24 + IPInfo( + InetAddresses.fromLittleEndianByteArray( + Ints.toByteArray( + ip + ) + ) as Inet4Address, 24 + ) + } catch (_: UnknownHostException) { + null + } + } + + fun networkIP(context: Context): IPInfo? { + val connMgr = + checkNotNull(context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager) + val activeNetwork = connMgr.activeNetwork + val networkCaps = connMgr.getNetworkCapabilities(activeNetwork) + val properties = connMgr.getLinkProperties(activeNetwork) + if (properties != null && networkCaps != null && networkCaps.hasCapability( + NetworkCapabilities.NET_CAPABILITY_NOT_VPN + ) && (networkCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || networkCaps.hasTransport( + NetworkCapabilities.TRANSPORT_ETHERNET + )) + ) { + for (addr in properties.linkAddresses) if (addr.address is Inet4Address) return IPInfo( + (addr.address as Inet4Address?)!!, addr.prefixLength + ) + } + return null + } + + @get:Throws(SocketException::class) + val activeIface: NetworkInterface? + get() { + val nis: MutableList = + Collections.list(NetworkInterface.getNetworkInterfaces()) + //prioritize wlan(...) interfaces and deprioritize tun(...) which can be leftover from VPN apps + Collections.sort( + nis, Comparator sort@{ i1: NetworkInterface?, i2: NetworkInterface? -> + val i1Name = i1!!.displayName + val i2Name = i2!!.displayName + if (i1Name.contains("wlan") || i2Name.contains("tun")) { + return@sort -1 + } else if (i1Name.contains("tun") || i2Name.contains("wlan")) { + return@sort 1 + } + 0 + }) + + for (ni in nis) { + if ((!ni.isLoopback) && ni.isUp) { + val name = ni.displayName + if (name.contains("dummy") || name.contains("rmnet") || name.contains("ifb")) continue + if (getIPForIface(ni) == null) //Skip ifaces with no IPv4 address + continue + Log.d( + TAG, "Selected interface: " + ni.displayName + ) + return ni + } + } + return null + } + + val wifiInterface: String + get() { + var iface: String? = null + try { + val m = Class.forName("android.os.SystemProperties") + .getMethod("get", String::class.java) + iface = m.invoke(null, "wifi.interface") as String? + } catch (ignored: Throwable) { + } + if (iface == null || iface.isEmpty()) iface = "wlan0" + return iface + } + + val networkInterfaces: Array? + get() { + val nisNames = ArrayList() + try { + val nis = NetworkInterface.getNetworkInterfaces() + while (nis.hasMoreElements()) { + val ni = nis.nextElement() + if (ni.isUp) { + nisNames.add(ni.displayName) + } + } + } catch (e: SocketException) { + Log.e( + TAG, "Could not get network interfaces", e + ) + return null + } + return nisNames.toTypedArray() + } + + fun dumpInterfaces(): String? { + val nis: Array = networkInterfaces ?: return "Failed to get network interfaces" + return TextUtils.join("\n", nis) + } + + @Throws(SocketException::class) + fun getIPForIfaceName(ifaceName: String?): IPInfo? { + val nis = NetworkInterface.getNetworkInterfaces() + var ni: NetworkInterface + while (nis.hasMoreElements()) { + ni = nis.nextElement() + if (ni.displayName == ifaceName) { + return getIPForIface(ni) + } + } + return null + } + + fun getIPForIface(ni: NetworkInterface): IPInfo? { + for (ia in ni.interfaceAddresses) { + //filter for ipv4/ipv6 + if (ia.address.address.size == 4) { + //4 for ipv4, 16 for ipv6 + return IPInfo( + (ia.address as Inet4Address?)!!, ia.networkPrefixLength.toInt() + ) + } + } + return null + } + + @JvmStatic + fun isSameSubnet(a: InetAddress, b: InetAddress, prefix: Int): Boolean { + val aa = a.address + val ba = b.address + if (aa.size != ba.size) return false + var i = 0 + while (i < prefix) { + val bi = i / 8 + if (bi >= aa.size) break + val rem = prefix - i + if (rem >= 8) { + if (aa[bi] != ba[bi]) return false + } else { + val mask = ((0xFF shl (8 - rem)) and 0xFF).toByte() + if ((aa[bi].toInt() and mask.toInt()) != (ba[bi].toInt() and mask.toInt())) return false + } + i += 8 + } + return true + } + + @JvmStatic + fun certsDir(context: Context): File { + return File(context.cacheDir, "certs") + } + + @JvmStatic + @Throws(IOException::class) + fun readAllBytes(file: File?): ByteArray { + RandomAccessFile(file, "r").use { f -> + val b = ByteArray(f.length().toInt()) + f.readFully(b) + return b + } + } + + fun bytesToHumanReadable(bytes: Long): String { + val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else abs(bytes) + if (absB < 1024) { + return "$bytes B" + } + var value = absB + val ci: CharacterIterator = StringCharacterIterator("KMGTPE") + var i = 40 + while (i >= 0 && absB > 0xfffccccccccccccL shr i) { + value = value shr 10 + ci.next() + i -= 10 + } + value *= bytes.sign + return String.format("%.1f %cB", value / 1024.0, ci.current()) + } + + @SuppressLint("Range") + fun getNameFromUri(ctx: Context, uri: Uri): String? { + var result: String? = null + try { + if ("content" == uri.scheme) { + val cursor = ctx.contentResolver.query(uri, null, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + cursor.close() + } + } + if (result == null) { + val parts: Array = URLDecoder.decode(uri.toString()).split("/".toRegex()) + .dropLastWhile { it.isEmpty() }.toTypedArray() + return parts[parts.size - 1] + } + } catch (_: Exception) { + return null + } + return result + } + + + fun getChildUri(treeUri: Uri?, path: String?): Uri { + val rootID = DocumentsContract.getTreeDocumentId(treeUri) + val docID = "$rootID/$path" + return DocumentsContract.buildDocumentUriUsingTree(treeUri, docID) + } + + fun getChildFromTree(ctx: Context, treeUri: Uri?, path: String?): DocumentFile { + val childUri = getChildUri(treeUri, path) + return DocumentFile.fromSingleUri(ctx, childUri)!! + } + + //Just like DocumentFile.exists() but doesn't spam "Failed query" when file is not found + fun pathExistsInTree(ctx: Context, treeUri: Uri?, path: String?): Boolean { + val resolver = ctx.contentResolver + val u = getChildUri(treeUri, path) + val c: Cursor? + try { + c = resolver.query( + u, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null + ) + val found = c!!.count > 0 + c.close() + return found + } catch (_: Exception) { + } + return false + } + + fun isMyServiceRunning(ctx: Context, serviceClass: Class<*>): Boolean { + val manager = + checkNotNull(ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) + for (service in manager.getRunningServices(Int.MAX_VALUE)) { + if (serviceClass.name == service.service.className) { + return true + } + } + return false + } + + fun isConnectedToWiFiOrEthernet(ctx: Context): Boolean { + val connManager = + checkNotNull(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager) + val wifi = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI) + val ethernet = connManager.getNetworkInfo(ConnectivityManager.TYPE_ETHERNET) + return (wifi != null && wifi.isConnected) || (ethernet != null && ethernet.isConnected) + } + + fun isHotspotOn(ctx: Context): Boolean { + val manager = checkNotNull( + ctx.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + ) + try { + val method = manager.javaClass.getDeclaredMethod("isWifiApEnabled") + method.isAccessible = true //in the case of visibility change in future APIs + return (method.invoke(manager) as Boolean?)!! + } catch (e: Exception) { + Log.e(TAG, "Failed to get hotspot state", e) + } + + return false + } + + @JvmStatic + fun generateServiceName(context: Context): String { + return getDeviceName(context).uppercase().replace(" ", "") + "-" + getRandomHexString(6) + } + + fun getRandomHexString(len: Int): String { + val buf = CharArray(len) + val random = Random() + for (idx in buf.indices) buf[idx] = HEX_ARRAY[random.nextInt(HEX_ARRAY.size)] + return String(buf) + } + + fun openUrl(context: Context, url: String?) { + val intent = Intent(Intent.ACTION_VIEW, url?.toUri()) + context.startActivity(intent) + } + + //FOR DEBUG PURPOSES + private val HEX_ARRAY = "0123456789ABCDEF".toCharArray() + fun bytesToHex(bytes: ByteArray): String { + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = HEX_ARRAY[v ushr 4] + hexChars[j * 2 + 1] = HEX_ARRAY[v and 0x0F] + } + return String(hexChars) + } + + class IPInfo internal constructor( + @JvmField var address: Inet4Address, @JvmField var prefixLength: Int + ) + + class VoidObserver : StreamObserver { + override fun onNext(value: WarpProto.VoidType?) {} + override fun onError(t: Throwable?) { + Log.e(TAG, "Call failed with exception", t) + } + + override fun onCompleted() {} + } +} diff --git a/app/src/main/java/slowscript/warpinator/ZlibCompressor.java b/app/src/main/java/slowscript/warpinator/core/utils/ZlibCompressor.java similarity index 81% rename from app/src/main/java/slowscript/warpinator/ZlibCompressor.java rename to app/src/main/java/slowscript/warpinator/core/utils/ZlibCompressor.java index a726e8a3..b3b9fddd 100644 --- a/app/src/main/java/slowscript/warpinator/ZlibCompressor.java +++ b/app/src/main/java/slowscript/warpinator/core/utils/ZlibCompressor.java @@ -1,4 +1,4 @@ -package slowscript.warpinator; +package slowscript.warpinator.core.utils; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -8,18 +8,15 @@ public class ZlibCompressor { private static final int BUFFER_SIZE = 1024; - public static byte[] compress(final byte[] input, int length, int level) throws IOException - { + public static byte[] compress(final byte[] input, int length, int level) throws IOException { final Deflater deflater = new Deflater(); deflater.setLevel(level); deflater.setInput(input, 0, length); deflater.finish(); - try (final ByteArrayOutputStream output = new ByteArrayOutputStream(length)) - { + try (final ByteArrayOutputStream output = new ByteArrayOutputStream(length)) { final byte[] buffer = new byte[1024]; - while (!deflater.finished()) - { + while (!deflater.finished()) { final int count = deflater.deflate(buffer); output.write(buffer, 0, count); } @@ -28,16 +25,13 @@ public static byte[] compress(final byte[] input, int length, int level) throws } } - public static byte[] decompress(final byte[] input) throws Exception - { + public static byte[] decompress(final byte[] input) throws Exception { final Inflater inflater = new Inflater(); inflater.setInput(input); - try (final ByteArrayOutputStream output = new ByteArrayOutputStream(input.length)) - { + try (final ByteArrayOutputStream output = new ByteArrayOutputStream(input.length)) { byte[] buffer = new byte[BUFFER_SIZE]; - while (!inflater.finished()) - { + while (!inflater.finished()) { final int count = inflater.inflate(buffer); output.write(buffer, 0, count); } diff --git a/app/src/main/java/slowscript/warpinator/core/utils/messages/FailedToWriteLog.kt b/app/src/main/java/slowscript/warpinator/core/utils/messages/FailedToWriteLog.kt new file mode 100644 index 00000000..e8ebeaaf --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/messages/FailedToWriteLog.kt @@ -0,0 +1,21 @@ +package slowscript.warpinator.core.utils.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToWriteLog(val exception: Exception) : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource( + R.string.could_not_save_log_to_file_message, + exception.message ?: stringResource(R.string.unknown_error), + ), + duration = SnackbarDuration.Long, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/messages/SucceededToWriteLog.kt b/app/src/main/java/slowscript/warpinator/core/utils/messages/SucceededToWriteLog.kt new file mode 100644 index 00000000..8e53d94b --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/messages/SucceededToWriteLog.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.core.utils.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class SucceededToWriteLog : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.dumped_log_file_to_selected_destination), + duration = SnackbarDuration.Short, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/transformers/IPAddressTransformer.kt b/app/src/main/java/slowscript/warpinator/core/utils/transformers/IPAddressTransformer.kt new file mode 100644 index 00000000..fe98f435 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/transformers/IPAddressTransformer.kt @@ -0,0 +1,34 @@ +package slowscript.warpinator.core.utils.transformers + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +class IPAddressTransformer( + private val punctuationColor: Color +) : OutputTransformation { + val spanStyle = SpanStyle( + color = punctuationColor, fontWeight = FontWeight.Companion.Bold, + // Increased spacing makes the dots breathe more + letterSpacing = 8.sp + ) + + override fun TextFieldBuffer.transformOutput() { + for ((i, c) in asCharSequence().withIndex()) { + if (c == '.' || c == ':') { + // Visual replacement + if (c == '.') { + replace(i, i + 1, "•") + } + + // Styling + addStyle( + spanStyle, i, i + 1 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/core/utils/transformers/WarpinatorProtocolValidator.kt b/app/src/main/java/slowscript/warpinator/core/utils/transformers/WarpinatorProtocolValidator.kt new file mode 100644 index 00000000..767e255e --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/core/utils/transformers/WarpinatorProtocolValidator.kt @@ -0,0 +1,78 @@ +package slowscript.warpinator.core.utils.transformers + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.delete + +private const val WARPINATOR_SCHEME = "warpinator://" + + +/** + * A validator utility class that can also transform input to be valid in a text box using InputTransformation + * */ +class ProtocolAddressInputValidator : InputTransformation { + + @OptIn(ExperimentalFoundationApi::class) + override fun TextFieldBuffer.transformInput() { + // In case of pasting the link from another device remove the protocol prefix + + if (this.changes.changeCount == 1) { + val text = asCharSequence() + if (text.startsWith(WARPINATOR_SCHEME)) { + this.delete(0, WARPINATOR_SCHEME.length) + } + } + + val text = asCharSequence().toString() + if (!text.all { it.isDigit() || it == '.' || it == ':' }) { + revertAllChanges() + } + if (!isValidIp(text)) { + revertAllChanges() + } + } + + companion object { + val scheme: String + get() = WARPINATOR_SCHEME + + fun isValidIp(text: String, allowPartial: Boolean = true): Boolean { + // Prevent starting with a dot or colon + if (text.startsWith(".") || text.startsWith(":")) return false + + // Handle Port separation (IP:PORT) + val parts = text.split(":") + if (parts.size > 2) return false // Only one colon allowed + + val ipPart = parts[0] + val portPart = if (parts.size > 1) parts[1] else null + + val segments = ipPart.split(".") + if (segments.size > 4) return false // Max 4 segments (x.x.x.x) + + for (segment in segments) { + if (segment.isEmpty()) continue + + if (segment.length > 3) return false + + val value = segment.toIntOrNull() ?: return false + if (value > 255) return false + } + + // Validate Port Part + if (portPart != null) { + if (portPart.isEmpty() && allowPartial) return true + val port = portPart.toIntOrNull() ?: return false + if (port > 65535) return false + } + + return allowPartial || (segments.size == 4 && portPart != null) + } + + fun getFullAddressUrl(address: String): String { + return "$WARPINATOR_SCHEME$address" + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/about/AboutScreen.kt b/app/src/main/java/slowscript/warpinator/feature/about/AboutScreen.kt new file mode 100644 index 00000000..38c35c0b --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/about/AboutScreen.kt @@ -0,0 +1,285 @@ +package slowscript.warpinator.feature.about + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Gavel +import androidx.compose.material.icons.rounded.RateReview +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import slowscript.warpinator.BuildConfig +import slowscript.warpinator.R +import slowscript.warpinator.app.LocalNavController +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.utils.Utils + +private const val TRANSLATE_URL = "https://hosted.weblate.org/engage/warpinator-android/" +private const val GOOGLE_PLAY_URL = + "https://play.google.com/store/apps/details?id=slowscript.warpinator" +private const val SOURCE_URL = "https://github.com/slowscript/warpinator-android" +private const val ISSUES_URL = "$SOURCE_URL/issues" +private const val LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html" + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AboutScreen() { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val navController = LocalNavController.current + val context = LocalContext.current + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumFlexibleTopAppBar( + title = { Text(stringResource(R.string.about_title)) }, + navigationIcon = { + IconButton(onClick = { navController?.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + LazyColumn( + contentPadding = innerPadding.plus( + PaddingValues(top = 16.dp, start = 16.dp, end = 16.dp) + ), + ) { + + item { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = MaterialShapes.Cookie9Sided.toShape(), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Image( + painter = painterResource(R.drawable.ic_warpinator), + contentDescription = null, + modifier = Modifier + .padding(16.dp) + .size(48.dp), + colorFilter = ColorFilter.tint( + MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + + } + Spacer(Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = stringResource( + id = R.string.version, BuildConfig.VERSION_NAME + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + } + + item { + SegmentedListItem( + onClick = { + Utils.openUrl( + context, TRANSLATE_URL, + ) + }, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 3), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + content = { + Text(stringResource(R.string.help_translate_title)) + }, + supportingContent = { + Text(stringResource(R.string.help_translate_subtitle)) + }, + leadingContent = { + Icon( + Icons.Rounded.Translate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon(Icons.AutoMirrored.Rounded.OpenInNew, contentDescription = null) + }, + ) + Spacer(Modifier.height(ListItemDefaults.SegmentedGap)) + SegmentedListItem( + onClick = { + Utils.openUrl( + context, + GOOGLE_PLAY_URL, + ) + }, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + content = { + Text(stringResource(R.string.rate_on_google_play_title)) + }, + supportingContent = { + Text(stringResource(R.string.rate_on_google_play_subtitle)) + }, + leadingContent = { + Icon( + Icons.Rounded.RateReview, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + Spacer(Modifier.height(ListItemDefaults.SegmentedGap)) + SegmentedListItem( + onClick = { + Utils.openUrl(context, SOURCE_URL) + }, + shapes = ListItemDefaults.segmentedDynamicShapes(2, 3), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + content = { + Text(stringResource(R.string.see_the_source_code_title)) + }, + supportingContent = { + Text(stringResource(R.string.contributions_are_welcome_subtitle)) + }, + leadingContent = { + Icon( + Icons.Rounded.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + Spacer(Modifier.height(16.dp)) + } + + item { + SegmentedListItem( + onClick = { + Utils.openUrl( + context, ISSUES_URL, + ) + }, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 2), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + content = { + Text(stringResource(R.string.issues_title)) + }, + supportingContent = { + Text(stringResource(R.string.issues_subtitle)) + }, + leadingContent = { + Icon( + Icons.Rounded.BugReport, + contentDescription = null, + ) + }, + ) + Spacer(Modifier.height(ListItemDefaults.SegmentedGap)) + SegmentedListItem( + onClick = { + Utils.openUrl(context, LICENSE_URL) + // TODO: add a license screen to show all OSS licenses of dependencies and the project + }, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 2), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + content = { + Text(stringResource(R.string.license_title)) + }, + supportingContent = { + Text(stringResource(R.string.license_type)) + }, + leadingContent = { + Icon( + Icons.Rounded.Gavel, + contentDescription = null, + ) + }, + ) + } + + item { + Text( + text = stringResource(R.string.warranty), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Justify, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 24.dp) + ) + } + } + } +} + +@Composable +@PreviewDynamicColors +@PreviewLightDark +fun AboutScreenPreview() { + WarpinatorTheme { + AboutScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/HomeScreen.kt b/app/src/main/java/slowscript/warpinator/feature/home/HomeScreen.kt new file mode 100644 index 00000000..3920191c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/HomeScreen.kt @@ -0,0 +1,206 @@ +package slowscript.warpinator.feature.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import slowscript.warpinator.core.data.WarpinatorViewModel +import slowscript.warpinator.core.model.ui.RemoteRoute +import slowscript.warpinator.feature.home.panes.MessagesPane +import slowscript.warpinator.feature.home.panes.RemoteListPane +import slowscript.warpinator.feature.home.panes.TransfersPane + +@OptIn( + ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, +) +@Composable +fun HomeScreen( + viewModel: WarpinatorViewModel = hiltViewModel(), + remoteTarget: Pair? = null, + onRemoteTargetConsumed: () -> Unit = {}, +) { + // The Navigator controls the three panes logic automatically + val navigator = rememberListDetailPaneScaffoldNavigator( + scaffoldDirective = calculatePaneScaffoldDirective( + currentWindowAdaptiveInfo(), + ).copy( + // This removes the gap between the panes + horizontalPartitionSpacerSize = 0.dp, + ), + ) + val scope = rememberCoroutineScope() + + LaunchedEffect(remoteTarget) { + if (remoteTarget != null) { + val (uuid, openMessages) = remoteTarget + + navigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + RemoteRoute(uuid), + ) + + if (openMessages && !viewModel.integrateMessages) { + navigator.navigateTo( + ListDetailPaneScaffoldRole.Extra, + RemoteRoute(uuid), + ) + } + + // TODO(raresvanca): find a way to navigate without animations here + + onRemoteTargetConsumed() + } + } + + // If structure changes (List -> Detail), allow back. If content changes in Detail, don't pop. + val backBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange + + val scaffoldValue = navigator.scaffoldValue + val isPrimaryExpanded = scaffoldValue.primary == PaneAdaptedValue.Expanded + val isSecondaryExpanded = scaffoldValue.secondary == PaneAdaptedValue.Expanded + val isTertiaryExpanded = scaffoldValue.tertiary == PaneAdaptedValue.Expanded + + val secondaryPaneMode = isPrimaryExpanded && isSecondaryExpanded + val tertiaryPaneMode = isPrimaryExpanded && isTertiaryExpanded + + // Consumed insets + val listPaneCI = if (isSecondaryExpanded || isTertiaryExpanded) { + WindowInsets.safeDrawing.only(WindowInsetsSides.End) + } else WindowInsets(0) + + val detailPaneCI = when { + secondaryPaneMode && isTertiaryExpanded -> WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + secondaryPaneMode -> WindowInsets.safeDrawing.only(WindowInsetsSides.Start) + !secondaryPaneMode && isTertiaryExpanded -> WindowInsets.safeDrawing.only(WindowInsetsSides.End) + else -> WindowInsets(0) + } + + val extraPaneCI = if (tertiaryPaneMode) { + WindowInsets.safeDrawing.only(WindowInsetsSides.Start) + } else WindowInsets(0) + + Surface(color = MaterialTheme.colorScheme.surface) { + NavigableListDetailPaneScaffold( + navigator = navigator, + defaultBackBehavior = backBehavior, + listPane = { + AnimatedPane( + modifier = Modifier + .fillMaxHeight() + .consumeWindowInsets(listPaneCI) + .semantics { + paneTitle = "Devices list" + }, + ) { + RemoteListPane( + onRemoteClick = { remote -> + scope.launch { + navigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + RemoteRoute(remote.uuid), + ) + } + }, + paneMode = secondaryPaneMode, + onFavoriteToggle = { remote -> viewModel.toggleFavorite(remote.uuid) }, + ) + + } + }, + detailPane = { + AnimatedPane( + Modifier + .consumeWindowInsets(detailPaneCI) + .semantics { + paneTitle = "Transfers list" + }, + ) { + val selectedUuid = navigator.currentDestination?.contentKey?.uuid + + val selectedRemote by viewModel.getRemote(selectedUuid) + .collectAsStateWithLifecycle(initialValue = null) + + if (selectedRemote != null) { + TransfersPane( + remote = selectedRemote!!, + paneMode = secondaryPaneMode, + onBack = { scope.launch { navigator.navigateBack(backBehavior) } }, + onFavoriteToggle = { remote -> viewModel.toggleFavorite(selectedRemote!!.uuid) }, + onOpenMessagesPane = { + if (!viewModel.integrateMessages) scope.launch { + navigator.navigateTo( + ListDetailPaneScaffoldRole.Extra, + RemoteRoute(selectedRemote!!.uuid), + ) + } + }, + ) + } else { + // Placeholder for Tablet Landscape when no device is selected + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "Please select a device", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + }, + extraPane = extraPane@{ + if (viewModel.integrateMessages) return@extraPane + + val selectedUuid = navigator.currentDestination?.contentKey?.uuid + val selectedRemote by viewModel.getRemote(selectedUuid) + .collectAsStateWithLifecycle(initialValue = null) + + if (selectedRemote?.supportsTextMessages != true) return@extraPane + + AnimatedPane( + Modifier + .consumeWindowInsets(extraPaneCI) + .semantics { + paneTitle = "Messages history" + }, + ) { + MessagesPane( + remote = selectedRemote!!, + paneMode = tertiaryPaneMode, + onBack = { scope.launch { navigator.navigateBack(backBehavior) } }, + ) + } + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/HomeMenu.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/HomeMenu.kt new file mode 100644 index 00000000..8a29b260 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/HomeMenu.kt @@ -0,0 +1,170 @@ +package slowscript.warpinator.feature.home.components + +import android.app.Activity +import android.net.Uri +import android.view.KeyEvent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.rounded.AddLink +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.WifiTethering +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import slowscript.warpinator.R +import slowscript.warpinator.app.LocalNavController +import slowscript.warpinator.core.design.components.MenuAction +import slowscript.warpinator.core.design.components.MenuGroup +import slowscript.warpinator.core.design.components.MenuGroupsPopup +import slowscript.warpinator.core.design.components.TooltipIconButton +import slowscript.warpinator.feature.home.panes.CONNECTION_ISSUES_HELP_URL + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun HomeMenu( + initiallyExpanded: Boolean = false, + onManualConnectionClick: () -> Unit, + onRescan: () -> Unit, + onReannounce: () -> Unit, + onSaveLog: (uri: Uri) -> Unit, +) { + val uriHandler = LocalUriHandler.current + + val saveLocationPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument( + mimeType = "text/plain", + ), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + onSaveLog(uri) + } + + var menuOpen by rememberSaveable { mutableStateOf(initiallyExpanded) } + val groupInteractionSource = remember { MutableInteractionSource() } + + val navController = LocalNavController.current + val context = LocalContext.current + + val menuGroups = listOf( + MenuGroup( + listOf( + MenuAction( + stringResource(R.string.manual_connection_label), + trailingIcon = Icons.Rounded.AddLink, + onClick = onManualConnectionClick, + shortcutKeyCode = KeyEvent.KEYCODE_K, + shortcutKeyCtrl = true, + ), + MenuAction( + stringResource(R.string.reannounce_label), + trailingIcon = Icons.Rounded.WifiTethering, + onClick = onReannounce, + shortcutKeyCode = KeyEvent.KEYCODE_R, + shortcutKeyCtrl = true, + shortcutKeyAlt = true, + ), + MenuAction( + stringResource(R.string.rescan_label), + trailingIcon = Icons.Rounded.Refresh, + onClick = onRescan, + shortcutKeyCode = KeyEvent.KEYCODE_R, + shortcutKeyCtrl = true, + ), + MenuAction( + stringResource(R.string.connection_issues_label), + trailingIcon = Icons.AutoMirrored.Rounded.MenuBook, + onClick = { + uriHandler.openUri(CONNECTION_ISSUES_HELP_URL) + }, + shortcutKeyCode = KeyEvent.KEYCODE_F1, + ), + ), + ), + MenuGroup( + listOf( + MenuAction( + stringResource(R.string.settings_title), + trailingIcon = Icons.Rounded.Settings, + onClick = { navController?.navigate("settings") }, + ), + MenuAction( + stringResource(R.string.save_log_label), trailingIcon = Icons.Rounded.Archive, + onClick = { + saveLocationPicker.launch("warpinator-log.txt") + }, + ), + MenuAction( + stringResource(R.string.about_title), + trailingIcon = Icons.Outlined.Info, + onClick = { navController?.navigate("about") }, + ), + ), + ), + MenuGroup( + listOf( + MenuAction( + stringResource(R.string.quit_label), + trailingIcon = Icons.Rounded.Close, + onClick = { + (context as? Activity)?.finish() + }, + ), + ), + ), + ) + + Box( + modifier = Modifier.wrapContentSize(Alignment.TopEnd), + ) { + TooltipIconButton( + description = stringResource(R.string.open_menu_label), + icon = Icons.Rounded.MoreVert, + onClick = { menuOpen = true }, + ) + MenuGroupsPopup( + menuGroups = menuGroups, + groupInteractionSource = groupInteractionSource, + menuOpen = menuOpen, + onDismiss = { menuOpen = false }, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun HomeMenuPreview() { + Scaffold { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + HomeMenu( + true, + onRescan = {}, + onManualConnectionClick = {}, + onSaveLog = {}, + onReannounce = {}, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/MessageBubble.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/MessageBubble.kt new file mode 100644 index 00000000..888b8a2c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/MessageBubble.kt @@ -0,0 +1,267 @@ +package slowscript.warpinator.feature.home.components + +import android.content.ClipData +import android.content.Intent +import android.text.format.DateFormat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import kotlinx.coroutines.launch +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.MenuAction +import slowscript.warpinator.core.design.components.MenuGroup +import slowscript.warpinator.core.design.components.MenuGroupsPopup +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.utils.rememberAnnotatedLinkText +import java.util.Date + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MessageBubble(message: Message, onDeleteMessage: () -> Unit = {}) { + val isSent = message.direction == Transfer.Direction.Send + val context = LocalContext.current + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + + val interactionSource = remember { MutableInteractionSource() } + + var showMenu by remember { mutableStateOf(false) } + var showTimestamp by remember { mutableStateOf(false) } + + val backgroundColor = + if (isSent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest + val textColor = + if (isSent) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + val accentColor = + if (isSent) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.primary + val overlayColor = if (isSent) MaterialTheme.colorScheme.surfaceContainerLowest else textColor + val bubbleShape = if (isSent) { + RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) + } else { + RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp) + } + + + val timeString = remember(message.timestamp) { + DateFormat.getTimeFormat(context).format(Date(message.timestamp)) + } + + val annotatedMessage = rememberAnnotatedLinkText(message.text, accentColor) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + + if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { + showMenu = true + event.changes.forEach { it.consume() } + } + } + } + } + .combinedClickable( + onClick = { showTimestamp = !showTimestamp }, + onLongClick = { showMenu = true }, + onClickLabel = if (showTimestamp) stringResource(R.string.hide_timestamp_label) else stringResource( + R.string.show_timestamp_label, + ), + onLongClickLabel = stringResource(R.string.open_message_options_label), + // Don't show the ripple over the padded container. This allows the user to tap next + // to message, while looking like they're tapping the bubble. + indication = null, + interactionSource = interactionSource, + ) + .semantics(mergeDescendants = true) { + liveRegion = LiveRegionMode.Polite + }, + contentAlignment = if (isSent) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Surface( + color = backgroundColor, + modifier = Modifier + .widthIn(max = 360.dp) + .padding( + if (isSent) PaddingValues( + start = 48.dp, + end = 16.dp, + ) else PaddingValues(end = 48.dp, start = 16.dp), + ) + .clip(bubbleShape) + .indication( + interactionSource, + ripple( + color = overlayColor, + ), + ), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + val messageContentDescription = if (isSent) stringResource( + R.string.sent_message_content_description, + message.text, + ) else stringResource(R.string.received_message_content_description, message.text) + + val timestampContentDescription = if (isSent) stringResource( + R.string.sent_at_content_description, + timeString, + ) else stringResource(R.string.received_at_content_description, timeString) + + Text( + text = annotatedMessage, + style = MaterialTheme.typography.bodyLarge.copy(color = textColor), + modifier = Modifier.semantics { + contentDescription = messageContentDescription + }, + ) + AnimatedVisibility( + visible = showTimestamp, + modifier = Modifier.align(Alignment.End), + ) { + Text( + text = timeString, + style = MaterialTheme.typography.labelSmall, + color = textColor.copy(alpha = 0.7f), + modifier = Modifier + .padding(top = 4.dp) + .semantics { + contentDescription = timestampContentDescription + }, + ) + } + } + } + + Box { + MenuGroupsPopup( + menuOpen = showMenu, + onDismiss = { showMenu = false }, + offset = DpOffset(if (isSent) (-24).dp else 24.dp, 0.dp), + properties = PopupProperties( + // Allow the keyboard to remain active while the popup is opened + focusable = false, + dismissOnClickOutside = true, + ), + minWidth = 160.dp, + menuGroups = listOf( + MenuGroup( + listOf( + MenuAction( + title = stringResource(R.string.copy_label), + leadingIcon = Icons.Rounded.ContentCopy, + onClick = { + coroutineScope.launch { + val clipData = + ClipData.newPlainText("Message", message.text) + clipboard.setClipEntry(clipData.toClipEntry()) + } + showMenu = false + }, + ), + MenuAction( + title = stringResource(R.string.share_label), + leadingIcon = Icons.Rounded.Share, + onClick = { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, message.text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + showMenu = false + }, + ), + ), + ), + MenuGroup( + listOf( + MenuAction( + title = stringResource(R.string.delete_label), + leadingIcon = Icons.Rounded.Delete, + onClick = onDeleteMessage, + ), + ), + true, + ), + ), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MessageBubbleSentPreview() { + WarpinatorTheme { + MessageBubble( + message = Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = System.currentTimeMillis(), + text = "This is a sent message with a link: https://www.google.com", + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MessageBubbleReceivedPreview() { + WarpinatorTheme { + MessageBubble( + message = Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = System.currentTimeMillis(), + text = "This is a received message with a link: https://www.google.com", + ), + ) + } +} diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/MessageListItem.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/MessageListItem.kt new file mode 100644 index 00000000..b7c0ebee --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/MessageListItem.kt @@ -0,0 +1,384 @@ +package slowscript.warpinator.feature.home.components + +import android.content.ClipData +import android.content.Intent +import android.text.format.DateFormat +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.ExpandableSegmentedListItem +import slowscript.warpinator.core.design.components.TooltipIconButton +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.utils.rememberAnnotatedLinkText +import java.util.Date +import kotlin.math.abs + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MessageListItem( + message: Message, + expanded: Boolean, + onExpandRequest: () -> Unit, + // Callbacks for actions + onClear: (Message) -> Unit = {}, + itemIndex: Int, + itemListCount: Int, +) { + val swipeToDismissState = rememberSwipeToDismissBoxState() + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val clipboard = LocalClipboard.current + val haptics = LocalHapticFeedback.current + + val isSending = message.direction == Transfer.Direction.Send + + val timeString = remember(message.timestamp) { + DateFormat.getTimeFormat(context).format(Date(message.timestamp)) + } + + val accentColor = MaterialTheme.colorScheme.primary + + val annotatedMessage = rememberAnnotatedLinkText(message.text, accentColor) + + val onCopy: () -> Unit = { + coroutineScope.launch { + val clipData = ClipData.newPlainText("Message", message.text) + clipboard.setClipEntry(clipData.toClipEntry()) + } + } + + val onShare = { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, message.text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } + + val onDelete = { + coroutineScope.launch { + haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) + onClear(message) + } + } + + val semanticCustomActions = listOf( + CustomAccessibilityAction(stringResource(R.string.delete_message_action)) { + onDelete() + true + }, + CustomAccessibilityAction(stringResource(R.string.copy_message_action)) { + onCopy() + true + }, + CustomAccessibilityAction(stringResource(R.string.share_message_action)) { + onShare() + true + }, + ) + + val tileInteractionSource = remember { MutableInteractionSource() } + val clearInteractionSource = remember { MutableInteractionSource() } + + val isTileHovered by tileInteractionSource.collectIsHoveredAsState() + val isTileFocused by tileInteractionSource.collectIsFocusedAsState() + val isButtonHovered by clearInteractionSource.collectIsHoveredAsState() + val isButtonPressed by clearInteractionSource.collectIsPressedAsState() + + val showClearAction = isTileHovered || isTileFocused || isButtonHovered || isButtonPressed + + SwipeToDismissBox( + state = swipeToDismissState, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = !expanded, + backgroundContent = { + DismissBackground(swipeToDismissState) + }, + onDismiss = { onDelete() }, + content = { + ExpandableSegmentedListItem( + isExpanded = expanded, + toggleExpand = onExpandRequest, + content = { + Text( + text = message.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + }, + leadingContent = { + Icon( + imageVector = if (isSending) Icons.Rounded.Upload else Icons.Rounded.Download, + contentDescription = if (isSending) stringResource(R.string.transfer_direction_send_short) else stringResource( + R.string.transfer_direction_receive_short, + ), + ) + }, + trailingContent = { + Row { + if (showClearAction) { + TooltipIconButton( + onClick = { onDelete() }, + icon = Icons.Rounded.Delete, + description = stringResource(R.string.delete_message_action), + interactionSource = clearInteractionSource, + ) + } + TooltipIconButton( + onClick = onCopy, + icon = Icons.Rounded.ContentCopy, + description = stringResource(R.string.copy_label), + ) + } + }, + subItemBuilder = { subItemIndex, containerColor, shape -> + when (subItemIndex) { + 0 -> Surface( + color = containerColor, + shape = shape, + ) { + SelectionContainer { + Text( + annotatedMessage, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) + } + } + + 1 -> Surface( + color = containerColor, + shape = shape, + ) { + Column { + ListItem( + headlineContent = { + Text( + if (isSending) stringResource(R.string.sent_message_state) else stringResource( + R.string.received_message_state, + ), + ) + }, + supportingContent = { Text(timeString) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + + 2 -> Surface( + color = containerColor, + shape = shape, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + Button( + onClick = onCopy, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.copy_label)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = onShare, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ), + ) { + Icon( + Icons.Rounded.Share, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.share_label)) + } + + } + } + } + }, + subItemCount = 3, + itemIndex = itemIndex, + listItemCount = itemListCount, + listItemModifier = Modifier.semantics { + customActions = semanticCustomActions + }, + interactionSource = tileInteractionSource, + ) + }, + ) +} + +@Composable +private fun DismissBackground( + state: SwipeToDismissBoxState, +) { + val density = LocalDensity.current + val offsetInDp = try { + val offset = abs(state.requireOffset()) + with(density) { + offset.toDp() + } + } catch (_: IllegalStateException) { + 0f.dp + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxHeight() + .width(offsetInDp), + ) { + Box( + contentAlignment = Alignment.CenterEnd, + + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(align = Alignment.End, unbounded = true), + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier + .padding(end = 24.dp) + .requiredSize(24.dp), + ) + + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@PreviewLightDark +@Composable +fun MessageListItemPreview() { + var expandedId by remember { mutableStateOf(15) } + + val messages = listOf( + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 14, + text = "I finished it last night. That ending was wild!", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 15, + text = "I told you! We definitely need to talk about it over coffee.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 16, + text = "https://somerandomcaffe.com", + ), + ) + + WarpinatorTheme { + Scaffold { paddingValues -> + LazyColumn( + contentPadding = paddingValues, + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + itemsIndexed(messages) { index, message -> + MessageListItem( + message = message, + expanded = message.timestamp == expandedId, + onExpandRequest = { + expandedId = + if (message.timestamp == expandedId) null else message.timestamp + }, + itemIndex = index, + itemListCount = messages.size, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/RemoteLargeFlexibleTopAppBar.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/RemoteLargeFlexibleTopAppBar.kt new file mode 100644 index 00000000..fc7d577d --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/RemoteLargeFlexibleTopAppBar.kt @@ -0,0 +1,162 @@ +package slowscript.warpinator.feature.home.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TwoRowsTopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.DynamicAvatarCircle +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.utils.RemoteDisplayInfo +import java.net.InetAddress + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RemoteLargeFlexibleTopAppBar( + remote: Remote, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null, + containerColor: Color = Color.Unspecified, + elevatedContainerColor: Color = Color.Unspecified, + isFavoriteOverride: Boolean? = null, +) { + val isFavorite = isFavoriteOverride ?: remote.isFavorite + + val titleFormat = RemoteDisplayInfo.fromRemote(remote) + + val favoriteString = stringResource(R.string.favorite_label) + val ipAddressString = + stringResource(R.string.remote_ip_content_description, titleFormat.label ?: "") + + val semanticContentDescription = remember(titleFormat) { + buildString { + if (isFavorite) append(favoriteString, " ") + append(titleFormat.title, " ") + append(titleFormat.subtitle, ". ") + if (titleFormat.label != null) append(ipAddressString) + } + } + + TwoRowsTopAppBar( + title = { expanded -> + if (expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(0.dp, 12.dp) + .clearAndSetSemantics { + contentDescription = semanticContentDescription + }, + ) { + DynamicAvatarCircle( + bitmap = remote.picture, + isFavorite = isFavorite, + size = 64.dp, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + Text( + titleFormat.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + ) + Text( + titleFormat.subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + titleFormat.label?.let { + Text( + it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clearAndSetSemantics { + contentDescription = semanticContentDescription + }, + ) { + DynamicAvatarCircle(bitmap = remote.picture, isFavorite = isFavorite) + Text(titleFormat.title, modifier = Modifier.padding(8.dp)) + } + }, + navigationIcon = navigationIcon, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = elevatedContainerColor, + ), + scrollBehavior = scrollBehavior, + modifier = modifier, + collapsedHeight = TopAppBarDefaults.LargeAppBarCollapsedHeight, + expandedHeight = TopAppBarDefaults.LargeFlexibleAppBarWithSubtitleExpandedHeight, + windowInsets = TopAppBarDefaults.windowInsets, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +fun RemoteLargeFlexibleTopAppBarPreview() { + val remote = Remote( + uuid = "", + displayName = "Test Device", + userName = "user", + hostname = "hostname", + address = InetAddress.getLocalHost(), + status = Remote.RemoteStatus.Connected, + isFavorite = false, + ) + + WarpinatorTheme { + RemoteLargeFlexibleTopAppBar( + remote = remote, isFavoriteOverride = false, + navigationIcon = { + IconButton(onClick = {}) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, "", + ) + } + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/RemoteListItem.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/RemoteListItem.kt new file mode 100644 index 00000000..ca65f175 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/RemoteListItem.kt @@ -0,0 +1,329 @@ +package slowscript.warpinator.feature.home.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.DynamicAvatarCircle +import slowscript.warpinator.core.design.components.TooltipIconButton +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Remote.RemoteStatus +import slowscript.warpinator.core.utils.RemoteDisplayInfo +import kotlin.math.abs + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RemoteListItem( + remote: Remote, + onFavoriteToggle: () -> Unit, onClick: () -> Unit, + index: Int, itemCount: Int, +) { + val isFavorite = remote.isFavorite + val status = remote.status + + val isError = status is RemoteStatus.Error + val isConnecting = status == RemoteStatus.Connecting || status == RemoteStatus.AwaitingDuplex + val isDisconnected = status == RemoteStatus.Disconnected + + val swipeToDismissState = rememberSwipeToDismissBoxState() + val coroutineScope = rememberCoroutineScope() + + val displayInfo = remember(remote) { RemoteDisplayInfo.fromRemote(remote) } + val haptics = LocalHapticFeedback.current + + val favoriteLabel = stringResource(R.string.favorite_label) + val connectionStateLabel = when { + isError -> stringResource(R.string.connection_error) + isConnecting -> stringResource(R.string.remote_connecting) + isDisconnected -> stringResource(R.string.remote_disconnected) + else -> stringResource(R.string.remote_connected) + } + + val tileInteractionSource = remember { MutableInteractionSource() } + val buttonInteractionSource = remember { MutableInteractionSource() } + + val isTileHovered by tileInteractionSource.collectIsHoveredAsState() + val isTileFocused by tileInteractionSource.collectIsFocusedAsState() + val isButtonHovered by buttonInteractionSource.collectIsHoveredAsState() + val isButtonPressed by buttonInteractionSource.collectIsPressedAsState() + + val showFavoriteAction = isTileHovered || isTileFocused || isButtonHovered || isButtonPressed + + val accessibilityState = remember(isFavorite, isError, isConnecting, isDisconnected) { + buildString { + if (isFavorite) append(favoriteLabel, " ") + append(connectionStateLabel) + } + } + + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = ListItemDefaults.SegmentedGap), + ) { + SwipeToDismissBox( + state = swipeToDismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { + SwipeBackground(swipeToDismissState, isFavorite) + }, + onDismiss = { + coroutineScope.launch { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + swipeToDismissState.reset() + onFavoriteToggle() + } + }, + content = { + val onClickLabel = stringResource(R.string.select_device_action) + val toggleActionLabel = stringResource(R.string.toggle_favorite_action) + SegmentedListItem( + onClick = onClick, + modifier = Modifier.semantics { + stateDescription = accessibilityState + onClick(onClickLabel, null) + customActions = listOf( + CustomAccessibilityAction(label = toggleActionLabel) { + coroutineScope.launch { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + swipeToDismissState.reset() + onFavoriteToggle() + } + true + }, + ) + }, + shapes = ListItemDefaults.segmentedDynamicShapes( + index = index, + count = itemCount, + ), + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + content = { + Text(displayInfo.title) + }, + supportingContent = { Text(displayInfo.subtitle) }, + leadingContent = { + DynamicAvatarCircle( + bitmap = remote.picture, + isFavorite = isFavorite, + hasError = isError, + isLoading = isConnecting, + isDisabled = isDisconnected, + ) + }, + trailingContent = { + if (showFavoriteAction) { + TooltipIconButton( + description = if (remote.isFavorite) stringResource(R.string.remove_from_favorites_label) else stringResource( + R.string.add_to_favorites_label, + ), + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onFavoriteToggle() + }, + icon = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + interactionSource = buttonInteractionSource, + ) + } else { + Icon(Icons.Rounded.ChevronRight, contentDescription = null) + } + }, + interactionSource = tileInteractionSource, + ) + }, + ) + } +} + +@Composable +private fun SwipeBackground( + state: SwipeToDismissBoxState, isFavorite: Boolean, +) { + val density = LocalDensity.current + val offsetInDp = try { + val offset = abs(state.requireOffset()) + with(density) { + offset.toDp() + } + } catch (_: IllegalStateException) { + 0f.dp + } + + + val backgroundColor = if (isFavorite) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + + val favoriteIcon = if (isFavorite) Icons.Rounded.FavoriteBorder else Icons.Rounded.Favorite + val favoriteIconColor = + if (isFavorite) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer + + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = backgroundColor, + modifier = Modifier + .fillMaxHeight() + .width(offsetInDp), + ) { + Box( + contentAlignment = Alignment.CenterEnd, + + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(align = Alignment.End, unbounded = true), + ) { + Icon( + imageVector = favoriteIcon, + contentDescription = null, + tint = favoriteIconColor, + modifier = Modifier + .padding(end = 24.dp) + .requiredSize(24.dp), + ) + + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@PreviewLightDark +@PreviewDynamicColors +@Composable +fun RemoteListItemPreview() { + // Mock Data + val mockConnectedRemote = Remote( + uuid = "mockConnectedRemote", + displayName = "Connected remote", + userName = "user", + hostname = "device", + status = RemoteStatus.Connected, + isFavorite = false, + ) + + val mockErrorRemote = Remote( + uuid = "mockErrorRemote", + displayName = "Error remote", + userName = "error", + hostname = "192.168.0.999", + status = RemoteStatus.Error("Connection Refused"), + isFavorite = false, + ) + + val mockConnectingRemote = Remote( + uuid = "mockConnectingRemote", + displayName = "Connecting remote", + status = RemoteStatus.Connecting, + isFavorite = false, + ) + + val mockDisconnectedRemote = Remote( + uuid = "mockDisconnectedRemote", + hostname = "phone", + status = RemoteStatus.Disconnected, + isFavorite = false, + ) + + WarpinatorTheme { + Scaffold { scaffoldPadding -> + Column( + Modifier + .padding(scaffoldPadding) + .padding(top = 16.dp), + ) { + // Connected & Favorite + RemoteListItem( + remote = mockConnectedRemote.copy(isFavorite = true), + onFavoriteToggle = {}, + onClick = {}, index = 0, itemCount = 6, + ) + // Connected + RemoteListItem( + remote = mockConnectedRemote, + onFavoriteToggle = {}, + onClick = {}, index = 1, itemCount = 6, + ) + // Error + RemoteListItem( + remote = mockErrorRemote, + onFavoriteToggle = {}, + onClick = {}, index = 2, itemCount = 6, + ) + // Error & Favorite + RemoteListItem( + remote = mockErrorRemote.copy(isFavorite = true), + onFavoriteToggle = {}, + onClick = {}, index = 3, itemCount = 6, + ) + // Connecting (Animated) + RemoteListItem( + remote = mockConnectingRemote, + onFavoriteToggle = {}, + onClick = {}, index = 4, itemCount = 6, + ) + // Disconnected + RemoteListItem( + remote = mockDisconnectedRemote, + onFavoriteToggle = {}, + onClick = {}, index = 5, itemCount = 6, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/SendMessageDialog.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/SendMessageDialog.kt new file mode 100644 index 00000000..7fd656f4 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/SendMessageDialog.kt @@ -0,0 +1,61 @@ +package slowscript.warpinator.feature.home.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import slowscript.warpinator.R + +@Composable +fun SendMessageDialog( + onSendMessage: (String) -> Unit, + onDismiss: () -> Unit = {}, +) { + var text by rememberSaveable { mutableStateOf("") } + + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null) + }, + title = { Text(stringResource(R.string.send_message_dialog_title)) }, + confirmButton = { + Button( + onClick = { + onDismiss() + onSendMessage(text) + }, + enabled = text.isNotBlank(), + ) { Text(stringResource(R.string.send_label)) } + }, + text = { + OutlinedTextField( + value = text, + onValueChange = { + text = it + }, + placeholder = { Text(stringResource(R.string.message_text_field_placeholder)) }, + ) + }, + ) +} + +@Preview +@Composable +fun SendMessageDialogPreview() { + SendMessageDialog( + onSendMessage = {}, + onDismiss = {}, + ) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/TransferFloatingActionButton.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/TransferFloatingActionButton.kt new file mode 100644 index 00000000..4213e445 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/TransferFloatingActionButton.kt @@ -0,0 +1,159 @@ +package slowscript.warpinator.feature.home.components + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FilePresent +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.ModeComment +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButtonMenu +import androidx.compose.material3.FloatingActionButtonMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleFloatingActionButton +import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.tooling.preview.Preview +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.ShortcutLabel + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TransferFloatingActionButton( + onSendFolder: () -> Unit, + onSendFile: () -> Unit, + onSendMessage: () -> Unit = {}, + initiallyExpanded: Boolean = false, +) { + var isMenuExpanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + val haptics = LocalHapticFeedback.current + + val sendMessageLabel = stringResource(R.string.send_message_label) + val sendFolderLabel = stringResource(R.string.send_folder_label) + val sendFileLabel = stringResource(R.string.send_file_label) + + BackHandler(isMenuExpanded) { isMenuExpanded = false } + FloatingActionButtonMenu( + expanded = isMenuExpanded, + button = { + ToggleFloatingActionButton( + modifier = Modifier.semantics { + traversalIndex = -1f + stateDescription = if (isMenuExpanded) "Expanded" else "Collapsed" + contentDescription = "Send menu" + onClick("Expand", null) + if (!isMenuExpanded) { + customActions = listOf( + CustomAccessibilityAction(sendMessageLabel) { + onSendMessage() + true + }, + CustomAccessibilityAction(sendFolderLabel) { + onSendFolder() + true + }, + CustomAccessibilityAction(sendFileLabel) { + onSendFile() + true + }, + ) + } + }, + checked = isMenuExpanded, + onCheckedChange = { + isMenuExpanded = !isMenuExpanded + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + }, + ) { + val imageVector by remember { + derivedStateOf { + if (checkedProgress > 0.5f) Icons.Rounded.Close else Icons.Rounded.Upload + } + } + Icon( + painter = rememberVectorPainter(imageVector), + contentDescription = null, + modifier = Modifier.animateIcon({ checkedProgress }), + ) + } + }, + ) { + FloatingActionButtonMenuItem( + onClick = { + onSendMessage() + isMenuExpanded = false + }, + text = { + Column { + Text(sendMessageLabel) + ShortcutLabel( + KeyEvent.KEYCODE_3, ctrl = true, + ) + } + }, + icon = { Icon(Icons.Rounded.ModeComment, contentDescription = null) }, + ) + FloatingActionButtonMenuItem( + onClick = onSendFolder, + text = { + Column { + Text(sendFolderLabel) + ShortcutLabel( + KeyEvent.KEYCODE_2, ctrl = true, + ) + } + }, + icon = { Icon(Icons.Rounded.Folder, contentDescription = null) }, + ) + FloatingActionButtonMenuItem( + onClick = onSendFile, + text = { + Column { + Text(sendFileLabel) + ShortcutLabel( + KeyEvent.KEYCODE_1, ctrl = true, + ) + } + }, + icon = { Icon(Icons.Rounded.FilePresent, contentDescription = null) }, + ) + + } +} + +@Preview(showBackground = true) +@Composable +fun TransferFloatingActionButtonPreview() { + Scaffold( + floatingActionButton = { + TransferFloatingActionButton( + onSendFolder = {}, + onSendFile = {}, + initiallyExpanded = true, + ) + }, + ) {} +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/components/TransferListItem.kt b/app/src/main/java/slowscript/warpinator/feature/home/components/TransferListItem.kt new file mode 100644 index 00000000..759c80c2 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/components/TransferListItem.kt @@ -0,0 +1,606 @@ +package slowscript.warpinator.feature.home.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.FolderOpen +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.ExpandableSegmentedListItem +import slowscript.warpinator.core.design.components.TooltipIconButton +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.feature.home.state.TransferUiActionButtons +import slowscript.warpinator.feature.home.state.TransferUiProgressIndicator +import slowscript.warpinator.feature.home.state.toUiState +import kotlin.math.abs + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TransferListItem( + transfer: Transfer, + expanded: Boolean, + onExpandRequest: () -> Unit, + // Callbacks for actions + onItemOpen: (Transfer) -> Unit = {}, + onAccept: (Transfer) -> Unit = {}, + onDecline: (Transfer) -> Unit = {}, + onStop: (Transfer) -> Unit = {}, + onRetry: (Transfer) -> Unit = {}, + onClear: (Transfer) -> Unit = {}, + itemIndex: Int, + itemListCount: Int, +) { + val uiState = transfer.toUiState() + val swipeToDismissState = rememberSwipeToDismissBoxState() + val coroutineScope = rememberCoroutineScope() + + val haptics = LocalHapticFeedback.current + + val onDelete = { + coroutineScope.launch { + haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) + onClear(transfer) + } + } + + val deleteTransferActionLabel = stringResource(R.string.delete_transfer_action) + val acceptTransferActionLabel = stringResource(R.string.accept_transfer_action) + val declineTransferActionLabel = stringResource(R.string.decline_transfer_action) + val stopTransferActionLabel = stringResource(R.string.stop_transfer_action) + val cancelTransferActionLabel = stringResource(R.string.cancel_transfer_action) + val retryTransferActionLabel = stringResource(R.string.retry_transfer_action) + val openTransferActionLabel = stringResource(R.string.open_transfer_item_action) + + + val semanticCustomActions = remember(uiState.actionButtons, uiState.allowDismiss) { + buildList { + if (uiState.allowDismiss) add( + CustomAccessibilityAction(deleteTransferActionLabel) { + onDelete() + true + }, + ) + + when (uiState.actionButtons) { + TransferUiActionButtons.AcceptAndDecline -> { + add( + CustomAccessibilityAction(acceptTransferActionLabel) { + onAccept(transfer) + true + }, + ) + add( + CustomAccessibilityAction(declineTransferActionLabel) { + onDecline(transfer) + true + }, + ) + } + + TransferUiActionButtons.Stop -> { + add( + CustomAccessibilityAction(stopTransferActionLabel) { + onStop(transfer) + true + }, + ) + } + + TransferUiActionButtons.Cancel -> { + add( + CustomAccessibilityAction(cancelTransferActionLabel) { + onStop(transfer) + true + }, + ) + } + + TransferUiActionButtons.Retry -> { + add( + CustomAccessibilityAction(retryTransferActionLabel) { + onRetry(transfer) + true + }, + ) + } + + TransferUiActionButtons.OpenFolder -> { + add( + CustomAccessibilityAction(openTransferActionLabel) { + onItemOpen(transfer) + true + }, + ) + } + + TransferUiActionButtons.None -> {} + } + } + } + + val tileInteractionSource = remember { MutableInteractionSource() } + val clearInteractionSource = remember { MutableInteractionSource() } + + val isTileHovered by tileInteractionSource.collectIsHoveredAsState() + val isTileFocused by tileInteractionSource.collectIsFocusedAsState() + val isButtonHovered by clearInteractionSource.collectIsHoveredAsState() + val isButtonPressed by clearInteractionSource.collectIsPressedAsState() + + val showClearAction = isTileHovered || isTileFocused || isButtonHovered || isButtonPressed + + + SwipeToDismissBox( + state = swipeToDismissState, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = uiState.allowDismiss && !expanded, + backgroundContent = { + DismissBackground(swipeToDismissState) + }, + onDismiss = { + onDelete() + }, + content = { + ExpandableSegmentedListItem( + isExpanded = expanded, + toggleExpand = onExpandRequest, + content = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = uiState.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "(${uiState.totalSize})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + }, + supportingContent = { + Text( + text = uiState.statusText, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + Icon( + imageVector = if (uiState.isSending) Icons.Rounded.Upload else Icons.Rounded.Download, + contentDescription = if (uiState.isSending) stringResource(R.string.transfer_direction_send_short) else stringResource( + R.string.transfer_direction_receive_short, + ), + tint = uiState.iconColor, + ) + }, + trailingContent = { + Row { + if (showClearAction && uiState.allowDismiss) { + TooltipIconButton( + onClick = { onDelete() }, + icon = Icons.Rounded.Delete, + description = stringResource(R.string.delete_transfer_action), + interactionSource = clearInteractionSource, + ) + } + + when (uiState.actionButtons) { + TransferUiActionButtons.AcceptAndDecline -> { + TooltipIconButton( + onClick = { onAccept(transfer) }, icon = Icons.Rounded.Check, + description = stringResource(R.string.accept_label), + tint = MaterialTheme.colorScheme.primary, + ) + TooltipIconButton( + onClick = { onDecline(transfer) }, + icon = Icons.Rounded.Close, + description = stringResource(R.string.decline_label), + tint = MaterialTheme.colorScheme.error, + ) + } + + TransferUiActionButtons.Stop -> { + TooltipIconButton( + onClick = { onStop(transfer) }, + icon = Icons.Rounded.Stop, + description = stringResource(R.string.stop_label), + ) + + } + + TransferUiActionButtons.Retry -> { + TooltipIconButton( + description = stringResource(R.string.retry_label), + onClick = { onRetry(transfer) }, + icon = Icons.Rounded.Refresh, + ) + } + + TransferUiActionButtons.OpenFolder -> { + TooltipIconButton( + description = stringResource(R.string.open_item_label), + onClick = { onItemOpen(transfer) }, + icon = Icons.Rounded.FolderOpen, + ) + } + + TransferUiActionButtons.Cancel -> { + TooltipIconButton( + onClick = { onStop(transfer) }, + icon = Icons.Rounded.Clear, + description = stringResource(R.string.cancel_label), + ) + } + + TransferUiActionButtons.None -> {} + } + } + }, + subItemBuilder = { subItemIndex, containerColor, shape -> + when (subItemIndex) { + 0 -> Surface( + color = containerColor, + shape = shape, + ) { + Column { + // Detailed stats (Speed, Transferred/Total) + ListItem( + headlineContent = { + Text( + if (uiState.isSending) stringResource(R.string.outgoing_transfer) else stringResource( + R.string.incoming_transfer, + ), + ) + }, + supportingContent = { Text(uiState.statusLongText) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + + when (uiState.progressIndicator) { + TransferUiProgressIndicator.Active -> { + LinearWavyProgressIndicator( + progress = { uiState.progressFloat }, + amplitude = { 0.5f }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp, 12.dp), + trackColor = MaterialTheme.colorScheme.surface, + ) + } + + TransferUiProgressIndicator.Static -> { + // Static progress bar for finished/failed states + LinearProgressIndicator( + progress = { uiState.progressFloat }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp, 12.dp), + trackColor = MaterialTheme.colorScheme.surface, + ) + } + + TransferUiProgressIndicator.None -> {} + } + } + } + + 1 -> Surface( + color = containerColor, + shape = shape, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + + + when (uiState.actionButtons) { + TransferUiActionButtons.AcceptAndDecline -> { + Button( + onClick = { onAccept(transfer) }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.accept_label)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onDecline(transfer) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.decline_label)) + } + } + + TransferUiActionButtons.Stop -> { + Button( + onClick = { onStop(transfer) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ), + ) { + Icon( + Icons.Rounded.Stop, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.stop_label)) + } + } + + TransferUiActionButtons.Retry -> { + Button( + onClick = { onRetry(transfer) }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.retry_label)) + } + } + + TransferUiActionButtons.OpenFolder -> { + Button( + onClick = { onItemOpen(transfer) }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Rounded.FolderOpen, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.open_item_label)) + } + } + + TransferUiActionButtons.Cancel -> { + Button( + onClick = { onStop(transfer) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.cancel_label)) + } + } + + TransferUiActionButtons.None -> {} + } + + } + } + } + }, + subItemCount = if (uiState.actionButtons != TransferUiActionButtons.None) 2 else 1, + itemIndex = itemIndex, + listItemCount = itemListCount, + listItemModifier = Modifier.semantics { + customActions = semanticCustomActions + }, + interactionSource = tileInteractionSource, + ) + }, + ) +} + +@Composable +private fun DismissBackground( + state: SwipeToDismissBoxState, +) { + val density = LocalDensity.current + val offsetInDp = try { + val offset = abs(state.requireOffset()) + with(density) { + offset.toDp() + } + } catch (_: IllegalStateException) { + 0f.dp + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxHeight() + .width(offsetInDp), + ) { + Box( + contentAlignment = Alignment.CenterEnd, + + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(align = Alignment.End, unbounded = true), + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier + .padding(end = 24.dp) + .requiredSize(24.dp), + ) + + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@PreviewLightDark +@Composable +fun TransferListItemPreview() { + var expandedId by remember { mutableStateOf("2") } + + // Incoming Transfer + val incoming = Transfer( + remoteUuid = "remote-uuid", + direction = Transfer.Direction.Receive, + status = Transfer.Status.WaitingPermission, + singleFileName = "holiday_photos.zip", + fileCount = 1, + totalSize = 1024 * 1024 * 5, // 5MB + ) + + + // Outgoing Transfer + val outgoing = Transfer( + remoteUuid = "remote-uuid", + direction = Transfer.Direction.Send, + status = Transfer.Status.Transferring, + fileCount = 12, + totalSize = 1024 * 1024 * 100, // 100MB + bytesTransferred = 1024 * 1024 * 45, // 45MB + bytesPerSecond = 1024 * 1024 * 2, // 2MB/s + startTime = System.currentTimeMillis() - 25000, // Started 25s ago + ) + + + // Finished Transfer + val finished = Transfer( + remoteUuid = "remote-uuid", + direction = Transfer.Direction.Send, + status = Transfer.Status.Finished, + singleFileName = "contract.pdf", + fileCount = 1, + totalSize = 1024 * 500, + bytesTransferred = 1024 * 500, + ) + + WarpinatorTheme { + Scaffold { paddingValues -> + LazyColumn( + contentPadding = paddingValues, + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + item { + // Incoming (Waiting) + TransferListItem( + transfer = incoming, + expanded = expandedId == "1", + onExpandRequest = { expandedId = if (expandedId == "1") null else "1" }, + itemIndex = 0, + itemListCount = 3, + ) + + } + item { + // Outgoing (Progress) + TransferListItem( + transfer = outgoing, + expanded = expandedId == "2", + onExpandRequest = { expandedId = if (expandedId == "2") null else "2" }, + itemIndex = 1, + itemListCount = 3, + ) + } + item { + // Finished + TransferListItem( + transfer = finished, + expanded = expandedId == "3", + onExpandRequest = { expandedId = if (expandedId == "3") null else "3" }, + itemIndex = 2, + itemListCount = 3, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/panes/MessagesPane.kt b/app/src/main/java/slowscript/warpinator/feature/home/panes/MessagesPane.kt new file mode 100644 index 00000000..e3339d89 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/panes/MessagesPane.kt @@ -0,0 +1,384 @@ +package slowscript.warpinator.feature.home.panes + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import slowscript.warpinator.R +import slowscript.warpinator.core.data.WarpinatorViewModel +import slowscript.warpinator.core.design.components.DynamicAvatarCircle +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Remote.RemoteStatus +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.notification.components.NotificationInhibitor +import slowscript.warpinator.core.utils.RemoteDisplayInfo +import slowscript.warpinator.feature.home.components.MessageBubble + +@Composable +fun MessagesPane( + remote: Remote, + paneMode: Boolean, + onBack: () -> Unit, + viewModel: WarpinatorViewModel = hiltViewModel(), +) { + NotificationInhibitor( + remoteUuid = remote.uuid, + messages = true, + ) + + MessagesPaneContent( + remote = remote, + paneMode = paneMode, + onBack = onBack, + onSendMessage = { message -> viewModel.sendTextMessage(remote, message) }, + onMarkAsRead = { viewModel.markTextMessagesAsRead(remote) }, + onDeleteMessage = { message -> viewModel.clearMessage(message) }, + ) +} + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MessagesPaneContent( + remote: Remote, + paneMode: Boolean, + onBack: () -> Unit = {}, + onSendMessage: (String) -> Unit = {}, + onMarkAsRead: () -> Unit = {}, + onDeleteMessage: (Message) -> Unit = {}, +) { + val titleFormat = RemoteDisplayInfo.fromRemote(remote) + var messageText by rememberSaveable { mutableStateOf("") } + + val listState = rememberLazyListState() + + val status = remote.status + val isError = status is RemoteStatus.Error + val isConnecting = status == RemoteStatus.Connecting || status == RemoteStatus.AwaitingDuplex + val isDisconnected = status == RemoteStatus.Disconnected + + LaunchedEffect(remote.messages.size) { + onMarkAsRead() + + if (listState.firstVisibleItemIndex <= 1 || remote.messages.first().direction == Transfer.Direction.Send) { + listState.animateScrollToItem(0) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + TopAppBar( + title = { + if (paneMode) Text(stringResource(R.string.messages)) else Row(verticalAlignment = Alignment.CenterVertically) { + DynamicAvatarCircle( + bitmap = remote.picture, + isFavorite = remote.isFavorite, + hasError = isError, + isLoading = isConnecting, + isDisabled = isDisconnected, + ) + Text(titleFormat.title, modifier = Modifier.padding(8.dp, 0.dp)) + } + }, + navigationIcon = { + if (!paneMode) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) + }, + + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) + .imePadding(), + ) { + val listContentDescription = + stringResource(R.string.message_history_with_content_description, titleFormat.title) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .semantics { + contentDescription = listContentDescription + }, + contentPadding = innerPadding + PaddingValues(bottom = 92.dp, top = 8.dp), + reverseLayout = true, + state = listState, + ) { + items( + items = remote.messages, + key = { message -> message.timestamp }, + ) { message -> + MessageBubble( + message, + onDeleteMessage = { onDeleteMessage(message) }, + ) + } + } + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(innerPadding) + .padding(16.dp) + .fillMaxWidth(), + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val sendingEnabled = + messageText.isNotBlank() && remote.status == RemoteStatus.Connected && remote.supportsTextMessages + val onSend = { + if (messageText.isNotBlank()) { + onSendMessage(messageText) + messageText = "" + } + } + + TextField( + value = messageText, + onValueChange = { messageText = it }, + placeholder = { Text(stringResource(R.string.message_text_field_placeholder)) }, + modifier = Modifier + .weight(1f) + .onPreviewKeyEvent { event -> + if (event.isCtrlPressed && event.key == Key.Enter && event.type == KeyEventType.KeyDown && sendingEnabled) { + onSend() + true // consumed, does NOT insert newline + } else { + false // let Enter fall through to insert newline normally + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + unfocusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + maxLines = 3, + ) + + val sendButtonClickLabel = stringResource(R.string.send_action) + val sendButtonStateDescription = when { + !remote.supportsTextMessages -> stringResource(R.string.device_does_not_support_text_messages_state) + remote.status != RemoteStatus.Connected -> stringResource(R.string.device_is_disconnected_state) + messageText.isBlank() -> stringResource(R.string.message_is_empty_state) + else -> "" + } + + + IconButton( + onClick = onSend, + enabled = sendingEnabled, + modifier = Modifier.semantics { + onClick(sendButtonClickLabel, null) + + if (!sendingEnabled) { + stateDescription = sendButtonStateDescription + } + + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = stringResource(R.string.send_label), + tint = if (sendingEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun MessagesPanePreview() { + // An AI generated conversation for testing purposes + val messages = listOf( + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 1, + text = "Hey! Are you around?", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 2, + text = "Hey there! Yeah, just finished some work. What's up?", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 3, + text = "I was thinking about that new cafe downtown. Want to check it out?", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 4, + text = "Oh, the one with the blue storefront? I've heard the espresso is incredible.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 5, + text = "Exactly that one! They also have those huge croissants everyone is posting about.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 6, + text = "Haha, count me in. I'm a sucker for a good pastry.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 7, + text = "Great! Does 4:00 PM work for you?", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 8, + text = "Make it 4:15? I need to walk the dog first.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 9, + text = "No problem at all. 4:15 it is.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 10, + text = "Perfect. See you there!", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 11, + text = "See ya!", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 12, + text = "Wait, should I bring that book I borrowed from you?", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 13, + text = "Oh! If you've finished it, sure. Otherwise, no rush.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + timestamp = 14, + text = "I finished it last night. That ending was wild!", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 15, + text = "I told you! We definitely need to talk about it over coffee.", + ), + Message( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + timestamp = 16, + text = "https://somerandomcaffe.com", + ), + ).reversed() + + val remote = Remote( + uuid = "remote", + displayName = "Test Device", + userName = "user", + hostname = "hostname", + status = RemoteStatus.Connected, + messages = messages, + supportsTextMessages = true, + isFavorite = false, + ) + + WarpinatorTheme { + MessagesPaneContent( + remote = remote, + paneMode = false, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/home/panes/RemotesPane.kt b/app/src/main/java/slowscript/warpinator/feature/home/panes/RemotesPane.kt new file mode 100644 index 00000000..804bd03c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/panes/RemotesPane.kt @@ -0,0 +1,545 @@ +package slowscript.warpinator.feature.home.panes + +import android.net.Uri +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DevicesOther +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.PortableWifiOff +import androidx.compose.material.icons.rounded.SignalWifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.LoadingIndicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import slowscript.warpinator.R +import slowscript.warpinator.core.data.ServiceState +import slowscript.warpinator.core.data.WarpinatorViewModel +import slowscript.warpinator.core.design.components.MessagesHandlerEffect +import slowscript.warpinator.core.design.shapes.WarpinatorRoundedIconOutlineShape +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.utils.KeyboardShortcuts +import slowscript.warpinator.feature.home.components.HomeMenu +import slowscript.warpinator.feature.home.components.RemoteListItem +import slowscript.warpinator.feature.manual_connection.ManualConnectionDialog + +private enum class RemoteListUiState { + Normal, Empty, Starting, Stopping, FailedToStart, NetworkChangeRestart +} + +const val CONNECTION_ISSUES_HELP_URL = + "https://slowscript.xyz/warpinator-android/connection-issues/" + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalLayoutApi::class, +) +@Composable +fun RemoteListPane( + paneMode: Boolean, + onRemoteClick: (Remote) -> Unit, + onFavoriteToggle: (Remote) -> Unit, + viewModel: WarpinatorViewModel = hiltViewModel(), +) { + val currentRemotes by viewModel.remoteListState.collectAsStateWithLifecycle() + val currentServiceState by viewModel.serviceState.collectAsStateWithLifecycle() + val currentNetworkState by viewModel.networkState.collectAsStateWithLifecycle() + val currentIsRefreshing by viewModel.refreshState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + + MessagesHandlerEffect( + messageProvider = viewModel.uiMessages, snackbarHostState = snackbarHostState, + ) + + // Dialog states + var showManualConnectionDialog by rememberSaveable { mutableStateOf(false) } + + RemoteListPaneContent( + remotes = currentRemotes, + onRemoteClick = onRemoteClick, + onFavoriteToggle = onFavoriteToggle, + state = currentServiceState, + isOnline = currentNetworkState.isOnline, + isRefreshing = currentIsRefreshing, + paneMode = paneMode, + onRescan = viewModel::rescan, + onReannounce = viewModel::reannounce, + onSaveLog = viewModel::saveLog, + onShowManualConnectionDialog = { showManualConnectionDialog = true }, + snackbarHostState = snackbarHostState, + ) + + if (showManualConnectionDialog) ManualConnectionDialog( + onDismiss = { showManualConnectionDialog = false }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RemoteListPaneContent( + remotes: List, + state: ServiceState, + isOnline: Boolean = true, + isRefreshing: Boolean = false, + onRemoteClick: (Remote) -> Unit, + onFavoriteToggle: (Remote) -> Unit, + onRescan: () -> Unit, + onReannounce: () -> Unit = {}, + onSaveLog: (uri: Uri) -> Unit = {}, + onShowManualConnectionDialog: () -> Unit = {}, + paneMode: Boolean = false, + snackbarHostState: SnackbarHostState? = null, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + canScroll = { !paneMode }, + ) + val refreshState = rememberPullToRefreshState() + + val filteredRemotes = remotes.filter { !it.hasErrorGroupCode } + + // UIStates + val uiState: RemoteListUiState = (when { + state is ServiceState.Starting -> RemoteListUiState.Starting + state is ServiceState.Stopping -> RemoteListUiState.Stopping + state is ServiceState.InitializationFailed -> RemoteListUiState.FailedToStart + state is ServiceState.NetworkChangeRestart -> RemoteListUiState.NetworkChangeRestart + filteredRemotes.isEmpty() -> RemoteListUiState.Empty + else -> RemoteListUiState.Normal + }) + + val uriHandler = LocalUriHandler.current + + KeyboardShortcuts { event -> + when { + event.isCtrlPressed && event.key == Key.K -> { + onShowManualConnectionDialog() + true + } + + event.isCtrlPressed && event.key == Key.R -> { + onRescan() + true + } + + event.isCtrlPressed && event.isAltPressed && event.key == Key.R -> { + onReannounce() + true + } + + event.key == Key.F1 -> { + uriHandler.openUri(CONNECTION_ISSUES_HELP_URL) + true + } + + else -> false + } + } + + Scaffold( + snackbarHost = { snackbarHostState?.let { SnackbarHost(it) } }, + topBar = { + if (paneMode) TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + actions = { + HomeMenu( + onManualConnectionClick = onShowManualConnectionDialog, + onRescan = onRescan, + onReannounce = onReannounce, + onSaveLog = onSaveLog, + ) + }, + scrollBehavior = scrollBehavior, + ) else LargeFlexibleTopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + actions = { + HomeMenu( + onManualConnectionClick = onShowManualConnectionDialog, + onRescan = onRescan, + onReannounce = onReannounce, + onSaveLog = onSaveLog, + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRescan, + state = refreshState, + indicator = { + LoadingIndicator( + state = refreshState, + isRefreshing = isRefreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(padding), + ) + }, + ) { + Crossfade( + targetState = uiState, + label = "RemoteListPaneContent", + modifier = Modifier.fillMaxSize(), + ) { listUiState -> + LazyColumn( + contentPadding = padding, + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + if (!isOnline) { + item { + Card( + modifier = Modifier.padding( + 16.dp, 12.dp, + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.SignalWifiOff, contentDescription = null) + Spacer(modifier = Modifier.size(16.dp)) + Column { + Text( + stringResource(R.string.no_internet_connection_title), + style = MaterialTheme.typography.labelLarge, + ) + Text( + stringResource(R.string.no_internet_connection_subtitle), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + + + if (listUiState != RemoteListUiState.Normal) { + item { + Column( + modifier = Modifier.fillParentMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (listUiState) { + RemoteListUiState.Empty -> { + Icon( + Icons.Rounded.DevicesOther, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(150.dp), + ) + Text( + stringResource(R.string.no_devices_found), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(32.dp), + ) + Button( + onClick = onShowManualConnectionDialog, + ) { + Text(stringResource(R.string.manual_connection_label)) + } + } + + RemoteListUiState.Starting, RemoteListUiState.NetworkChangeRestart -> { +// // Using the new indeterminate loading indicator + LoadingIndicator( + modifier = Modifier.size(200.dp), + polygons = listOf( + // Some shapes to represent Warpinator, sending files and starting + WarpinatorRoundedIconOutlineShape, + MaterialShapes.Arrow, + MaterialShapes.Ghostish, + MaterialShapes.Slanted, + MaterialShapes.ClamShell, + ), + ) + Text( + if (listUiState == RemoteListUiState.NetworkChangeRestart) stringResource( + R.string.restarting_warpinator_service, + ) else stringResource(R.string.starting_warpinator_service), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + ) + if (listUiState == RemoteListUiState.NetworkChangeRestart) Text( + stringResource(R.string.network_changed_state), + style = MaterialTheme.typography.labelLarge, + ) + } + + RemoteListUiState.Stopping -> { + Icon( + Icons.Rounded.PortableWifiOff, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(150.dp), + ) + Text( + stringResource(R.string.stopping_warpinator_service), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(24.dp), + ) + } + + RemoteListUiState.FailedToStart -> { + val state = state as ServiceState.InitializationFailed + + + Icon( + Icons.Rounded.ErrorOutline, + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + modifier = Modifier.size(150.dp), + ) + Text( + stringResource(R.string.failed_to_start_warpinator), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + + ) + if (state.interfaces != null) { + Text( + state.interfaces, + style = MaterialTheme.typography.labelLarge, + ) + } + Text( + state.exception, + style = MaterialTheme.typography.labelLarge, + ) + } + + else -> {} + } + } + } + return@LazyColumn + } + + item { + Text( + stringResource(R.string.available_devices_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 26.dp) + .padding( + top = 32.dp, bottom = 12.dp, + ) + .semantics { + heading() + }, + ) + } + + itemsIndexed( + filteredRemotes, + key = { index, remote -> remote.uuid }, + ) { index, remote -> + Box { + RemoteListItem( + remote = remote, + onFavoriteToggle = { onFavoriteToggle(remote) }, + onClick = { onRemoteClick(remote) }, + index = index, + itemCount = filteredRemotes.size, + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun RemotePanePreview() { + val remote = Remote( + uuid = "remote", + displayName = "Test Device", + userName = "user", + hostname = "hostname", + status = Remote.RemoteStatus.Connected, + isFavorite = false, + ) + + + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(remote), + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + state = ServiceState.Ok, + ) + } +} + +@Preview +@Composable +private fun RemotePaneEmptyPreview() { + + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(), + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + state = ServiceState.Ok, + ) + } +} + +@Preview +@Composable +private fun RemotePaneStartingServicePreview() { + + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(), + state = ServiceState.Starting, + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + ) + } +} + +@Preview +@Composable +private fun RemotePaneStoppingServicePreview() { + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(), + state = ServiceState.Stopping, + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + ) + } +} + +@Preview +@Composable +private fun RemotePaneNetworkChangeRestartPreview() { + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(), + state = ServiceState.NetworkChangeRestart, + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + ) + } +} + +@Preview +@Composable +private fun RemotePaneInitializationFailedPreview() { + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(), + state = ServiceState.InitializationFailed("interfaces", "exception"), + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + ) + } +} + +@Preview +@Composable +private fun RemotePaneNotOnlinePreview() { + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(), + isOnline = false, + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + state = ServiceState.Ok, + ) + } +} + +@Preview +@Composable +private fun RemotePaneNotOnlineContentPreview() { + val remote = Remote( + uuid = "remote", + displayName = "Test Device", + userName = "user", + hostname = "hostname", + status = Remote.RemoteStatus.Connected, + isFavorite = false, + ) + + + WarpinatorTheme { + RemoteListPaneContent( + remotes = listOf(remote), + isOnline = false, + onRemoteClick = {}, + onFavoriteToggle = {}, + onRescan = {}, + state = ServiceState.Ok, + ) + } +} diff --git a/app/src/main/java/slowscript/warpinator/feature/home/panes/TransfersPane.kt b/app/src/main/java/slowscript/warpinator/feature/home/panes/TransfersPane.kt new file mode 100644 index 00000000..8765fd33 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/panes/TransfersPane.kt @@ -0,0 +1,759 @@ +package slowscript.warpinator.feature.home.panes + +import android.content.ClipDescription +import android.net.Uri +import android.view.KeyEvent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.rounded.ClearAll +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.Inbox +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material.icons.rounded.SyncAlt +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import slowscript.warpinator.R +import slowscript.warpinator.core.data.WarpinatorViewModel +import slowscript.warpinator.core.design.components.DragAndDropUiMode +import slowscript.warpinator.core.design.components.FileDropTargetIndicator +import slowscript.warpinator.core.design.components.TooltipIconButton +import slowscript.warpinator.core.design.components.fileDropTarget +import slowscript.warpinator.core.design.components.rememberDropTargetState +import slowscript.warpinator.core.design.components.rememberShortcutLabelText +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Message +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Transfer +import slowscript.warpinator.core.notification.components.NotificationInhibitor +import slowscript.warpinator.core.utils.KeyboardShortcuts +import slowscript.warpinator.feature.home.components.MessageListItem +import slowscript.warpinator.feature.home.components.RemoteLargeFlexibleTopAppBar +import slowscript.warpinator.feature.home.components.SendMessageDialog +import slowscript.warpinator.feature.home.components.TransferFloatingActionButton +import slowscript.warpinator.feature.home.components.TransferListItem + +@Composable +fun TransfersPane( + remote: Remote, + paneMode: Boolean, + onBack: () -> Unit, + onOpenMessagesPane: () -> Unit, + onFavoriteToggle: (Remote) -> Unit, + viewModel: WarpinatorViewModel = hiltViewModel(), +) { + NotificationInhibitor( + remoteUuid = remote.uuid, + transfers = true, + messages = viewModel.integrateMessages, + ) + + TransferPaneContent( + remote = remote, + paneMode = paneMode, + integrateMessages = viewModel.integrateMessages, + onBack = onBack, + onOpenMessagesPane = onOpenMessagesPane, + onSendMessage = viewModel::sendTextMessage, + onFavoriteToggle = onFavoriteToggle, + onAcceptTransfer = viewModel::acceptTransfer, + onDeclineTransfer = viewModel::declineTransfer, + onStopTransfer = viewModel::cancelTransfer, + onRetryTransfer = viewModel::retryTransfer, + onItemOpen = viewModel::openTransfer, + onSendUris = { uris, isDir -> + viewModel.sendUris(remote, uris, isDir) + }, + onClearTransfer = viewModel::clearTransfer, + onClearMessage = viewModel::clearMessage, + onClearTransfers = viewModel::clearAllFinished, + onReconnect = viewModel::reconnect, + ) +} + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun TransferPaneContent( + remote: Remote, + paneMode: Boolean, + integrateMessages: Boolean, + onBack: () -> Unit = {}, + onOpenMessagesPane: () -> Unit = {}, + onSendMessage: (Remote, String) -> Unit = { _: Remote, _: String -> }, + isFavoriteOverride: Boolean? = null, + onFavoriteToggle: (Remote) -> Unit = {}, + onSendUris: (List, Boolean) -> Unit = { _: List, _: Boolean -> }, + onAcceptTransfer: (Transfer) -> Unit = {}, + onDeclineTransfer: (Transfer) -> Unit = {}, + onStopTransfer: (Transfer) -> Unit = {}, + onRetryTransfer: (Transfer) -> Unit = {}, + onItemOpen: (Transfer) -> Unit = {}, + onClearTransfer: (Transfer) -> Unit = {}, + onClearMessage: (Message) -> Unit = {}, + onClearTransfers: (String) -> Unit = {}, + onReconnect: (Remote) -> Unit = {}, +) { + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + onSendUris(uris, false) + } + + val folderPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + if (uri != null) { + onSendUris(listOf(uri), true) + } + } + + val fileDropTargetState = rememberDropTargetState( + onUrisDropped = { uris -> + onSendUris(uris, false) + true + }, + shouldStartDragAndDrop = shouldStartDragAndDrop@{ event -> + val description = + event.toAndroidDragEvent().clipDescription ?: return@shouldStartDragAndDrop false + (0 until description.mimeTypeCount).any { mimeType -> + description.getMimeType(mimeType) !in setOf( + ClipDescription.MIMETYPE_TEXT_PLAIN, + ClipDescription.MIMETYPE_TEXT_HTML, + ClipDescription.MIMETYPE_TEXT_INTENT, + ) + } + }, + ) + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + var showMessageDialog by rememberSaveable { mutableStateOf(false) } + + KeyboardShortcuts { event -> + when { + event.isCtrlPressed && event.key == Key.One || event.isCtrlPressed && event.key == Key.O -> { + filePicker.launch(arrayOf("*/*")) + true + } + + event.isCtrlPressed && event.key == Key.Two || event.isCtrlPressed && event.isShiftPressed && event.key == Key.O -> { + folderPicker.launch(null) + true + } + + event.isCtrlPressed && event.key == Key.Three || event.isCtrlPressed && event.key == Key.M -> { + if (integrateMessages) showMessageDialog = true + else onOpenMessagesPane() + + true + } + + event.isCtrlPressed && event.key == Key.D -> { + onFavoriteToggle(remote) + true + } + + event.isCtrlPressed && event.isShiftPressed && event.key == Key.R -> { + onReconnect(remote) + true + } + + event.isCtrlPressed && event.isShiftPressed && event.key == Key.Delete -> { + onClearTransfers(remote.uuid) + true + } + + else -> false + } + } + + Scaffold( + topBar = { + RemoteLargeFlexibleTopAppBar( + remote = remote, + navigationIcon = { + if (!paneMode) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back_button_label), + ) + } + } + }, + isFavoriteOverride = isFavoriteOverride, + actions = { + TooltipIconButton( + onClick = { + onClearTransfers(remote.uuid) + }, + icon = Icons.Rounded.ClearAll, + description = rememberShortcutLabelText( + KeyEvent.KEYCODE_DEL, ctrl = true, shift = true, + text = stringResource(R.string.clear_transfer_history_label), + ), + ) + + val favouriteButtonSemanticState = if (isFavoriteOverride + ?: remote.isFavorite + ) stringResource(R.string.favorite_label) else stringResource(R.string.not_favorite_label) + + TooltipIconButton( + onClick = { onFavoriteToggle(remote) }, + icon = if (isFavoriteOverride + ?: remote.isFavorite + ) Icons.Rounded.Star else Icons.Rounded.StarBorder, + description = rememberShortcutLabelText( + KeyEvent.KEYCODE_D, ctrl = true, + text = if (isFavoriteOverride + ?: remote.isFavorite + ) stringResource(R.string.remove_from_favorites_label) else stringResource( + R.string.add_to_favorites_label, + ), + ), + modifier = Modifier.semantics { + stateDescription = favouriteButtonSemanticState + + toggleableState = if (isFavoriteOverride + ?: remote.isFavorite + ) ToggleableState.On else ToggleableState.Off + + }, + ) + + if (!integrateMessages) TooltipIconButton( + onClick = onOpenMessagesPane, + icon = Icons.AutoMirrored.Rounded.Message, + description = rememberShortcutLabelText( + keyCode = KeyEvent.KEYCODE_M, ctrl = true, + text = stringResource(R.string.messages), + ), + enabled = remote.supportsTextMessages, + addBadge = remote.hasUnreadMessages, + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + if (remote.status == Remote.RemoteStatus.Connected) { + TransferFloatingActionButton( + onSendFile = { filePicker.launch(arrayOf("*/*")) }, + onSendFolder = { folderPicker.launch(null) }, + onSendMessage = { + if (integrateMessages) showMessageDialog = true + else onOpenMessagesPane() + }, + ) + } + }, + ) { padding -> + val transfers = remote.transfers + var expandedTransferID by rememberSaveable { mutableStateOf(null) } + + val listContentDescription = + stringResource(R.string.transfers_history_list_content_description) + + LazyColumn( + contentPadding = padding.plus( + PaddingValues( + bottom = 100.dp, start = 16.dp, end = 16.dp, + ), + ), + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .semantics { + contentDescription = listContentDescription + } + .fileDropTarget(fileDropTargetState), + ) { + + item { + ConnectionStatusCard( + remote.status, + transfers.size, + if (integrateMessages) remote.messages.size else null, + ) { onReconnect(remote) } + } + + if (fileDropTargetState.uiMode != DragAndDropUiMode.None) { + item { + FileDropTargetIndicator( + fileDropTargetState.uiMode, + text = stringResource(R.string.drop_here_to_send), + modifier = Modifier.fillParentMaxSize(), + ) + } + return@LazyColumn + } + + if ((transfers.isEmpty() && !integrateMessages) || (transfers.isEmpty() && remote.messages.isEmpty())) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .height(400.dp) + .fillMaxWidth(), + ) { + Icon( + Icons.Rounded.Inbox, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(100.dp), + ) + Text( + stringResource(R.string.no_transfers_yet), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + if (integrateMessages && remote.messages.isNotEmpty()) { + itemsIndexed( + remote.messages, + key = { _, message -> message.timestamp }, + ) { index, message -> + val expanded = "mt${message.timestamp}" == expandedTransferID + + MessageListItem( + message = message, + expanded = expanded, + onExpandRequest = { + expandedTransferID = if (expanded) null else "mt${message.timestamp}" + }, + onClear = { + onClearMessage(message) + }, + itemIndex = index, + itemListCount = remote.messages.size, + ) + + Spacer(modifier = Modifier.height(ListItemDefaults.SegmentedGap)) + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + } + + itemsIndexed( + transfers, + key = { _, transfer -> transfer.uid }, + ) { index, transfer -> + val expanded = transfer.uid == expandedTransferID + TransferListItem( + transfer = transfer, + expanded = expanded, + onExpandRequest = { + expandedTransferID = if (expanded) null else transfer.uid + }, + onAccept = onAcceptTransfer, + onDecline = onDeclineTransfer, + onStop = onStopTransfer, + onRetry = onRetryTransfer, + onItemOpen = onItemOpen, + onClear = onClearTransfer, + itemIndex = index, + itemListCount = transfers.size, + ) + + Spacer(modifier = Modifier.height(ListItemDefaults.SegmentedGap)) + } + } + } + + if (showMessageDialog) { + SendMessageDialog( + onSendMessage = { message -> + onSendMessage(remote, message) + }, + onDismiss = { + showMessageDialog = false + }, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun ConnectionStatusCard( + status: Remote.RemoteStatus, transfersCount: Int, messageCount: Int? = null, + onReconnect: () -> Unit, +) { + Card( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth() + .semantics(true) { + liveRegion = LiveRegionMode.Polite + }, + colors = if (status is Remote.RemoteStatus.Error) CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) else CardDefaults.cardColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp), + ) { + // TODO(raresvanca): look at putting shape backgrounds for the status icons + when (status) { + Remote.RemoteStatus.AwaitingDuplex, Remote.RemoteStatus.Connecting -> { + LoadingIndicator(modifier = Modifier.padding(horizontal = 6.dp)) + } + + Remote.RemoteStatus.Connected -> { + Icon( + Icons.Rounded.SyncAlt, + null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 16.dp) + .size(28.dp), + ) + } + + Remote.RemoteStatus.Disconnected -> { + Icon( + Icons.Rounded.SyncAlt, + null, + tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.6f), + modifier = Modifier + .padding(horizontal = 16.dp) + .size(28.dp), + ) + } + + is Remote.RemoteStatus.Error -> { + Icon( + Icons.Rounded.Error, + null, + modifier = Modifier + .padding(horizontal = 16.dp) + .size(28.dp), + ) + } + } + + + Column { + Text( + when (status) { + Remote.RemoteStatus.AwaitingDuplex -> stringResource(R.string.remote_awaiting_duplex) + Remote.RemoteStatus.Connecting -> stringResource(R.string.remote_connecting) + Remote.RemoteStatus.Connected -> stringResource(R.string.remote_connected) + Remote.RemoteStatus.Disconnected -> stringResource(R.string.remote_disconnected) + is Remote.RemoteStatus.Error -> stringResource( + R.string.remote_failed_to_connect, + status.message, + ) + }, + style = MaterialTheme.typography.titleMedium, + ) + Text( + buildString { + append( + pluralStringResource( + R.plurals.transfers_count, + transfersCount, + transfersCount, + ), + ) + if (messageCount != null) { + append(" • ") + append( + pluralStringResource( + R.plurals.messages_count, + messageCount, + messageCount, + ), + ) + } + }, + style = MaterialTheme.typography.labelLarge, + color = LocalContentColor.current.copy(alpha = 0.8f), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (status == Remote.RemoteStatus.Disconnected || status is Remote.RemoteStatus.Error) { + FilledTonalButton( + modifier = Modifier.padding(horizontal = 8.dp), + colors = if (status is Remote.RemoteStatus.Error) ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ) else ButtonDefaults.filledTonalButtonColors(), + onClick = { + onReconnect() + }, + ) { + Text(stringResource(R.string.reconnect_button_label)) + } + } + } + } +} + + +@Preview +@Composable +private fun TransfersPanePreview() { + // Transfers covering different states and directions + val transfers = listOf( + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + status = Transfer.Status.Transferring, + totalSize = 100 * 1024 * 1024, // 100 MB + bytesTransferred = 45 * 1024 * 1024, // 45 MB + singleFileName = "sending_video.mp4", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.Transferring, + totalSize = 50 * 1024 * 1024, + bytesTransferred = 10 * 1024 * 1024, + singleFileName = "receiving_document.pdf", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.WaitingPermission, + totalSize = 2 * 1024 * 1024, + singleFileName = "incoming_request.zip", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + status = Transfer.Status.Finished, + totalSize = 5 * 1024 * 1024, + bytesTransferred = 5 * 1024 * 1024, + singleFileName = "sent_image.jpg", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.Finished, + totalSize = 3 * 1024 * 1024, + bytesTransferred = 3 * 1024 * 1024, + singleFileName = "received_song.mp3", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.Declined, + totalSize = 1024, + singleFileName = "declined_file.exe", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + status = Transfer.Status.Failed( + error = Transfer.Error.Generic("Network error"), isRecoverable = true, + ), + totalSize = 10 * 1024 * 1024, + bytesTransferred = 1 * 1024 * 1024, + singleFileName = "failed_upload.iso", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.Failed( + error = Transfer.Error.StorageFull, isRecoverable = false, + ), + totalSize = 500 * 1024, + singleFileName = "corrupted_file.bin", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + status = Transfer.Status.Paused, + totalSize = 200 * 1024 * 1024, + bytesTransferred = 100 * 1024 * 1024, + singleFileName = "paused_backup.tar.gz", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.Stopped, + totalSize = 15 * 1024 * 1024, + bytesTransferred = 2 * 1024 * 1024, + singleFileName = "stopped_download.apk", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + status = Transfer.Status.Failed( + error = Transfer.Error.FileNotFound("missing_file.txt"), isRecoverable = false, + ), + totalSize = 0, + singleFileName = "missing_file.txt", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Receive, + status = Transfer.Status.FinishedWithErrors( + errors = listOf(Transfer.Error.PermissionDenied("/root/forbidden")), + ), + totalSize = 8 * 1024 * 1024, + bytesTransferred = 8 * 1024 * 1024, + singleFileName = "completed_with_errors.log", + fileCount = 1, + ), + Transfer( + remoteUuid = "remote", + direction = Transfer.Direction.Send, + status = Transfer.Status.Initializing, + totalSize = 0, + singleFileName = "initializing_folder", + fileCount = 5, + ), + ) + // Reverse transfers so it can be shown as in the list, because the UI flips the list to show first the + // added last + val remote = Remote( + uuid = "remote", + displayName = "Test Device", + userName = "user", + hostname = "hostname", + status = Remote.RemoteStatus.Connected, + transfers = transfers, + isFavorite = false, + supportsTextMessages = true, + hasUnreadMessages = true, + ) + + WarpinatorTheme { + TransferPaneContent( + remote = remote, + paneMode = false, + integrateMessages = false, + onBack = {}, + isFavoriteOverride = false, + ) + } +} + + +@Preview +@Composable +private fun TransfersPaneEmptyPreview() { + + // Reverse transfers so it can be shown as in the list, because the UI flips the list to show first the + // added last + val remote = Remote( + uuid = "remote", + displayName = "Test Device", + userName = "user", + hostname = "hostname", + status = Remote.RemoteStatus.Connected, + transfers = listOf(), + isFavorite = false, + ) + + WarpinatorTheme { + TransferPaneContent( + remote = remote, + paneMode = false, + integrateMessages = false, + onBack = {}, + isFavoriteOverride = false, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConnectionStatusCardPreview() { + WarpinatorTheme { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ConnectionStatusCard( + status = Remote.RemoteStatus.Connecting, transfersCount = 3, + ) {} + ConnectionStatusCard( + status = Remote.RemoteStatus.Connected, transfersCount = 5, + ) {} + ConnectionStatusCard( + status = Remote.RemoteStatus.Disconnected, transfersCount = 5, + ) {} + ConnectionStatusCard( + status = Remote.RemoteStatus.Error(message = "Connection failed"), + transfersCount = 2, + ) {} + } + } +} diff --git a/app/src/main/java/slowscript/warpinator/feature/home/state/TransferUiState.kt b/app/src/main/java/slowscript/warpinator/feature/home/state/TransferUiState.kt new file mode 100644 index 00000000..f19421b9 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/home/state/TransferUiState.kt @@ -0,0 +1,273 @@ +package slowscript.warpinator.feature.home.state + +import android.text.format.Formatter +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.core.text.BidiFormatter +import slowscript.warpinator.R +import slowscript.warpinator.core.model.Transfer + +enum class TransferUiActionButtons { + AcceptAndDecline, Stop, Cancel, Retry, OpenFolder, None +} + +enum class TransferUiProgressIndicator { + Active, Static, None +} + +data class TransferUiState( + val id: String, + val title: String, + val statusText: String, + val statusLongText: String, + val totalSize: String, + val isSending: Boolean, + val progressFloat: Float, + val iconColor: Color, + val allowDismiss: Boolean, + val actionButtons: TransferUiActionButtons, + val progressIndicator: TransferUiProgressIndicator, +) + +@Composable +fun Transfer.toUiState(): TransferUiState { + val context = LocalContext.current + + val title = if (this.fileCount == 1L) { + this.singleFileName ?: "File" + } else { + pluralStringResource(R.plurals.transfer_files_count, this.fileCount.toInt(), this.fileCount) + } + + val bidi = BidiFormatter.getInstance() + + val totalSizeStr = remember(this.totalSize) { + Formatter.formatFileSize( + context, + this.totalSize, + ).let { bidi.unicodeWrap(it) } + } + val transferredSizeStr = remember(this.bytesTransferred) { + Formatter.formatFileSize( + context, + this.bytesTransferred, + ).let { bidi.unicodeWrap(it) } + } + val transferSpeedSizeStr = remember(this.bytesPerSecond) { + Formatter.formatFileSize( + context, + this.bytesPerSecond, + ) + } + + val transferSpeedStr = stringResource( + R.string.transfer_speed_fmt, + transferSpeedSizeStr, + ).let { bidi.unicodeWrap(it) } + + + val (statusText, statusLongText) = this.getStatusStrings( + transferredSizeStr, + totalSizeStr, + transferSpeedStr, + bidi, + ) + + val progressFloat = if (this.totalSize > 0) { + this.bytesTransferred.toFloat() / this.totalSize.toFloat() + } else 0f + + + val isSending = this.direction == Transfer.Direction.Send + val isRetryable = + isSending && (status is Transfer.Status.Failed || status == Transfer.Status.Stopped || status is Transfer.Status.FinishedWithErrors || status == Transfer.Status.Declined) + + val allowDismiss = + status is Transfer.Status.Failed || status == Transfer.Status.Stopped || status == Transfer.Status.Declined || status is Transfer.Status.FinishedWithErrors || status == Transfer.Status.Finished + + val actionButtons = when { + status == Transfer.Status.WaitingPermission && !isSending -> TransferUiActionButtons.AcceptAndDecline + (status == Transfer.Status.WaitingPermission && isSending) -> TransferUiActionButtons.Cancel + status == Transfer.Status.Transferring -> TransferUiActionButtons.Stop + isRetryable -> TransferUiActionButtons.Retry + status == Transfer.Status.Finished && !isSending -> TransferUiActionButtons.OpenFolder + else -> TransferUiActionButtons.None + } + + val progressIndicator = when { + status == Transfer.Status.Transferring -> TransferUiProgressIndicator.Active + status != Transfer.Status.WaitingPermission && status != Transfer.Status.Declined -> TransferUiProgressIndicator.Static + else -> TransferUiProgressIndicator.None + } + + return TransferUiState( + id = this.uid, + title = title, + statusText = statusText, + statusLongText = statusLongText, + totalSize = totalSizeStr, + isSending = isSending, + progressFloat = progressFloat, + iconColor = this.getStatusColor(), + allowDismiss = allowDismiss, + actionButtons = actionButtons, + progressIndicator = progressIndicator, + ) +} + +@Composable +private fun Transfer.getStatusStrings( + transferredSizeStr: String, + totalSizeStr: String, + transferSpeedStr: String, + bidi: BidiFormatter, +): Pair { + return when (this.status) { + Transfer.Status.WaitingPermission -> { + val str = if (this.overwriteWarning) { + stringResource(R.string.transfer_state_waiting_permission_overwrite_warning) + } else { + stringResource(R.string.transfer_state_waiting_permission) + } + str to str + } + + Transfer.Status.Transferring -> { + val remaining = this.getRemainingTime() + val remainingString = when { + remaining == null -> stringResource(R.string.time_indefinite) + remaining <= 5 -> stringResource(R.string.time_few_seconds_remaining) + remaining < 60 -> pluralStringResource( + R.plurals.time_seconds_remaining, + remaining, + remaining, + ) + + remaining < 3600 -> { + val seconds = (remaining % 60).let { + pluralStringResource( + R.plurals.duration_seconds_short, + it, + it, + ) + } + val minutes = (remaining / 60).let { + pluralStringResource( + R.plurals.duration_minutes_short, + it, + it, + ) + } + + stringResource(R.string.time_details_remaining_fmt, minutes, seconds) + } + + remaining < 86400 -> { + val hours = (remaining / 3600).let { + pluralStringResource( + R.plurals.duration_hours_short, + it, + it, + ) + } + val minutes = ((remaining % 3600) / 60).let { + pluralStringResource( + R.plurals.duration_minutes_short, + it, + it, + ) + } + + stringResource(R.string.time_details_remaining_fmt, hours, minutes) + } + + else -> stringResource(R.string.time_over_day) + }.let { bidi.unicodeWrap(it) } + + val short = + stringResource(R.string.transfer_short_status_fmt, transferredSizeStr, totalSizeStr) + val long = stringResource( + R.string.transfer_long_status_fmt, + transferredSizeStr, + totalSizeStr, + transferSpeedStr, + remainingString, + ) + + short to long + } + + Transfer.Status.Declined -> { + val str = stringResource(R.string.transfer_state_declined) + + str to str + } + + is Transfer.Status.Failed -> { + val str = stringResource(R.string.transfer_state_failed) + val details = status.error + + str to "$str\n$details" + } + + Transfer.Status.Finished -> { + val str = stringResource(R.string.transfer_state_finished) + + str to str + } + + is Transfer.Status.FinishedWithErrors -> { + val str = stringResource(R.string.transfer_state_finished_with_errors) + val details = status.errors.joinToString("\n") + + str to "$str\n$details" + } + + Transfer.Status.Initializing -> { + val str = stringResource(R.string.transfer_state_init) + + str to str + } + + Transfer.Status.Paused -> { + val str = stringResource(R.string.transfer_state_paused) + + str to str + } + + Transfer.Status.Stopped -> { + val str = stringResource(R.string.transfer_state_stopped) + + str to str + + } + } +} + +private fun Transfer.getRemainingTime(): Int? { + val now = System.currentTimeMillis() + val timeDiff = (now - startTime) / 1000f + if (timeDiff <= 0) return null + + val avgSpeed = bytesTransferred / timeDiff + if (avgSpeed <= 0) return null + + val secondsRemaining = ((totalSize - bytesTransferred) / avgSpeed).toInt() + return secondsRemaining +} + +@Composable +private fun Transfer.getStatusColor(): Color { + return when (status) { + is Transfer.Status.Failed, is Transfer.Status.FinishedWithErrors -> MaterialTheme.colorScheme.error + Transfer.Status.Finished -> MaterialTheme.colorScheme.primary + Transfer.Status.Transferring -> MaterialTheme.colorScheme.tertiary + else -> LocalContentColor.current + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/manual_connection/ManualConnectionDialog.kt b/app/src/main/java/slowscript/warpinator/feature/manual_connection/ManualConnectionDialog.kt new file mode 100644 index 00000000..7480e87f --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/manual_connection/ManualConnectionDialog.kt @@ -0,0 +1,700 @@ +package slowscript.warpinator.feature.manual_connection + +import android.content.ClipData +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddLink +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.ContentPasteGo +import androidx.compose.material.icons.rounded.Done +import androidx.compose.material.icons.rounded.HistoryToggleOff +import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.BlendModeColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import kotlinx.coroutines.launch +import slowscript.warpinator.R +import slowscript.warpinator.core.data.ManualConnectionResult +import slowscript.warpinator.core.data.WarpinatorViewModel +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.preferences.RecentRemote +import slowscript.warpinator.core.utils.QRCodeBitmaps +import slowscript.warpinator.core.utils.transformers.IPAddressTransformer +import slowscript.warpinator.core.utils.transformers.ProtocolAddressInputValidator + + +data class RecentRemoteOption( + val address: String, + val name: String? = null, + val fromClipboard: Boolean = false, +) + +fun List.toRecentRemoteOptions(): List { + return this.map { RecentRemoteOption(it.host, it.hostname, false) } +} + +sealed interface ManualConnectionDialogState { + data object QRCode : ManualConnectionDialogState + data object QuickSelect : ManualConnectionDialogState + data class Connecting(val address: String) : ManualConnectionDialogState +} + +@Composable +fun ManualConnectionDialog( + address: String? = null, + onDismiss: () -> Unit = {}, + viewModel: WarpinatorViewModel = hiltViewModel(), + dialogState: ManualConnectionDialogState = ManualConnectionDialogState.QRCode, +) { + var dialogState by remember { mutableStateOf(dialogState) } + + val clipboard = LocalClipboard.current + + val address = address ?: viewModel.address + + val onShowQuickSelectDialog = { + dialogState = ManualConnectionDialogState.QuickSelect + } + + when (dialogState) { + ManualConnectionDialogState.QRCode -> { + QRCodeDialog( + address, + clipboard, + onDismiss, + onShowQuickSelectDialog, + ) + } + + ManualConnectionDialogState.QuickSelect -> { + val recentRemotes = viewModel.repository.prefs.recentRemotes + QuickSelectRemoteDialog( + clipboard, + recentRemotes.toRecentRemoteOptions(), + onDismiss, + ) { address -> + dialogState = ManualConnectionDialogState.Connecting(address) + } + } + + is ManualConnectionDialogState.Connecting -> { + ConnectingToRemoteDialog( + onDismiss, + (dialogState as ManualConnectionDialogState.Connecting).address, + viewModel::connectToRemoteHost, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun ConnectingToRemoteDialog( + onDismiss: () -> Unit, + address: String, + onTryRegisterWithHost: suspend (String) -> ManualConnectionResult?, + forceState: ManualConnectionResult? = null, +) { + val unknownErrorLabel = stringResource(R.string.unknown_error) + + val connectionResult by produceState( + initialValue = forceState, key1 = address, + ) { + value = try { + onTryRegisterWithHost(address) + } catch (e: Exception) { + ManualConnectionResult.Error(e.message ?: unknownErrorLabel) + } + } + + @Composable + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + fun warningContainer(warningText: String) { + Surface( + modifier = Modifier + .padding(top = 32.dp) + .size(124.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = MaterialShapes.Cookie9Sided.toShape(), + ) { + Icon( + Icons.Rounded.PriorityHigh, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(24.dp), + ) + } + + Text( + warningText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + ) + } + + @Composable + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + fun errorContainer(errorMessage: String, errorDetails: String? = null) { + Surface( + modifier = Modifier + .padding(top = 32.dp) + .size(124.dp), + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialShapes.SoftBurst.toShape(), + ) { + Icon( + Icons.Rounded.PriorityHigh, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(24.dp), + ) + } + + Text( + errorMessage, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + ) + + if (errorDetails != null) Text( + errorDetails, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.9f), + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + ) + } + + @Composable + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + fun successContainer() { + Surface( + modifier = Modifier + .padding(top = 32.dp) + .size(124.dp), + color = MaterialTheme.colorScheme.primary, + shape = MaterialShapes.Cookie9Sided.toShape(), + ) { + Icon( + Icons.Rounded.Done, + contentDescription = stringResource(R.string.manual_connection_success_label), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(24.dp), + ) + } + } + + AlertDialog( + onDismissRequest = { + if (connectionResult != null) onDismiss() + }, + title = { Text(stringResource(R.string.manual_connection_connecting_title)) }, + icon = { Icon(Icons.Rounded.AddLink, contentDescription = null) }, + modifier = Modifier.widthIn(max = 350.dp), + confirmButton = { + TextButton(onClick = onDismiss, enabled = connectionResult != null) { + Text(stringResource(R.string.done_label)) + } + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + address, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary, + ) + + + when (connectionResult) { + ManualConnectionResult.AlreadyConnected -> { + warningContainer(stringResource(R.string.manual_connection_already_connected)) + } + + is ManualConnectionResult.Error -> { + errorContainer( + stringResource(R.string.manual_connection_failed), + (connectionResult as ManualConnectionResult.Error).message, + ) + + } + + ManualConnectionResult.NotOnSameSubnet -> { + errorContainer(stringResource(R.string.manual_connection_not_same_subnet)) + } + + ManualConnectionResult.RemoteDoesNotSupportManualConnect -> { + errorContainer(stringResource(R.string.manual_connection_not_supported)) + } + + ManualConnectionResult.Success -> { + successContainer() + } + + null -> { + LoadingIndicator( + modifier = Modifier + .padding(top = 32.dp) + .size(124.dp), + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } + }, + ) +} + +@Composable +private fun QRCodeDialog( + address: String, + clipboard: Clipboard, + onDismiss: () -> Unit, + onShowQuickSelectDialog: () -> Unit, +) { + val clipboardCoroutineScope = rememberCoroutineScope() + val url = ProtocolAddressInputValidator.getFullAddressUrl(address) + + val copyUrl: () -> Unit = { + // Copy the url to the clipboard + clipboardCoroutineScope.launch { + val clipData = ClipData.newPlainText("url", url) + clipboard.setClipEntry(ClipEntry(clipData)) + } + } + + val qrCode by produceState(null as Bitmap?, address) { + value = QRCodeBitmaps.generate(url) + } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.widthIn(max = 350.dp), + title = { Text(stringResource(R.string.manual_connection_label)) }, + confirmButton = { + Row { + TextButton(onClick = onShowQuickSelectDialog) { + Text(stringResource(R.string.add_connection_label)) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close_label)) + } + } + }, + icon = { Icon(Icons.Rounded.AddLink, contentDescription = null) }, + text = { + val copyUrlActionLabel = stringResource(R.string.copy_url_action) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + qrCode?.asImageBitmap()?.let { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + modifier = Modifier + .height(256.dp) + .aspectRatio(1f) + .fillMaxWidth() + .padding( + bottom = 12.dp, + ) + .clip(RoundedCornerShape(24.dp)) + .clickable( + onClick = copyUrl, + ) + .semantics(mergeDescendants = true) { + onClick( + label = copyUrlActionLabel, + action = null, + ) + }, + ) { + Image( + it, + contentDescription = stringResource(R.string.qr_code_content_description), + colorFilter = BlendModeColorFilter( + MaterialTheme.colorScheme.primary, BlendMode.SrcIn, + ), + contentScale = ContentScale.FillHeight, + filterQuality = FilterQuality.None, // Force image to be crisp + modifier = Modifier.padding(16.dp), + ) + } + } + Text( + stringResource(R.string.manual_connect_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Justify, + ) + + TextButton( + onClick = copyUrl, + modifier = Modifier + .padding(top = 12.dp) + .semantics { + onClick(label = copyUrlActionLabel, action = null) + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(address) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Icon( + Icons.Rounded.ContentCopy, + null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + } + } + } + + }, + ) +} + +@Composable +private fun QuickSelectRemoteDialog( + clipboard: Clipboard, + recentRemotes: List?, + onDismiss: () -> Unit, + onStartConnection: (String) -> Unit, +) { + val textFieldState = rememberTextFieldState() + val recentRemotes = remember(recentRemotes) { + (recentRemotes ?: emptyList()).toMutableStateList() + } + val validAddressSelected by remember { + derivedStateOf { + textFieldState.text.isNotEmpty() && ProtocolAddressInputValidator.isValidIp( + textFieldState.text.toString(), false, + ) + } + } + + var showValidationError by remember { mutableStateOf(false) } + + val fromClipboardLabel = stringResource(R.string.from_clipboard_recent_remote_label) + LaunchedEffect("GET_CLIPBOARD") { + val clipData = clipboard.getClipEntry()?.clipData + if ((clipData?.itemCount ?: 0) == 0) return@LaunchedEffect + + var clipText = clipData?.getItemAt(0)?.text?.toString() ?: return@LaunchedEffect + if (!clipText.startsWith(ProtocolAddressInputValidator.scheme)) return@LaunchedEffect + + clipText = clipText.removePrefix(ProtocolAddressInputValidator.scheme) + if (!ProtocolAddressInputValidator.isValidIp(clipText, false)) return@LaunchedEffect + + recentRemotes.add( + 0, + RecentRemoteOption( + clipText, + fromClipboardLabel, + true, + ), + ) + } + + val onSubmit = { + if (validAddressSelected) onStartConnection(textFieldState.text.toString()) + else showValidationError = true + } + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.widthIn(max = 350.dp), + title = { Text(stringResource(R.string.manual_connection_label)) }, + confirmButton = { + Button(onClick = onSubmit) { + Text(stringResource(R.string.add_connection_label)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close_label)) + } + }, + icon = { Icon(Icons.Rounded.AddLink, contentDescription = null) }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OutlinedTextField( + textFieldState, + label = { Text(stringResource(R.string.ip_address_label)) }, + modifier = Modifier.fillMaxWidth(), + prefix = { + Text(ProtocolAddressInputValidator.scheme) + }, + isError = !validAddressSelected && showValidationError, + supportingText = { + if (!validAddressSelected && showValidationError) { + Text(stringResource(R.string.invalid_address_error)) + } + }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, keyboardType = KeyboardType.Unspecified, + ), + inputTransformation = ProtocolAddressInputValidator(), + outputTransformation = IPAddressTransformer(MaterialTheme.colorScheme.onSurfaceVariant), + onKeyboardAction = { performDefaultAction -> + onSubmit() + performDefaultAction() + }, + ) + + Text( + stringResource(R.string.recent_remotes_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + ) + + if (recentRemotes.isEmpty()) { + Icon( + Icons.Rounded.HistoryToggleOff, + null, + modifier = Modifier + .padding(vertical = 16.dp) + .size(32.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + stringResource(R.string.no_recent_remotes), + style = MaterialTheme.typography.titleSmall, + ) + } else { + Spacer(modifier = Modifier.size(16.dp)) + } + + recentRemotes.forEachIndexed { index, remote -> + + RecentRemoteSegmentedListTile( + textFieldState, remote, index, recentRemotes.size, + ) + } + } + + }, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun RecentRemoteSegmentedListTile( + textFieldState: TextFieldState, remote: RecentRemoteOption, index: Int, listCount: Int, +) { + SegmentedListItem( + onClick = { + textFieldState.setTextAndPlaceCursorAtEnd(remote.address) + }, + selected = textFieldState.text == remote.address, + shapes = ListItemDefaults.segmentedDynamicShapes(index, listCount), + content = { + Text( + buildAnnotatedString { + val sections = remote.address.split(":") + + append(sections[0]) + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) { + append(":") + append(sections[1]) + } + }, + ) + }, + supportingContent = { + remote.name?.let { + Text( + it, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 1.dp), + trailingContent = { + if (remote.fromClipboard) Icon( + Icons.Rounded.ContentPasteGo, + contentDescription = stringResource(R.string.paste_address_from_clipboard_label), + + ) + else Icon(Icons.Rounded.ChevronRight, contentDescription = null) + }, + ) +} + +@Preview(showBackground = true) +@Composable +fun ManualConnectionDialogPreview() { + WarpinatorTheme { + Scaffold { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + QRCodeDialog( + address = "192.168.0.100:100", + onDismiss = {}, + clipboard = LocalClipboard.current, + onShowQuickSelectDialog = {}, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ManualConnectionQuickSelectDialogPreview() { + WarpinatorTheme { + Scaffold { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + QuickSelectRemoteDialog( + clipboard = LocalClipboard.current, + onDismiss = {}, + recentRemotes = listOf(), + onStartConnection = {}, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ManualConnectionQuickSelectRecentsDialogPreview() { + WarpinatorTheme { + Scaffold { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + QuickSelectRemoteDialog( + clipboard = LocalClipboard.current, onDismiss = {}, + recentRemotes = listOf( + RecentRemoteOption("192.168.0.90:42001", "From clipboard", true), + RecentRemoteOption("192.168.0.89:42001", "Device 1"), + RecentRemoteOption("192.168.0.233:42002", "Device 2"), + ), + onStartConnection = {}, + ) + } + } + } +} + +class ConnectionResultProvider : PreviewParameterProvider { + override val values = sequenceOf( + null, + ManualConnectionResult.Success, + ManualConnectionResult.Error("Timed out after 5000ms"), + ManualConnectionResult.AlreadyConnected, + ManualConnectionResult.NotOnSameSubnet, + ManualConnectionResult.RemoteDoesNotSupportManualConnect, + ) + + override fun getDisplayName(index: Int): String? { + return when (values.elementAt(index)) { + null -> "Connecting" + ManualConnectionResult.Success -> "Success" + ManualConnectionResult.AlreadyConnected -> "Already connected" + is ManualConnectionResult.Error -> "Error" + ManualConnectionResult.NotOnSameSubnet -> "Not on same subnet" + ManualConnectionResult.RemoteDoesNotSupportManualConnect -> "Remote does not support manual connect" + } + } +} + +@Preview(showBackground = true, group = "Connection States") +@Composable +fun ManualConnectionConnectingToDialogPreview( + @PreviewParameter(ConnectionResultProvider::class) result: ManualConnectionResult?, +) { + WarpinatorTheme { + Scaffold { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + ConnectingToRemoteDialog( + address = "192.168.0.100", + forceState = result, + onDismiss = {}, + onTryRegisterWithHost = { result }, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/SettingsScreen.kt b/app/src/main/java/slowscript/warpinator/feature/settings/SettingsScreen.kt new file mode 100644 index 00000000..83c28e67 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/SettingsScreen.kt @@ -0,0 +1,507 @@ +package slowscript.warpinator.feature.settings + +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import slowscript.warpinator.R +import slowscript.warpinator.app.LocalNavController +import slowscript.warpinator.core.design.components.DynamicAvatarCircle +import slowscript.warpinator.core.design.components.MessagesHandlerEffect +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.preferences.ThemeOptions +import slowscript.warpinator.core.network.Server +import slowscript.warpinator.core.utils.ProfilePicturePainter +import slowscript.warpinator.feature.settings.components.OptionsDialog +import slowscript.warpinator.feature.settings.components.ProfilePictureDialog +import slowscript.warpinator.feature.settings.components.SettingsCategoryLabel +import slowscript.warpinator.feature.settings.components.SwitchListItem +import slowscript.warpinator.feature.settings.components.TextInputDialog +import slowscript.warpinator.feature.settings.state.SettingsUiState +import slowscript.warpinator.feature.settings.state.SettingsViewModel + +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel = hiltViewModel(), launchDirPicker: Boolean = false, +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val navController = LocalNavController.current + + val snackbarHostState = remember { SnackbarHostState() } + + MessagesHandlerEffect( + messageProvider = viewModel.uiMessages, snackbarHostState = snackbarHostState, + ) + + val dirPickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + viewModel.setDirectory(uri) + } + } + + // Auto-launch picker if requested via Intent + LaunchedEffect(Unit) { + if (launchDirPicker) dirPickerLauncher.launch(null) + } + + SettingsScreenContent( + state = state, + snackbarHostState = snackbarHostState, + onBackClick = { navController?.popBackStack() }, + onDisplayNameChange = viewModel::setDisplayName, + onProfilePictureChange = viewModel::setProfilePicture, + onPickCustomProfilePicture = viewModel::handleCustomProfilePicture, + onPickDownloadDir = { dirPickerLauncher.launch(null) }, + onResetDownloadDir = viewModel::resetDirectory, + onNotifyIncomingChange = viewModel::setNotifyIncoming, + onAllowOverwriteChange = viewModel::setAllowOverwrite, + onAutoAcceptChange = viewModel::setAutoAccept, + onUseCompressionChange = viewModel::setUseCompression, + onStartOnBootChange = viewModel::setStartOnBoot, + onAutoStopChange = viewModel::setAutoStop, + onDebugLogChange = viewModel::setDebugLog, + onGroupCodeChange = viewModel::setGroupCode, + onServerPortChange = viewModel::setServerPort, + onAuthPortChange = viewModel::setAuthPort, + onNetworkInterfaceChange = viewModel::setNetworkInterface, + onThemeChange = viewModel::updateTheme, + onIntegrateMessagesChange = viewModel::setIntegrateMessages, + onUseDynamicColorsChange = viewModel::setUseDynamicColors, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SettingsScreenContent( + state: SettingsUiState, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + onDisplayNameChange: (String) -> Unit, + onProfilePictureChange: (String) -> Unit, + onPickCustomProfilePicture: (Uri) -> Unit, + onPickDownloadDir: () -> Unit, + onResetDownloadDir: () -> Unit, + onNotifyIncomingChange: (Boolean) -> Unit, + onAllowOverwriteChange: (Boolean) -> Unit, + onAutoAcceptChange: (Boolean) -> Unit, + onUseCompressionChange: (Boolean) -> Unit, + onStartOnBootChange: (Boolean) -> Unit, + onAutoStopChange: (Boolean) -> Unit, + onDebugLogChange: (Boolean) -> Unit, + onGroupCodeChange: (String) -> Unit, + onServerPortChange: (String) -> Unit, + onAuthPortChange: (String) -> Unit, + onNetworkInterfaceChange: (String) -> Unit, + onThemeChange: (ThemeOptions) -> Unit, + onIntegrateMessagesChange: (Boolean) -> Unit, + onUseDynamicColorsChange: (Boolean) -> Unit, +) { + val context = LocalContext.current + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + var showProfileDialog by remember { mutableStateOf(false) } + var editDialogTitle by remember { mutableStateOf(null) } + var editDialogValue by remember { mutableStateOf("") } + var editDialogIsNumber by remember { mutableStateOf(false) } + var onEditConfirm by remember { mutableStateOf<(String) -> Unit>({}) } + var showEditDialog by remember { mutableStateOf(false) } + + var showThemeDialog by remember { mutableStateOf(false) } + var showInterfaceDialog by remember { mutableStateOf(false) } + + val editSemanticModifierBase = Modifier.semantics { + onClick("Edit", null) + } + + fun openEdit( + titleRes: Int, currentValue: String, isNumber: Boolean = false, onConfirm: (String) -> Unit, + ) { + editDialogTitle = titleRes + editDialogValue = currentValue + editDialogIsNumber = isNumber + onEditConfirm = onConfirm + showEditDialog = true + } + + val listItemColors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + MediumFlexibleTopAppBar( + title = { Text(stringResource(R.string.settings_title)) }, + + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + scrollBehavior = scrollBehavior, + + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize(), + contentPadding = innerPadding, + ) { + item { + SettingsCategoryLabel(stringResource(R.string.identity_settings_category)) + + SegmentedListItem( + content = { Text(stringResource(R.string.display_settings_title)) }, + supportingContent = { Text(state.displayName) }, + onClick = { + openEdit( + R.string.display_settings_title, state.displayName, + ) { onDisplayNameChange(it) } + }, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 3), + colors = listItemColors, + modifier = editSemanticModifierBase.padding(bottom = ListItemDefaults.SegmentedGap), + ) + val profilePictureBitmap = remember( + state.profilePictureKey, state.profileImageSignature, + ) { ProfilePicturePainter.getProfilePicture(state.profilePictureKey, context) } + SegmentedListItem( + content = { Text(stringResource(R.string.picture_settings_title)) }, + trailingContent = { + DynamicAvatarCircle( + size = 32.dp, + bitmap = profilePictureBitmap, + ) + }, + onClick = { showProfileDialog = true }, + shapes = ListItemDefaults.segmentedDynamicShapes(2, 3), + colors = listItemColors, + modifier = editSemanticModifierBase, + ) + } + + item { + SettingsCategoryLabel(stringResource(R.string.transfer_settings_category)) + + SegmentedListItem( + content = { Text(stringResource(R.string.download_dir_settings_title)) }, + supportingContent = { Text(state.downloadDirSummary) }, + onClick = { onPickDownloadDir() }, + trailingContent = { + AnimatedVisibility( + visible = state.canResetDir, enter = fadeIn(), exit = fadeOut(), + ) { + IconButton(onClick = onResetDownloadDir) { + Icon(Icons.Default.Restore, contentDescription = "Reset to default") + } + } + }, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 3), + colors = listItemColors, + modifier = editSemanticModifierBase.padding(bottom = ListItemDefaults.SegmentedGap), + ) + + SwitchListItem( + title = stringResource(R.string.notification_settings_title), + summary = stringResource(R.string.notification_settings_summary), + checked = state.notifyIncoming, + onCheckedChange = onNotifyIncomingChange, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + ) + + SwitchListItem( + title = stringResource(R.string.overwrite_settings_title), + summary = if (state.allowOverwrite) stringResource(R.string.overwrite_settings_summary_on) + else stringResource(R.string.overwrite_settings_summary_off), + checked = state.allowOverwrite, + onCheckedChange = onAllowOverwriteChange, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + ) + + SwitchListItem( + title = stringResource(R.string.accept_settings_title), + summary = if (state.autoAccept) stringResource(R.string.accept_settings_summary_on) + else stringResource(R.string.accept_settings_summary_off), + checked = state.autoAccept, + onCheckedChange = onAutoAcceptChange, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + ) + + SwitchListItem( + title = stringResource(R.string.compression_settings_title), + checked = state.useCompression, + onCheckedChange = onUseCompressionChange, + shapes = ListItemDefaults.segmentedDynamicShapes(2, 3), + colors = listItemColors, + ) + } + + item { + SettingsCategoryLabel(stringResource(R.string.app_behavior_settings_category)) + + // Boot Start (Only for Android < 15/Vanilla Ice Cream) + val isBootStartSupported = + Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM + + SwitchListItem( + title = stringResource(R.string.boot_settings_title), + summary = if (isBootStartSupported) null else stringResource(R.string.boot_settings_summary_a15), + checked = state.startOnBoot, + enabled = isBootStartSupported, + onCheckedChange = onStartOnBootChange, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 3), + colors = listItemColors, + ) + + SwitchListItem( + title = stringResource(R.string.stop_service_when_leaving_title), + summary = if (state.autoStop) stringResource(R.string.stop_service_when_leaving_summary_on) + else stringResource(R.string.stop_service_when_leaving_summary_off), + checked = state.autoStop, + enabled = state.isAutoStopEnabled, // DISABLED if BootStart is ON + onCheckedChange = onAutoStopChange, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + ) + + SwitchListItem( + title = stringResource(R.string.export_log_settings_title), + summary = "Android/data/slowscript.warpinator/files/", // Hardcoded per XML + checked = state.debugLog, + onCheckedChange = onDebugLogChange, + shapes = ListItemDefaults.segmentedDynamicShapes(2, 3), + colors = listItemColors, + ) + + } + + item { + SettingsCategoryLabel(stringResource(R.string.network_settings_category)) + + SegmentedListItem( + content = { Text(stringResource(R.string.group_code_settings_title)) }, + supportingContent = { Text(state.groupCode) }, + onClick = { + openEdit( + R.string.group_code_settings_title, state.groupCode, + ) { onGroupCodeChange(it) } + }, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 3), + colors = listItemColors, + modifier = editSemanticModifierBase.padding(bottom = ListItemDefaults.SegmentedGap), + ) + + SegmentedListItem( + content = { Text(stringResource(R.string.port_settings_title)) }, + supportingContent = { Text(state.port) }, + onClick = { + openEdit( + R.string.port_settings_title, state.port, isNumber = true, + ) { onServerPortChange(it) } + }, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + modifier = editSemanticModifierBase.padding(bottom = ListItemDefaults.SegmentedGap), + ) + + SegmentedListItem( + content = { Text(stringResource(R.string.auth_port_settings_title)) }, + supportingContent = { Text(state.authPort) }, + onClick = { + openEdit( + R.string.auth_port_settings_title, state.authPort, isNumber = true, + ) { onAuthPortChange(it) } + }, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + modifier = editSemanticModifierBase.padding(bottom = ListItemDefaults.SegmentedGap), + ) + + SegmentedListItem( + content = { Text(stringResource(R.string.network_interface_settings_title)) }, + supportingContent = { + Text( + if (state.networkInterface != Server.NETWORK_INTERFACE_AUTO) stringResource( + R.string.network_interface_settings_summary, state.networkInterface, + ) + else state.networkInterface, + ) + }, + onClick = { showInterfaceDialog = true }, + shapes = ListItemDefaults.segmentedDynamicShapes(2, 3), + colors = listItemColors, + modifier = editSemanticModifierBase, + ) + } + + item { + SettingsCategoryLabel(stringResource(R.string.aspect_settings_category)) + + val themeLabelResId = state.themeMode.label + val dynamicColorsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + SwitchListItem( + title = stringResource(R.string.integrate_messages_with_transfers_title), + summary = if (state.integrateMessages) stringResource(R.string.integrate_messages_with_transfers_subtitle_on) else stringResource( + R.string.integrate_messages_with_transfers_subtitle_off, + ), + checked = state.integrateMessages, + onCheckedChange = onIntegrateMessagesChange, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 3), + colors = listItemColors, + ) + + + SegmentedListItem( + content = { Text(stringResource(R.string.theme_settings_title)) }, + supportingContent = { Text(stringResource(themeLabelResId)) }, + onClick = { showThemeDialog = true }, + shapes = ListItemDefaults.segmentedDynamicShapes(1, 3), + colors = listItemColors, + modifier = editSemanticModifierBase.padding(bottom = ListItemDefaults.SegmentedGap), + ) + + SwitchListItem( + title = stringResource(R.string.use_dynamic_colors_title), + checked = state.dynamicColors, + onCheckedChange = onUseDynamicColorsChange, + shapes = ListItemDefaults.segmentedDynamicShapes(2, 3), + colors = listItemColors, + enabled = dynamicColorsSupported, + ) + + Spacer(Modifier.height(32.dp)) + } + } + } + + if (showProfileDialog) { + ProfilePictureDialog( + currentKey = state.profilePictureKey, + onDismiss = { showProfileDialog = false }, + onSelectKey = { + onProfilePictureChange(it) + showProfileDialog = false + }, + onSelectCustom = onPickCustomProfilePicture, + imageSignature = state.profileImageSignature, + ) + } else if (showEditDialog) { + TextInputDialog( + titleRes = editDialogTitle!!, + initialValue = editDialogValue, + isNumber = editDialogIsNumber, + onDismiss = { showEditDialog = false }, + onConfirm = { newVal -> + onEditConfirm(newVal) + showEditDialog = false + }, + ) + } else if (showThemeDialog) { + val values = ThemeOptions.entries + + OptionsDialog( + title = stringResource(R.string.theme_settings_title), + options = values.map { e -> stringResource(e.label) }.toList(), + currentSelectionIndex = values.indexOf(state.themeMode), + onDismiss = { showThemeDialog = false }, + onOptionSelected = { idx -> onThemeChange(values[idx]) }, + ) + } else if (showInterfaceDialog) { + val options = state.interfaceEntries.map { it.first } + val values = state.interfaceEntries.map { it.second } + + OptionsDialog( + title = stringResource(R.string.network_interface_settings_title), + options = options, + currentSelectionIndex = values.indexOf(state.networkInterface), + onDismiss = { showInterfaceDialog = false }, + onOptionSelected = { idx -> onNetworkInterfaceChange(values[idx]) }, + ) + } +} + +@PreviewLightDark +@Composable +fun SettingsScreenPreview() { + WarpinatorTheme { + SettingsScreenContent( + state = SettingsUiState(), + snackbarHostState = SnackbarHostState(), + onBackClick = {}, + onDisplayNameChange = {}, + onProfilePictureChange = {}, + onPickCustomProfilePicture = {}, + onPickDownloadDir = {}, + onResetDownloadDir = {}, + onNotifyIncomingChange = {}, + onAllowOverwriteChange = {}, + onAutoAcceptChange = {}, + onUseCompressionChange = {}, + onStartOnBootChange = {}, + onAutoStopChange = {}, + onDebugLogChange = {}, + onGroupCodeChange = {}, + onServerPortChange = {}, + onAuthPortChange = {}, + onNetworkInterfaceChange = {}, + onThemeChange = {}, + onIntegrateMessagesChange = {}, + onUseDynamicColorsChange = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/components/OptionsDialog.kt b/app/src/main/java/slowscript/warpinator/feature/settings/components/OptionsDialog.kt new file mode 100644 index 00000000..2afb6577 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/components/OptionsDialog.kt @@ -0,0 +1,176 @@ +package slowscript.warpinator.feature.settings.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +/** + * A Composable function that displays a modal dialog allowing the user to select one option from a list using radio buttons. + * + * This dialog manages its own selection state internally. The final selection is only communicated back to the caller + * via [onOptionSelected] when the user clicks the "OK" button. Clicking "Cancel" or dismissing the dialog + * discards any changes. + * + * @param title The text displayed at the top of the dialog. + * @param options A list of strings representing the available options to choose from. + * @param currentSelectionIndex The index of the currently active option (initial state). + * @param onDismiss A callback invoked when the dialog is dismissed (e.g., clicking outside, back press, or "Cancel"). + * @param onOptionSelected A callback invoked with the index of the selected option when the "OK" button is clicked. + */ +@Composable +fun OptionsDialog( + title: String, + options: List, + currentSelectionIndex: Int, + onDismiss: () -> Unit, + onOptionSelected: (Int) -> Unit, +) { + var selectedIndex by remember(currentSelectionIndex) { mutableIntStateOf(currentSelectionIndex) } + + Dialog( + onDismissRequest = onDismiss, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + ) { + Column( + modifier = Modifier.selectableGroup() // Accessibility optimization + + ) { + Box( + Modifier + .padding( + PaddingValues( + bottom = 16.dp, top = 24.dp, start = 24.dp, end = 24.dp + ) + ) + .align( + Alignment.Start + ) + ) { + Text( + title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis + ) + } + HorizontalDivider() + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(1f, false) + ) { + options.forEachIndexed { index, label -> + Box( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = (index == selectedIndex), + onClick = { selectedIndex = index }, + role = Role.RadioButton + ) + + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) // Minimum touch target height + .padding(24.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (index == selectedIndex), + onClick = null // Null because the Row handles the click + ) + Spacer(Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + HorizontalDivider() + Box(modifier = Modifier.align(Alignment.End)) { + val textStyle = MaterialTheme.typography.bodyMedium + val buttonContentColor = MaterialTheme.colorScheme.primary + + FlowRow( + modifier = Modifier.padding(24.dp, 16.dp, 24.dp, 24.dp) + ) { + TextButton(onClick = onDismiss) { + Text( + stringResource(android.R.string.cancel), + style = textStyle, + color = buttonContentColor + ) + + } + TextButton( + onClick = { + onOptionSelected(selectedIndex) + onDismiss() + }) { + Text( + stringResource(android.R.string.ok), + style = textStyle, + color = buttonContentColor + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun OptionsDialogPreview() { + Scaffold { innerPadding -> + innerPadding + OptionsDialog( + title = "Options", + options = listOf("Option 1", "Option 2", "Option 3"), + currentSelectionIndex = 1, + onDismiss = {}, + onOptionSelected = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/components/ProfilePictureDialog.kt b/app/src/main/java/slowscript/warpinator/feature/settings/components/ProfilePictureDialog.kt new file mode 100644 index 00000000..b9e17981 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/components/ProfilePictureDialog.kt @@ -0,0 +1,298 @@ +package slowscript.warpinator.feature.settings.components + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.FileDropTargetIndicator +import slowscript.warpinator.core.design.components.fileDropTarget +import slowscript.warpinator.core.design.components.rememberDropTargetState +import slowscript.warpinator.core.utils.ProfilePicturePainter + +/** + * A dialog composable that allows the user to view and select a profile picture. + * + * It provides a grid of predefined profile avatars and an option to select a custom image. + * The currently selected image is displayed prominently at the top. + * + * @param currentKey The key representing the currently active profile picture (e.g., "1", "2", or "profilePic.png"). + * @param onDismiss Callback invoked when the user attempts to close the dialog (e.g., clicks the close button or back). + * @param onSelectKey Callback invoked when the user confirms their selection by clicking "Save". Passes the selected image key. + * @param onSelectCustom Callback invoked when the user clicks the button to add a custom photo. + * @param imageSignature A signature (timestamp/hash) used to force a refresh of the image painter when the underlying custom image file changes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilePictureDialog( + currentKey: String, + onDismiss: () -> Unit, + onSelectKey: (String) -> Unit, + onSelectCustom: (Uri) -> Unit, + imageSignature: Long, +) { + var selectedKey by remember { mutableStateOf(currentKey) } + val context = LocalContext.current + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri: Uri? -> + if (uri != null) { + try { + context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (_: Exception) { + // Ignore if specific permission grant fails (file might still be readable once) + } + onSelectCustom(uri) + } + } + + val fileDropTargetState = rememberDropTargetState( + onUrisDropped = onUrisDropped@{ uris -> + if (uris.size != 1) return@onUrisDropped false + onSelectCustom(uris.first()) + true + }, + shouldStartDragAndDrop = shouldStartDragAndDrop@{ event -> + val description = + event.toAndroidDragEvent().clipDescription ?: return@shouldStartDragAndDrop false + return@shouldStartDragAndDrop when { + description.mimeTypeCount != 1 -> false + description.hasMimeType("image/*") -> true + else -> false + } + }, + ) + + LaunchedEffect(imageSignature) { + if (imageSignature > 0 && selectedKey != "profilePic.png") { + selectedKey = "profilePic.png" + } + } + + Dialog( + onDismissRequest = onDismiss, properties = DialogProperties( + usePlatformDefaultWidth = false, decorFitsSystemWindows = false + ) + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource(R.string.change_profile_picture_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.close_label), + ) + } + }, + actions = { + Button( + onClick = { + onSelectKey(selectedKey) + }, modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text(stringResource(R.string.save_label)) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .fileDropTarget(fileDropTargetState), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = Modifier + .padding( + top = 160.dp, start = 16.dp, end = 16.dp, bottom = 32.dp + ) + .fillMaxWidth() + .clip(MaterialTheme.shapes.extraLarge), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + LazyVerticalGrid( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + columns = GridCells.Adaptive(64.dp), + contentPadding = PaddingValues( + top = 96.dp, start = 16.dp, end = 16.dp, bottom = 16.dp + ) + ) { + item { + Box(contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { + imagePickerLauncher.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly, + ), + ) + }, + shape = CircleShape, + modifier = Modifier + .height(64.dp) + .aspectRatio(1f), + ) { + Icon( + Icons.Default.AddPhotoAlternate, + contentDescription = stringResource(R.string.add_custom_picture), + ) + } + } + } + + items(ProfilePicturePainter.colorsLength) { index -> + val key = index.toString() + val isSelected = (key == selectedKey) + val bmp = remember(key) { + ProfilePicturePainter.getProfilePicture( + key, context + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .semantics { + selected = isSelected + } + .clickable { selectedKey = key }, + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = stringResource( + R.string.profile_picture_label, + index + 1, + ), + modifier = Modifier.fillMaxSize(), + ) + if (isSelected) { + Box( + modifier = Modifier + .fillMaxSize() + .border( + 4.dp, + MaterialTheme.colorScheme.tertiary, + CircleShape + ) + ) + } + } + } + } + } + FileDropTargetIndicator( + fileDropTargetState.uiMode, + text = stringResource(R.string.drop_image_here_to_set), + modifier = Modifier.fillMaxSize(), + ) + } + Box( + modifier = Modifier.offset(y = (80).dp), contentAlignment = Alignment.Center + ) { + val currentBitmap = remember(selectedKey, imageSignature) { + ProfilePicturePainter.getProfilePicture(selectedKey, context, true) + } + + Image( + bitmap = currentBitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(160.dp) + .border( + 8.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape + ) + .padding(8.dp) + .clip(CircleShape) + ) + } + } + } + } +} + +@Preview +@Composable +fun ProfilePictureDialogPreview() { + + ProfilePictureDialog( + currentKey = "1", + onDismiss = {}, + onSelectKey = {}, + onSelectCustom = {}, + imageSignature = 0 + ) + +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/components/SettingsCategoryLabel.kt b/app/src/main/java/slowscript/warpinator/feature/settings/components/SettingsCategoryLabel.kt new file mode 100644 index 00000000..cc511892 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/components/SettingsCategoryLabel.kt @@ -0,0 +1,36 @@ +package slowscript.warpinator.feature.settings.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsCategoryLabel(title: String) { + if (title.isNotEmpty()) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 10.dp) + .padding( + top = 32.dp, bottom = 12.dp, + ) + .semantics { + heading() + }, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsCategoryLabelPreview() { + SettingsCategoryLabel("Category Label") +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/components/SwitchListItem.kt b/app/src/main/java/slowscript/warpinator/feature/settings/components/SwitchListItem.kt new file mode 100644 index 00000000..9ff96e01 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/components/SwitchListItem.kt @@ -0,0 +1,68 @@ +package slowscript.warpinator.feature.settings.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes + + +/** + * A composable list item component featuring a switch toggle. + * + * This component is typically used in settings screens to represent boolean preferences or + * toggleable options. It combines a text label with a switch control, handling the layout + * and interaction states appropriate for a list item context. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SwitchListItem( + title: String, + summary: String? = null, + checked: Boolean, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, + shapes: ListItemShapes, + colors: ListItemColors, +) { + SegmentedListItem( + content = { Text(title) }, + supportingContent = if (summary != null) { + { Text(summary) } + } else null, + trailingContent = { + Switch( + checked = checked, onCheckedChange = onCheckedChange, enabled = enabled + ) + }, + enabled = enabled, + checked = checked, + onCheckedChange = { onCheckedChange(!checked) }, + shapes = shapes, + colors = colors, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = ListItemDefaults.SegmentedGap) + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Preview +@Composable +fun SwitchListItemPreview() { + SwitchListItem( + title = "Example Switch", + summary = "This is a summary", + checked = true, + onCheckedChange = {}, + shapes = ListItemDefaults.segmentedDynamicShapes(0, 1), + colors = ListItemDefaults.segmentedColors() + ) +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/components/TextInputDialog.kt b/app/src/main/java/slowscript/warpinator/feature/settings/components/TextInputDialog.kt new file mode 100644 index 00000000..8e26305c --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/components/TextInputDialog.kt @@ -0,0 +1,74 @@ +package slowscript.warpinator.feature.settings.components + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import slowscript.warpinator.R + +/** + * A reusable AlertDialog composable that prompts the user for a text input. + * + * It features a title, a single-line text field, and standard "OK" (Confirm) and + * "Cancel" (Dismiss) buttons. + * + * @param titleRes The string resource ID for the dialog title. + * @param initialValue The starting text value to be displayed in the text field. + * @param isNumber If true, configures the keyboard for numeric input; otherwise defaults to standard text input. + * @param onDismiss Callback invoked when the user dismisses the dialog (either via the "Cancel" button or clicking outside). + * @param onConfirm Callback invoked when the user clicks the "OK" button. Passes the current text value as a parameter. + */ +@Composable +fun TextInputDialog( + titleRes: Int, + initialValue: String, + isNumber: Boolean = false, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var text by remember { mutableStateOf(initialValue) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(titleRes)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) + else KeyboardOptions.Default + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(text); onDismiss() }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, + ) +} + +@Preview(showBackground = true) +@Composable +fun TextInputDialogPreview() { + Scaffold { innerPadding -> + innerPadding + TextInputDialog( + titleRes = R.string.port_settings_title, + initialValue = "8080", + onDismiss = {}, + onConfirm = {}, + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/messages/FailedToSaveProfilePicture.kt b/app/src/main/java/slowscript/warpinator/feature/settings/messages/FailedToSaveProfilePicture.kt new file mode 100644 index 00000000..819260ca --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/messages/FailedToSaveProfilePicture.kt @@ -0,0 +1,21 @@ +package slowscript.warpinator.feature.settings.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class FailedToSaveProfilePicture( + val exception: Exception, +) : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.failed_to_save_profile_picture, exception), + duration = SnackbarDuration.Long, + ) + + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/messages/NeedsRestartMessage.kt b/app/src/main/java/slowscript/warpinator/feature/settings/messages/NeedsRestartMessage.kt new file mode 100644 index 00000000..5beec2b5 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/messages/NeedsRestartMessage.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.feature.settings.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class NeedsRestartMessage : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.requires_restart_warning), + duration = SnackbarDuration.Long, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/messages/PortOutOfBounds.kt b/app/src/main/java/slowscript/warpinator/feature/settings/messages/PortOutOfBounds.kt new file mode 100644 index 00000000..3341fc77 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/messages/PortOutOfBounds.kt @@ -0,0 +1,18 @@ +package slowscript.warpinator.feature.settings.messages + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import slowscript.warpinator.R +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.model.ui.UiMessageState + +class PortOutOfBounds : UiMessage() { + @Composable + override fun getState(): UiMessageState { + return UiMessageState( + message = stringResource(R.string.port_range_warning), + duration = SnackbarDuration.Long, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/settings/state/SettingsViewModel.kt b/app/src/main/java/slowscript/warpinator/feature/settings/state/SettingsViewModel.kt new file mode 100644 index 00000000..bb9b211a --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/settings/state/SettingsViewModel.kt @@ -0,0 +1,312 @@ +package slowscript.warpinator.feature.settings.state + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Environment +import androidx.core.graphics.scale +import androidx.core.net.toUri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import slowscript.warpinator.core.model.preferences.ThemeOptions +import slowscript.warpinator.core.model.ui.UiMessage +import slowscript.warpinator.core.system.PreferenceManager +import slowscript.warpinator.core.utils.Utils +import slowscript.warpinator.feature.settings.messages.FailedToSaveProfilePicture +import slowscript.warpinator.feature.settings.messages.NeedsRestartMessage +import slowscript.warpinator.feature.settings.messages.PortOutOfBounds +import java.io.File +import javax.inject.Inject + +/** + * Represents the immutable UI state for the Settings screen. + * + * This state object aggregates all user-configurable preferences, including identity, + * transfer rules, application behavior, network configuration, and visual themes. + * It is exposed via a [kotlinx.coroutines.flow.StateFlow] in the [SettingsViewModel]. + * + */ +data class SettingsUiState( + // Identity + val displayName: String = PreferenceManager.DEFAULT_DISPLAY_NAME, + val profilePictureKey: String = PreferenceManager.DEFAULT_PROFILE_PICTURE, + val profileImageSignature: Long = 0, + + // Transfer + val downloadDir: String = "", + val downloadDirSummary: String = "", + val canResetDir: Boolean = false, + val notifyIncoming: Boolean = true, + val allowOverwrite: Boolean = false, + val autoAccept: Boolean = false, + val useCompression: Boolean = false, + + // App Behavior / Boot + val startOnBoot: Boolean = false, + val autoStop: Boolean = true, + val isAutoStopEnabled: Boolean = true, + val debugLog: Boolean = false, + + // Network + val groupCode: String = PreferenceManager.DEFAULT_GROUP_CODE, + val port: String = PreferenceManager.DEFAULT_PORT, + val authPort: String = PreferenceManager.DEFAULT_AUTH_PORT, + val networkInterface: String = PreferenceManager.DEFAULT_NETWORK_INTERFACE, + val interfaceEntries: List> = emptyList(), + + // Aspect + val themeMode: ThemeOptions = ThemeOptions.SYSTEM_DEFAULT, + val integrateMessages: Boolean = false, + val dynamicColors: Boolean = true, +) + +@HiltViewModel +class SettingsViewModel @Inject constructor( + application: Application, + private val preferenceManager: PreferenceManager, +) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState = _uiState.asStateFlow() + + private val _uiMessages = Channel(Channel.BUFFERED) + val uiMessages = _uiMessages.receiveAsFlow() + + private val preferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> + loadSettings() + } + + init { + preferenceManager.prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + loadSettings() + loadInterfaces() + } + + override fun onCleared() { + preferenceManager.prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + super.onCleared() + } + + private fun loadSettings() { + val savedDownloadPath = preferenceManager.downloadDirUri + val isCustomDir = savedDownloadPath?.startsWith("content") ?: false + + // Logic for default directory summary + val downloadPathSummary = if (isCustomDir) { + savedDownloadPath.toUri().path ?: savedDownloadPath + } else if (savedDownloadPath == null) { + "Tap to set" + } else { + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + PreferenceManager.DIR_NAME_WARPINATOR, + ).absolutePath + } + + + val isStartOnBootEnabled = preferenceManager.bootStart + + _uiState.update { state -> + state.copy( + // Identity + displayName = preferenceManager.displayName, + profilePictureKey = preferenceManager.profilePicture + ?: PreferenceManager.DEFAULT_PROFILE_PICTURE, + + // Transfer + downloadDir = savedDownloadPath ?: "", + downloadDirSummary = downloadPathSummary, + canResetDir = isCustomDir, + notifyIncoming = preferenceManager.notifyIncoming, + allowOverwrite = preferenceManager.allowOverwrite, + autoAccept = preferenceManager.autoAccept, + useCompression = preferenceManager.useCompression, + + // Boot / App Behavior + startOnBoot = isStartOnBootEnabled, + autoStop = preferenceManager.autoStop, + isAutoStopEnabled = !isStartOnBootEnabled, + debugLog = preferenceManager.debugLog, + + // Network + groupCode = preferenceManager.groupCode, + port = preferenceManager.port.toString(), + authPort = preferenceManager.authPort.toString(), + networkInterface = preferenceManager.networkInterface, + + // Aspect + themeMode = ThemeOptions.fromKey(preferenceManager.theme), + integrateMessages = preferenceManager.integrateMessages, + dynamicColors = preferenceManager.dynamicColors, + ) + } + } + + fun setDisplayName(value: String) { + preferenceManager.setDisplayName(value) + } + + fun setGroupCode(value: String) { + preferenceManager.setGroupCode(value) + viewModelScope.launch { _uiMessages.send(NeedsRestartMessage()) } + } + + fun setServerPort(value: String) { + setPortInternal(value) { preferenceManager.setServerPort(it) } + } + + fun setAuthPort(value: String) { + setPortInternal(value) { preferenceManager.setAuthPort(it) } + } + + private fun setPortInternal(value: String, saveAction: (String) -> Unit) { + val parsedPort = value.toIntOrNull() + if (parsedPort == null || parsedPort !in 1024..65535) { + viewModelScope.launch { + _uiMessages.send(PortOutOfBounds()) + } + return + } + saveAction(value) + viewModelScope.launch { _uiMessages.send(NeedsRestartMessage()) } + } + + fun setNetworkInterface(value: String) { + preferenceManager.setNetworkInterface(value) + } + + fun setNotifyIncoming(value: Boolean) { + preferenceManager.setNotifyIncoming(value) + } + + fun setAllowOverwrite(value: Boolean) { + preferenceManager.setAllowOverwrite(value) + } + + fun setAutoAccept(value: Boolean) { + preferenceManager.setAutoAccept(value) + } + + fun setUseCompression(value: Boolean) { + preferenceManager.setUseCompression(value) + } + + fun setStartOnBoot(value: Boolean) { + preferenceManager.setStartOnBoot(value) + } + + fun setAutoStop(value: Boolean) { + preferenceManager.setAutoStop(value) + } + + fun setDebugLog(value: Boolean) { + preferenceManager.setDebugLog(value) + viewModelScope.launch { _uiMessages.send(NeedsRestartMessage()) } + } + + fun setDirectory(uri: Uri) { + preferenceManager.setDirectory(uri) + } + + fun resetDirectory() { + preferenceManager.resetDirectory() + } + + fun setProfilePicture(key: String) { + _uiState.update { it.copy(profileImageSignature = 0) } + preferenceManager.setProfilePictureKey(key) + } + + fun updateTheme(value: ThemeOptions) { + preferenceManager.setTheme(value) + } + + fun setIntegrateMessages(value: Boolean) { + preferenceManager.setIntegrateMessages(value) + } + + fun setUseDynamicColors(value: Boolean) { + preferenceManager.setDynamicColors(value) + } + + + private fun loadInterfaces() { + viewModelScope.launch(Dispatchers.IO) { + val networkInterfaceNames = + Utils.networkInterfaces ?: arrayOf("Failed to get network interfaces") + val interfaceDropdownEntries = mutableListOf>() + + interfaceDropdownEntries.add("Auto" to PreferenceManager.DEFAULT_NETWORK_INTERFACE) + + for (interfaceName in networkInterfaceNames) { + if (interfaceName == null) continue + var interfaceDisplayLabel = interfaceName + try { + val ip = Utils.getIPForIfaceName(interfaceName) + if (ip != null) { + interfaceDisplayLabel += " (${ip.address.hostAddress} /${ip.prefixLength})" + } + } catch (_: Exception) { /* Ignored */ + } + interfaceDropdownEntries.add(interfaceDisplayLabel to interfaceName) + } + + _uiState.update { it.copy(interfaceEntries = interfaceDropdownEntries) } + } + } + + fun handleCustomProfilePicture(uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + try { + application.contentResolver.openInputStream(uri)?.use { inputStream -> + val originalBitmap = BitmapFactory.decodeStream(inputStream) ?: return@use + + val maxDimension = 512 + val (outW, outH) = if (originalBitmap.width > originalBitmap.height) { + maxDimension to (originalBitmap.height * maxDimension) / originalBitmap.width + } else { + (originalBitmap.width * maxDimension) / originalBitmap.height to maxDimension + } + + // Create scaled bitmap + val scaledBitmap = originalBitmap.scale(outW, outH) + + // Save to internal storage + application.openFileOutput( + PreferenceManager.FILE_PROFILE_PIC, + Context.MODE_PRIVATE, + ).use { os -> + scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, os) + } + + _uiState.update { + it.copy( + // Update timestamp to force reload + profileImageSignature = System.currentTimeMillis(), + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + viewModelScope.launch { + _uiMessages.send( + FailedToSaveProfilePicture(e), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/feature/share/ShareDialog.kt b/app/src/main/java/slowscript/warpinator/feature/share/ShareDialog.kt new file mode 100644 index 00000000..fd35ad33 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/share/ShareDialog.kt @@ -0,0 +1,524 @@ +package slowscript.warpinator.feature.share + +import android.content.Context +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DevicesOther +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import slowscript.warpinator.R +import slowscript.warpinator.core.data.WarpinatorViewModel +import slowscript.warpinator.core.design.components.DynamicAvatarCircle +import slowscript.warpinator.core.design.components.TooltipIconButton +import slowscript.warpinator.core.design.shapes.segmentedDynamicShapes +import slowscript.warpinator.core.design.shapes.segmentedHorizontalDynamicShapes +import slowscript.warpinator.core.design.theme.WarpinatorTheme +import slowscript.warpinator.core.model.Remote +import slowscript.warpinator.core.model.Remote.RemoteStatus +import slowscript.warpinator.core.utils.RemoteDisplayInfo +import slowscript.warpinator.core.utils.Utils +import slowscript.warpinator.feature.manual_connection.ManualConnectionDialog +import slowscript.warpinator.feature.share.components.ShareMenu + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareDialog( + onDismiss: () -> Unit, + uris: List, + text: String?, + viewModel: WarpinatorViewModel = hiltViewModel(), + onOpenRemote: (String, Boolean) -> Unit, +) { + val remotes = viewModel.remoteListState.collectAsStateWithLifecycle() + + var showManualConnectionDialog by remember { mutableStateOf(false) } + + val onSendUris = { remote: Remote, uris: List -> + viewModel.sendUris(remote, uris, false) + onOpenRemote(remote.uuid, false) + onDismiss() + } + + val onSendText = { remote: Remote, text: String -> + viewModel.sendTextMessage(remote, text) + onOpenRemote(remote.uuid, true) + onDismiss() + } + + val onShowManualConnectionDialog = { + showManualConnectionDialog = true + } + + BoxWithConstraints { + if (maxWidth < 600.dp) { + ShareDialogFullscreenWrapper( + onDismiss = onDismiss, + remotes = remotes.value, + uris = uris, + text = text, + onSendUris = onSendUris, + onSendText = onSendText, + onShowManualConnectionDialog = onShowManualConnectionDialog, + onRescan = viewModel::rescan, + onReannounce = viewModel::reannounce, + ) + } else { + ShareDialogFloatingWrapper( + onDismiss = onDismiss, + remotes = remotes.value, + uris = uris, + text = text, + onSendUris = onSendUris, + onSendText = onSendText, + onShowManualConnectionDialog = onShowManualConnectionDialog, + onRescan = viewModel::rescan, + onReannounce = viewModel::reannounce, + ) + } + } + + if (showManualConnectionDialog) ManualConnectionDialog( + onDismiss = { showManualConnectionDialog = false }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareDialogFullscreenWrapper( + onDismiss: () -> Unit, + remotes: List, + uris: List, + text: String?, + onSendUris: (Remote, List) -> Unit = { _: Remote, _: List -> }, + onSendText: (Remote, String) -> Unit = { _: Remote, _: String -> }, + onShowManualConnectionDialog: () -> Unit = {}, + onRescan: () -> Unit = {}, + onReannounce: () -> Unit = {}, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, decorFitsSystemWindows = false, + ), + ) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + title = { + Text( + stringResource(R.string.share_with_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(android.R.string.cancel), + ) + } + }, + ) + }, + ) { innerPadding -> + ShareDialogContent( + innerPadding = innerPadding + PaddingValues(horizontal = 16.dp), + remotes = remotes, + uris = uris, + text = text, + onSendUris = onSendUris, + onSendText = onSendText, + onShowManualConnectionDialog = onShowManualConnectionDialog, + onRescan = onRescan, + onReannounce = onReannounce, + ) + } + } +} + +@Composable +private fun ShareDialogFloatingWrapper( + onDismiss: () -> Unit, + remotes: List, + uris: List, + text: String?, + onSendUris: (Remote, List) -> Unit = { _: Remote, _: List -> }, + onSendText: (Remote, String) -> Unit = { _: Remote, _: String -> }, + onShowManualConnectionDialog: () -> Unit = {}, + onRescan: () -> Unit = {}, + onReannounce: () -> Unit = {}, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.share_with_title)) }, + confirmButton = { TextButton(onDismiss) { Text(stringResource(android.R.string.cancel)) } }, + icon = { Icon(Icons.Rounded.Share, contentDescription = null) }, + text = { + ShareDialogContent( + innerPadding = PaddingValues(), + remotes = remotes, + uris = uris, + text = text, + onSendUris = onSendUris, + onSendText = onSendText, + onShowManualConnectionDialog = onShowManualConnectionDialog, + onRescan = onRescan, + onReannounce = onReannounce, + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ShareDialogContent( + innerPadding: PaddingValues, + remotes: List, + uris: List, + text: String?, + onSendUris: (Remote, List) -> Unit, + onSendText: (Remote, String) -> Unit, + onShowManualConnectionDialog: () -> Unit, + onRescan: () -> Unit, + onReannounce: () -> Unit, +) { + var editedText by rememberSaveable { mutableStateOf(text) } + var isEditing by remember { mutableStateOf(false) } + val textMode = uris.isEmpty() && text != null + + var listTileHeight by remember { mutableStateOf(1.dp) } + val density = LocalDensity.current + val context = LocalContext.current + + val filteredRemotes = if (textMode) { + remotes.filter { it.supportsTextMessages && it.status == RemoteStatus.Connected } + } else { + remotes.filter { it.status == RemoteStatus.Connected } + } + + val supportingContent = + if (textMode) stringResource(R.string.tap_to_edit_hint) else rememberFormattedFileNames( + uris, + context, + ) + + LazyColumn( + contentPadding = innerPadding, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + AnimatedContent( + targetState = isEditing, + modifier = Modifier.weight(1f), + label = "TextEditAnimation", + transitionSpec = { + fadeIn() togetherWith fadeOut() using SizeTransform() + }, + ) { editing -> + + if (editing) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = editedText.orEmpty(), + onValueChange = { editedText = it }, + suffix = { + TooltipIconButton( + onClick = { isEditing = false }, + icon = Icons.Rounded.Check, + description = stringResource(R.string.confirm_edit_label), + ) + }, + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + unfocusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + unfocusedSuffixColor = MaterialTheme.colorScheme.onSecondaryContainer, + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + focusedSuffixColor = MaterialTheme.colorScheme.onSecondaryContainer, + unfocusedBorderColor = MaterialTheme.colorScheme.secondaryContainer, + focusedBorderColor = MaterialTheme.colorScheme.primary, + ), + shape = MaterialTheme.shapes.large, + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } else SegmentedListItem( + onClick = { + isEditing = true + }, + enabled = textMode, + content = { + Text( + if (textMode) editedText.orEmpty() else pluralStringResource( + R.plurals.files_selected, + uris.size, + uris.size, + ), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = supportingContent?.let { text -> + { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + shapes = ListItemDefaults.segmentedHorizontalDynamicShapes( + index = 0, + count = 2, + ), + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + supportingContentColor = MaterialTheme.colorScheme.onSecondaryContainer.copy( + alpha = 0.7f, + ), + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + disabledSupportingContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + modifier = Modifier.onSizeChanged { + listTileHeight = with(density) { it.height.toDp() } + }, + ) + } + AnimatedVisibility( + visible = !isEditing, + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + ShareMenu( + size = listTileHeight, + onRescan = onRescan, + onManualConnectionClick = onShowManualConnectionDialog, + onReannounce = onReannounce, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + + if (filteredRemotes.isEmpty()) { + item { + Icon( + Icons.Rounded.DevicesOther, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + Text( + stringResource(R.string.no_devices_found), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(12.dp), + ) + } + } else items(filteredRemotes.size) { index -> + val remote = filteredRemotes[index] + val isFavorite = remote.isFavorite + + val displayInfo = remember(remote) { RemoteDisplayInfo.fromRemote(remote) } + + SegmentedListItem( + onClick = { + if (textMode) { + onSendText(remote, editedText!!) + } else { + onSendUris(remote, uris) + } + }, + shapes = ListItemDefaults.segmentedDynamicShapes( + index = index, + count = filteredRemotes.size, + ), + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + content = { + Text(displayInfo.title) + }, + supportingContent = { Text(displayInfo.subtitle) }, + leadingContent = { + DynamicAvatarCircle( + bitmap = remote.picture, + isFavorite = isFavorite, + ) + }, + trailingContent = { + Icon(Icons.Rounded.ChevronRight, contentDescription = null) + }, + modifier = Modifier.padding(bottom = ListItemDefaults.SegmentedGap), + ) + } + } +} + +@Composable +fun rememberFormattedFileNames(uris: List, context: Context): String? { + return remember(uris) { + val validNames = uris.mapNotNull { uri -> + Utils.getNameFromUri(context, uri) + } + + when (val count = validNames.size) { + 0 -> null + 1 -> validNames[0] + 2 -> "${validNames[0]}, ${validNames[1]}" + else -> { + val firstTwo = validNames.take(2).joinToString(", ") + val remainingCount = count - 2 + context.resources.getQuantityString( + R.plurals.and_more, + remainingCount, + firstTwo, + remainingCount, + ) + } + } + } +} + +@Preview(showBackground = true, name = "Floating - Files") +@Composable +fun PreviewFloatingFiles() { + WarpinatorTheme { + ShareDialogFloatingWrapper( + onDismiss = {}, + remotes = previewRemotes, + uris = listOf(Uri.EMPTY, Uri.EMPTY), + text = null, + ) + } + +} + +@Preview(showBackground = true, name = "Floating - Text") +@Composable +fun PreviewFloatingText() { + WarpinatorTheme { + ShareDialogFloatingWrapper( + onDismiss = {}, + remotes = previewRemotes, + uris = emptyList(), + text = "Hello, this is a shared text snippet!", + ) + } + +} + +@Preview(name = "Fullscreen - Files") +@Composable +fun PreviewFullscreenFiles() { + WarpinatorTheme { + ShareDialogFullscreenWrapper( + onDismiss = {}, + remotes = previewRemotes, + uris = listOf(Uri.EMPTY, Uri.EMPTY), + text = null, + ) + } + +} + +private val previewRemotes = listOf( + Remote( + uuid = "remote", + displayName = "Test Device 1", + userName = "user", + hostname = "hostname", + status = RemoteStatus.Connected, + isFavorite = true, + ), + Remote( + uuid = "remote", + displayName = "Test Device 2", + userName = "user", + hostname = "hostname", + status = RemoteStatus.Connected, + isFavorite = false, + ), + Remote( + uuid = "remote", + displayName = "Test Device 3", + userName = "user", + hostname = "hostname", + status = RemoteStatus.Connected, + supportsTextMessages = true, + isFavorite = true, + ), +) diff --git a/app/src/main/java/slowscript/warpinator/feature/share/components/ShareMenu.kt b/app/src/main/java/slowscript/warpinator/feature/share/components/ShareMenu.kt new file mode 100644 index 00000000..0cea9ff1 --- /dev/null +++ b/app/src/main/java/slowscript/warpinator/feature/share/components/ShareMenu.kt @@ -0,0 +1,120 @@ +package slowscript.warpinator.feature.share.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddLink +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.WifiTethering +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import slowscript.warpinator.R +import slowscript.warpinator.core.design.components.MenuAction +import slowscript.warpinator.core.design.components.MenuGroup +import slowscript.warpinator.core.design.components.MenuGroupsPopup +import slowscript.warpinator.core.design.shapes.segmentedHorizontalDynamicShapes +import slowscript.warpinator.core.design.shapes.toIconButtonShapes + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ShareMenu( + initiallyExpanded: Boolean = false, + onManualConnectionClick: () -> Unit, + onRescan: () -> Unit, + onReannounce: () -> Unit, + size: Dp = 48.dp, +) { + var menuOpen by rememberSaveable { mutableStateOf(initiallyExpanded) } + val groupInteractionSource = remember { MutableInteractionSource() } + + + val menuGroups = listOf( + MenuGroup( + listOf( + MenuAction( + stringResource(R.string.manual_connection_label), + trailingIcon = Icons.Rounded.AddLink, + onClick = onManualConnectionClick, + ), + MenuAction( + stringResource(R.string.reannounce_label), + trailingIcon = Icons.Rounded.WifiTethering, + onClick = onReannounce, + ), + MenuAction( + stringResource(R.string.rescan_label), + trailingIcon = Icons.Rounded.Refresh, + onClick = onRescan, + ), + ), + ), + ) + + Box( + modifier = Modifier.wrapContentSize(Alignment.TopEnd), + ) { + IconButton( + onClick = { + menuOpen = true + }, + modifier = Modifier + .padding(start = ListItemDefaults.SegmentedGap) + .height(size) + .aspectRatio(1f), + shapes = ListItemDefaults.segmentedHorizontalDynamicShapes( + index = 1, + count = 2, + ).toIconButtonShapes(), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.open_menu_label), + ) + } + MenuGroupsPopup( + menuOpen, + menuGroups, + groupInteractionSource, + onDismiss = { menuOpen = false }, + offset = DpOffset(y = 2.dp, x = 0.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun HomeMenuPreview() { + Scaffold { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + ShareMenu(true, onRescan = {}, onManualConnectionClick = {}, onReannounce = {}) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/slowscript/warpinator/preferences/EditTextPreference.java b/app/src/main/java/slowscript/warpinator/preferences/EditTextPreference.java deleted file mode 100644 index cd52386b..00000000 --- a/app/src/main/java/slowscript/warpinator/preferences/EditTextPreference.java +++ /dev/null @@ -1,66 +0,0 @@ -package slowscript.warpinator.preferences; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.EditText; -import android.widget.FrameLayout; - -import androidx.annotation.Nullable; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -@SuppressWarnings("unused") -public class EditTextPreference extends androidx.preference.EditTextPreference { - - @Nullable private OnBindEditTextListener mOnBindEditTextListener; - - public EditTextPreference(Context context) { - super(context); - } - - public EditTextPreference(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - public EditTextPreference(Context context, AttributeSet attrs) { - super(context, attrs); - } - - @Override - public void setOnBindEditTextListener(@Nullable OnBindEditTextListener onBindEditTextListener) { - mOnBindEditTextListener = onBindEditTextListener; - } - - @Override - protected void onClick() { - FrameLayout frameLayout = new FrameLayout(getContext()); - EditText editText = new EditText(getContext()); - - editText.setText(getText()); - - frameLayout.setPaddingRelative(40, 30, 40, 0); - frameLayout.addView(editText); - if (mOnBindEditTextListener != null) { - mOnBindEditTextListener.onBindEditText(editText); - } - - new MaterialAlertDialogBuilder(getContext()).setPositiveButton(android.R.string.ok, (dialog, which) -> { - String newVal = editText.getText().toString(); - if (callChangeListener(newVal)) - setText(newVal); - dialog.dismiss(); - }).setNegativeButton(android.R.string.cancel, null).setTitle(getDialogTitle()).setView(frameLayout).show(); - editText.requestFocus(); - } - - @Override - public CharSequence getSummary() { - return super.getText(); - } - - @Override - public void setText(String text) { - super.setText(text); - this.setSummary(text); - } -} diff --git a/app/src/main/java/slowscript/warpinator/preferences/ListPreference.java b/app/src/main/java/slowscript/warpinator/preferences/ListPreference.java deleted file mode 100644 index 1fab8cf7..00000000 --- a/app/src/main/java/slowscript/warpinator/preferences/ListPreference.java +++ /dev/null @@ -1,41 +0,0 @@ -package slowscript.warpinator.preferences; - -import android.content.Context; -import android.util.AttributeSet; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -public class ListPreference extends androidx.preference.ListPreference { - public ListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - public ListPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public ListPreference(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public ListPreference(Context context) { - super(context); - } - - @Override - protected void onClick() { - int selected = super.findIndexOfValue((String) super.getValue()); - - new MaterialAlertDialogBuilder(getContext()) - .setNegativeButton(android.R.string.cancel, null) - .setTitle(getDialogTitle()).setSingleChoiceItems( - getEntries(), - selected, - (dialog, which) -> { - super.setValueIndex(which); - getOnPreferenceChangeListener().onPreferenceChange(this, super.getValue()); - dialog.dismiss(); - } - ).show(); - } -} diff --git a/app/src/main/java/slowscript/warpinator/preferences/ProfilePicturePreference.java b/app/src/main/java/slowscript/warpinator/preferences/ProfilePicturePreference.java deleted file mode 100644 index 28740f52..00000000 --- a/app/src/main/java/slowscript/warpinator/preferences/ProfilePicturePreference.java +++ /dev/null @@ -1,134 +0,0 @@ -package slowscript.warpinator.preferences; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.util.AttributeSet; -import android.util.Log; -import android.view.View; -import android.widget.GridLayout; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.preference.Preference; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import slowscript.warpinator.R; -import slowscript.warpinator.Server; -import slowscript.warpinator.SettingsActivity; - -public class ProfilePicturePreference extends Preference { - - ActivityResultLauncher customImageActivityResultLauncher; - private final Context mContext; - - public ProfilePicturePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - mContext = context; - registerForResult(); - } - - public ProfilePicturePreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - mContext = context; - registerForResult(); - } - - public ProfilePicturePreference(Context context, AttributeSet attrs) { - super(context, attrs); - mContext = context; - registerForResult(); - } - - public ProfilePicturePreference(Context context) { - super(context); - mContext = context; - registerForResult(); - } - - @Override - protected void onClick() { - View view = View.inflate(mContext, R.layout.profile_chooser_view, null); - - GridLayout layout = view.findViewById(R.id.layout1); - ImageView imgCurrent = view.findViewById(R.id.imgCurrent); - - String picture = getSharedPreferences().getString("profile", "0"); - imgCurrent.setImageBitmap(Server.getProfilePicture(picture, mContext)); - - for (int i = 0; i < 12; i++) { - final int idx = i; - ImageButton btn = new ImageButton(mContext); - btn.setImageBitmap(Server.getProfilePicture(String.valueOf(idx), mContext)); - btn.setOnClickListener((v) -> { - getSharedPreferences().edit().putString("profile", String.valueOf(idx)).apply(); - imgCurrent.setImageBitmap(Server.getProfilePicture(String.valueOf(idx), mContext)); - }); - layout.addView(btn); - } - - MaterialAlertDialogBuilder materialAlertDialogBuilder = new MaterialAlertDialogBuilder(mContext) - .setTitle(R.string.picture_settings_title) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(R.string.custom, (dialog, which) -> { - Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType("image/*"); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); - - customImageActivityResultLauncher.launch(i); - dialog.dismiss(); - }).setView(view); - materialAlertDialogBuilder.show(); - } - - private void registerForResult() { - customImageActivityResultLauncher = getActivity().registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == Activity.RESULT_OK) { - Intent data = result.getData(); - if (data != null) { - Uri u = data.getData(); - if (u != null) { - try (var is = mContext.getContentResolver().openInputStream(u)) { - var bmp = BitmapFactory.decodeStream(is); - // Save "profilePic" to private app storage in reduced resolution - int outW, outH; - int maxDim = Math.min(512, Math.max(bmp.getWidth(), bmp.getHeight())); - if(bmp.getWidth() > bmp.getHeight()){ - outW = maxDim; - outH = (bmp.getHeight() * maxDim) / bmp.getWidth(); - } else { - outH = maxDim; - outW = (bmp.getWidth() * maxDim) / bmp.getHeight(); - } - Bitmap resized = Bitmap.createScaledBitmap(bmp, outW, outH, true); - try (var os = mContext.openFileOutput("profilePic.png", Context.MODE_PRIVATE)) { - //quality is irrelevant for PNG - resized.compress(Bitmap.CompressFormat.PNG, 100, os); - } - getSharedPreferences().edit().putString("profile", "profilePic.png").apply(); - } catch (Exception e) { - Log.e("ProfilePic", "Failed to save profile picture: " + u, e); - Toast.makeText(mContext, "Failed to save profile picture: " + e, Toast.LENGTH_LONG).show(); - } - } - } - } - }); - } - - private SettingsActivity getActivity() { - return (SettingsActivity) mContext; - } -} diff --git a/app/src/main/java/slowscript/warpinator/preferences/ResetablePreference.java b/app/src/main/java/slowscript/warpinator/preferences/ResetablePreference.java deleted file mode 100644 index a8a70b30..00000000 --- a/app/src/main/java/slowscript/warpinator/preferences/ResetablePreference.java +++ /dev/null @@ -1,43 +0,0 @@ -package slowscript.warpinator.preferences; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.View; -import android.widget.Button; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceViewHolder; - -import slowscript.warpinator.R; - -public class ResetablePreference extends androidx.preference.Preference { - private Button resetBtn; - private boolean resetEnabled = true; - private View.OnClickListener onResetListener; - - public ResetablePreference(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - setLayoutResource(R.layout.resetable_preference); - } - - public void setOnResetListener(View.OnClickListener listener) { - onResetListener = listener; - if (resetBtn != null) - resetBtn.setOnClickListener(listener); - } - - public void setResetEnabled(boolean enabled) { - resetEnabled = enabled; - if (resetBtn != null) - resetBtn.setEnabled(enabled); - } - - @Override - public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - resetBtn = (Button)holder.itemView.findViewById(R.id.resetButton); - resetBtn.setOnClickListener(onResetListener); - resetBtn.setEnabled(resetEnabled); - } -} diff --git a/app/src/main/res/anim/anim_null.xml b/app/src/main/res/anim/anim_null.xml deleted file mode 100644 index 5c875381..00000000 --- a/app/src/main/res/anim/anim_null.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/anim_push_down.xml b/app/src/main/res/anim/anim_push_down.xml deleted file mode 100644 index 19681c30..00000000 --- a/app/src/main/res/anim/anim_push_down.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/anim_push_up.xml b/app/src/main/res/anim/anim_push_up.xml deleted file mode 100644 index d0fcb4f1..00000000 --- a/app/src/main/res/anim/anim_push_up.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-night/transfers_background.xml b/app/src/main/res/drawable-night/transfers_background.xml deleted file mode 100644 index 4ef61be2..00000000 --- a/app/src/main/res/drawable-night/transfers_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_accept.xml b/app/src/main/res/drawable/ic_accept.xml deleted file mode 100644 index 95e065d1..00000000 --- a/app/src/main/res/drawable/ic_accept.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_decline.xml b/app/src/main/res/drawable/ic_decline.xml deleted file mode 100644 index 5ac7f30c..00000000 --- a/app/src/main/res/drawable/ic_decline.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml deleted file mode 100644 index 1eb39341..00000000 --- a/app/src/main/res/drawable/ic_download.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml deleted file mode 100644 index 34933627..00000000 --- a/app/src/main/res/drawable/ic_error.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_file.xml b/app/src/main/res/drawable/ic_file.xml deleted file mode 100644 index dcbc6c9b..00000000 --- a/app/src/main/res/drawable/ic_file.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml deleted file mode 100644 index 3c991950..00000000 --- a/app/src/main/res/drawable/ic_folder.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_meh.xml b/app/src/main/res/drawable/ic_meh.xml deleted file mode 100644 index b66b0f25..00000000 --- a/app/src/main/res/drawable/ic_meh.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml deleted file mode 100644 index a3844778..00000000 --- a/app/src/main/res/drawable/ic_message.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_reconnect.xml b/app/src/main/res/drawable/ic_reconnect.xml deleted file mode 100644 index 4dfa9870..00000000 --- a/app/src/main/res/drawable/ic_reconnect.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_remove_items.xml b/app/src/main/res/drawable/ic_remove_items.xml deleted file mode 100644 index a7bb45b0..00000000 --- a/app/src/main/res/drawable/ic_remove_items.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_star_empty.xml b/app/src/main/res/drawable/ic_star_empty.xml deleted file mode 100644 index c4489e3d..00000000 --- a/app/src/main/res/drawable/ic_star_empty.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_star_full.xml b/app/src/main/res/drawable/ic_star_full.xml deleted file mode 100644 index d8b2971b..00000000 --- a/app/src/main/res/drawable/ic_star_full.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_star_toggle.xml b/app/src/main/res/drawable/ic_star_toggle.xml deleted file mode 100644 index c5cc4db8..00000000 --- a/app/src/main/res/drawable/ic_star_toggle.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_status_awaiting_duplex.xml b/app/src/main/res/drawable/ic_status_awaiting_duplex.xml deleted file mode 100644 index 4367b041..00000000 --- a/app/src/main/res/drawable/ic_status_awaiting_duplex.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_status_connected.xml b/app/src/main/res/drawable/ic_status_connected.xml deleted file mode 100644 index 4d82ac36..00000000 --- a/app/src/main/res/drawable/ic_status_connected.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_status_connecting.xml b/app/src/main/res/drawable/ic_status_connecting.xml deleted file mode 100644 index 31a61377..00000000 --- a/app/src/main/res/drawable/ic_status_connecting.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml deleted file mode 100644 index d88740b8..00000000 --- a/app/src/main/res/drawable/ic_stop.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_unavailable.xml b/app/src/main/res/drawable/ic_unavailable.xml deleted file mode 100644 index 59fde689..00000000 --- a/app/src/main/res/drawable/ic_unavailable.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml deleted file mode 100644 index 536d713c..00000000 --- a/app/src/main/res/drawable/ic_upload.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml deleted file mode 100644 index bb7c2021..00000000 --- a/app/src/main/res/drawable/ic_user.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/rounded_corners.xml b/app/src/main/res/drawable/rounded_corners.xml deleted file mode 100644 index 997010c1..00000000 --- a/app/src/main/res/drawable/rounded_corners.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/transfers_background.xml b/app/src/main/res/drawable/transfers_background.xml deleted file mode 100644 index 4277023b..00000000 --- a/app/src/main/res/drawable/transfers_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml deleted file mode 100644 index 47d5b989..00000000 --- a/app/src/main/res/layout/activity_about.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 1a3bb4d6..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml deleted file mode 100644 index 43c45e31..00000000 --- a/app/src/main/res/layout/activity_share.xml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_transfers.xml b/app/src/main/res/layout/activity_transfers.xml deleted file mode 100644 index 00832839..00000000 --- a/app/src/main/res/layout/activity_transfers.xml +++ /dev/null @@ -1,191 +0,0 @@ - - - - - -