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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_manual_connect.xml b/app/src/main/res/layout/dialog_manual_connect.xml
deleted file mode 100644
index 0efdd06c..00000000
--- a/app/src/main/res/layout/dialog_manual_connect.xml
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/profile_chooser_view.xml b/app/src/main/res/layout/profile_chooser_view.xml
deleted file mode 100644
index 629cf078..00000000
--- a/app/src/main/res/layout/profile_chooser_view.xml
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/remote_view.xml b/app/src/main/res/layout/remote_view.xml
deleted file mode 100644
index f3b291ff..00000000
--- a/app/src/main/res/layout/remote_view.xml
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/resetable_preference.xml b/app/src/main/res/layout/resetable_preference.xml
deleted file mode 100644
index 4f68eae8..00000000
--- a/app/src/main/res/layout/resetable_preference.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml
deleted file mode 100644
index 9af49305..00000000
--- a/app/src/main/res/layout/settings_activity.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/simple_list_item.xml b/app/src/main/res/layout/simple_list_item.xml
deleted file mode 100644
index aec80f23..00000000
--- a/app/src/main/res/layout/simple_list_item.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_view.xml b/app/src/main/res/layout/transfer_view.xml
deleted file mode 100644
index 3ba1a65e..00000000
--- a/app/src/main/res/layout/transfer_view.xml
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
deleted file mode 100644
index 626469f2..00000000
--- a/app/src/main/res/menu/menu_main.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_share.xml b/app/src/main/res/menu/menu_share.xml
deleted file mode 100644
index ecfa3458..00000000
--- a/app/src/main/res/menu/menu_share.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties
new file mode 100644
index 00000000..63b46f93
--- /dev/null
+++ b/app/src/main/res/resources.properties
@@ -0,0 +1 @@
+unqualifiedResLocale=en
\ No newline at end of file
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 3ed1b25c..be9c0182 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -1,110 +1,53 @@
- Трансфери
- Изпращане на файлове
- Изход
- Проблеми с връзката
- Ръчно свързване
- Запази отчет
- Реанонс
- Сканирай за устройства
- Относно
- Настройки
- Сподели с
- Достъпни устройства
+ Изход
+ Проблеми с връзката
+ Ръчно свързване
+ Запази отчет
+ Реанонс
+ Сканирай за устройства
+ Относно
+ Настройки
+ Сподели с
+ Достъпни устройства
Няма намерени други устройства
- Връзката се провали: Грешен групов код
Грешка при свързване
- Н сте свързани към WiFi или LAN. Използвайте hotspot, ако няма други възможности. Рестартирайте приложението след като се свържете.
- Мрежата е променена - рестартиране на услугата…
- Устройства извън групата: %1$d
- Адреса е копиран в клипборда
- Изчакване на разрешение…
- (Файловете може да бъдат презаписани!)
- остава
- Неуспешно отваряне на получения файл
- няколко секунди
- , услугата е недостъпна
- , не е в същата подмрежа
- Грешки по време на трансфер:
- Затварянето на тази активност по време на споделяне, може да предизвика провал във файловия трансфер
- Изпращат се файлове:
- Избор на папка за изпращане
Версия: %1$s
Промяната на тези настройки изисква рестарт на приложението
- Вашият телефонен доставчик не поддържа нужният диалог. Това ще бъде преодоляно в бъдеща версия.
Пристигащите файлове с имена, които вече съществуват, ще презапишат съществуващите файлове
Пристигащите файлове с имена, които вече съществуват, ще бъдат преименувани
- Потребителско
- Избрахте неподдържан доставчик на съдържание. Моля изберете директория от вътрешното хранилище.
- Нестандартните локации не поддържат запазване на последно променените времеви маркери. Може да се върнете към стандартните с бутона за възстановяване.
- Стандартна за системата
- Светла тема
- Тъмна тема
+ Стандартна за системата
+ Светла тема
+ Тъмна тема
Услугата на Warpinator е включена
Спри услугата
- Всички трансфери завършиха
%1$.1f%%, %2$d трансфера, %3$s/s
- Входящ трансфер от %s
- %d файла
+ Входящ трансфер от %s
Услугата работи
Известие за устойчивост на услугата
Входящ трансфер
Прогрес на трансфера
- Повторно свързване
- Състояние на връзката
- Профилна картинка
- Иконка на приложението
- Иконка на разочаровано лице
- Избрана картинка
- Откажи трансфера
- Приеми трансфера
- Спри трансфера
- Повтори трансфера
- Възстанови стандартната стойност
- QR код
- Изглежда за пръв път стартирате приложението. Моля изберете в коя директория искате Warpinator да запазва файлове.
- Трябва да изберете директория за свалянията от настройките
- Мрежата е недостъпна - да опитаме с hotspot?
- Услугата не работи
- Получаването на сертификат от%1$s се провали. Проверете дали трафика UDP (както и TCP) на порт %2$d е разрешен от другата защитна стена.
- Изчисти приключилите трансфери
+ Повторно свързване
Сканирайте QR кода по-горе с приложението на камерата и отворете връзката, или използвайте следния адрес за да инициирате връзка от другото устройство:
- Нова връзка
- Въведете адрес и порт
- Устройствата трябва да са в една мрежа и да използват еднакъв групов код
- Опитайте ръчна връзка от менюто
- Свързване с \'%s\'?
- %d свързан
- Другото устройство не се свързва обратно с нас. Може би не ни вижда?
- Предупреждение
- Устройството не е в същата подмрежа. Свързването с него вероятно би се провалило. Моля проверете в настройките и на двете устройства дали Warpinator използва правилния мрежов интерфейс.
- Това е неофициален порт на Warpinator.\nИзпращайте и получавайте файлове през локалната мрежа.
- Тази програма е софтуер с отворен код лицензиран под <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Може да се сдобиете с изходния код от <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Тази програма пристига без никаква гаранция. <br> За повече детайли вижте условията на лиценза.
- Устройства с които да споделяте
- Няма налични устройства с които да споделяте
- Получава се опит за неподдържано действие
- Нищо за споделяне
Идентичност
- Настройки за трансфер
- Мрежа
- Аспект
+ Настройки за трансфер
+ Мрежа
+ Аспект
Име за показване
Профилна картинка
Директория за сваляне
Покажи известие за пристигащи файлове
Разреши презапис
Автоматично приемай трансфери
- Трансферите ще бъдат приемани автоматично
- Всеки трансфер трябва да бъде одобрен от вас
+ Трансферите ще бъдат приемани автоматично
+ Всеки трансфер трябва да бъде одобрен от вас
Опитай да използваш компресия
- Работа на заден фон
Автоматичен старт
Автоматичното стартиране не е налично за Android 15+
- Спри услугата при затваряне на приложението
- Услугата ще спре автоматично след като я изчистите от скорошни или след период на неактивност
- Веднъж стартирана, услугата ще продължи да работи докато не я спрете ръчно
- Записвай дебъг отчета във файл
+ Спри услугата при затваряне на приложението
+ Услугата ще спре автоматично след като я изчистите от скорошни или след период на неактивност
+ Веднъж стартирана, услугата ще продължи да работи докато не я спрете ръчно
+ Записвай дебъг отчета във файл
Групов код
Порт за трансфери
Порт за регистрация
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index c6d740fb..2ac5ab1a 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -1,107 +1,57 @@
- , servei no disponible
- Aquest programa és de codi obert, amb llicència <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br>Trobareu el codi font a <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br><br>El programa es proporciona sense cap mena de garantia.<br>Per a més informació, consulteu el termes de la llicència.
- Tema fosc
- %d fitxers
- Estat de la connexió
- Accepta la transferència
+ Tema fosc
Notificació persistent del servei
- Esborrar les transferències acabades
- %d connectat
- No esteu connectat a cap xarxa WiFi o LAN. Si no en teniu cap a l\'abast, empreu un punt d\'accés (hotspot). Reinicieu l\'aplicació quan tingueu connexió.
- Les ubicacions no predeterminades no permeten conservar les marques de temps de la darrera modificació. Podeu restaurar-ne la predeterminada amb el botó de reinici.
- No s\'ha pogut rebre el certificat de %1$s. Comproveu que el tràfic UDP i TCP estan permesos en el port %2$d del tallafoc remot.
- Transferències
- Envia fitxers
- Surt
- Problemes de connexió
- Connexió manual
- Desar registre
- Torna a anunciar
- Cerca dispositius
- Quant a
- Paràmetres
- Compartir amb
- Dispositius trobats
+ Surt
+ Problemes de connexió
+ Connexió manual
+ Desar registre
+ Torna a anunciar
+ Cerca dispositius
+ Quant a
+ Paràmetres
+ Compartir amb
+ Dispositius trobats
No s\'ha trobat cap altre dispositiu
- Error de connexió: codi de grup incorrecte
La connexió ha fallat
- No hi ha cap dispositiu amb què compartir
- Dispositius fora del grup: %1$d
- restant
- No s\'ha pogut obrir el fitxer rebut
- uns pocs segons
- Errors de transferència:
- (Pot sobreescriure fitxers)
- Tancar l\'activitat mentre es comparteix pot fer que la transferència falli
- Fitxers que s\'envien:
- Trieu una carpeta per enviar
Versió: %1$s
- Això és una adaptació no oficial del Warpinator.
-\nEnvieu i rebeu fitxers dins una xarxa local.
- Dispositius amb què compartir
- Res per compartir
Identitat
- Ajusts de transferències
- S\'ha rebut un intent d\'acció no compatible
- Esperant aprovació…
- Teniu connexió a la xarxa. S\'està reiniciant el servei…
- Xarxa
- Aspecte
+ Ajusts de transferències
+ Esperant aprovació…
+ Xarxa
+ Aspecte
Nom a mostrar
Imatge de perfil
Carpeta de descàrregues
Notificació de fitxers entrants
Permís per sobreescriure
- Heu d\'acceptar cada transferència entrant individualment
+ Heu d\'acceptar cada transferència entrant individualment
Compressió quan sigui possible
- Executar en segon pla
- Les transferències entrants s\'accepten automàticament
+ Les transferències entrants s\'accepten automàticament
Iniciar automàticament
Transferències automàtiques
- Aturar el servei en sortir
- El servei s\'aturarà automàticament quan s\'elimini de les aplicacions recents o hagi passat un temps d\'inactivitat
- Un cop iniciat, el servei només es pot aturar manualment
- Desar registre de depuració
+ Aturar el servei en sortir
+ El servei s\'aturarà automàticament quan s\'elimini de les aplicacions recents o hagi passat un temps d\'inactivitat
+ Un cop iniciat, el servei només es pot aturar manualment
+ Desar registre de depuració
Codi de grup
Tema
El port ha d\'ésser un nombre entre 1024 i 65535
Aquesta modificació requereix reiniciar l\'aplicació
Els fitxers entrants sobreescriuen els fitxers locals amb el mateix nom
Es reanomenen els fitxers entrants si ja hi ha fitxers locals amb el mateix nom
- Personalitzat
- Heu triat un proveïdor de contingut no compatible. Seleccioneu una carpeta de l\'emmagatzematge intern.
- Tema del sistema
- Tema clar
+ Tema del sistema
+ Tema clar
Servei Warpinator en execució
Atura el servei
- Transferències completades
%1$.1f%%, %2$d transferències, %3$s/s
- Transferència entrant de %s
+ Transferència entrant de %s
Servei en execució
Transferència entrant
- Reconnecta
- Imatge de perfil
- Icona de l\'aplicació
- Imatge seleccionada
- Rebutja la transferència
+ Reconnecta
Això pot veure\'s afectat pels ajusts de notificacions d\'Android
- Atura la transferència
- Hi ha un diàleg necessari que el fabricant del vostre dispositiu no ha implementat. Es donarà una alternativa en una propera versió.
Port per a transferències
Port per a registrar-se
Progrés
- Icona de cara decebuda
- No hi ha connexió de xarxa. Proveu a fer servir un punt d\'accés (hotspot)
- El servei no s\'està executant
- Intenteu una connexió manual des del menú
- Reintenta la transferència
Des de l\'altre dispositiu: escanegeu aquest codi QR i obriu l\'enllaç; o podeu introduir l\'adreça següent per a iniciar la connexió:
- Restaura el valor predeterminat
- Probablement aquest és el primer cop que obriu aquesta aplicació. Trieu una carpeta a on voleu que Warpinator desi les descàrregues.
- Heu de definir la carpeta de descàrregues a Paràmetres
- Iniciar connexió
- Inseriu adreça i port
- Voleu connectar-vos amb «%s»?
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 4ab7623a..8013d1e3 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -1,66 +1,35 @@
- Přenosy
- Poslat soubory
- Ukončit
- O aplikaci
- Ruční spojení
- Uložit ladící záznam
- Znovu se ohlásit
- Znovu vyhledat zařízení
- Nastavení
- Sdílet
- Dostupná zařízení
+ Ukončit
+ O aplikaci
+ Ruční spojení
+ Uložit ladící záznam
+ Znovu se ohlásit
+ Znovu vyhledat zařízení
+ Nastavení
+ Sdílet
+ Dostupná zařízení
Žádná zařízení nenalezena
- Spojení selhalo: špatný kód skupiny
Chyba spojení
- Nejste připojeni k WiFi ani k LAN. Pokud ani jedno není k dispozici,
- použijte hotspot. Až budete připojeni restartujte aplikaci.
- Zařízení mimo vaši skupinu: %1$d
- Adresa zkopírována do schránky
- Čeká na přijetí…
- (Soubory mohou být přepsány!)
- zbývá
- několik vteřin
- Soubor se nepodařilo otevřít
- , služba není dostupná
- , není ve stejné podsíti
- Chyby během přenosu:
- Zavření této aktivity během sdílení může způsobit že odesílání selže
- Vložte zprávu
- Vaše zpráva
- Zpráva zkopírována
- Odesláno
- Přijato
+ Čeká na přijetí…
Verze: %1$s
- Toto je neoficiální port Warpinatoru. \nPosílejte a přijímejte soubory v místní síti.
-
- Tento program je svobodný software licencovaný pod <a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU General Public License</a>.<br>
- Zdrojový kód můžete nalézt na <a href="https://github.com/slowscript/warpinator-android/">GitHubu</a>.<br>
- <br>
- Tento program je šířen bez jakékoliv záruky.<br>Více informací naleznete v podmínkách licence.
- Dostupná zařízení
- Neobdrželi jsme nic ke sdílení
- Upravte zprávu před odesláním:
-
Identita
- Přenos
- Síť
- Vzhled
+ Přenos
+ Síť
+ Vzhled
Zobrazované jméno
Profilový obrázek
Složka pro stahování
Zobrazit oznámení o příchozích souborech
Povolit přepisování
Automaticky přijímat přenosy
- Přenos bude přijat automaticky
- Každý přenos bude vyžadovat vaše schválení
+ Přenos bude přijat automaticky
+ Každý přenos bude vyžadovat vaše schválení
Pokusit se použít kompresi
- Běžet na pozadí
Spouštět při startu
Automatické spouštění na Androidu 15+ dostupné
- Zastavit službu po opuštění aplikace
- Zapisovat ladící záznamy do souboru
+ Zastavit službu po opuštění aplikace
+ Zapisovat ladící záznamy do souboru
Kód skupiny
Port pro přenosy
Port pro registraci
@@ -70,81 +39,23 @@
Toto může být ovlivněno systémovým nastavením oznámení
Port musí být mezi 1024 a 65535
Změna tohoto nastavení vyžaduje restart aplikace
- Výrobce vašeho zařízení neimplementoval vyždovaný dialog.
Příchozí soubory se jménem které již existuje budou přepsány
Příchozí soubory se jménem které již existuje budou přejmenovány
- Vlastní
- Zvolili jste obsah nepodporovaného poskytovatele. Vyberte prosím složku z vnitřního úložiště.
- Motiv systému
- Světlý motiv
- Tmavý motiv
+ Motiv systému
+ Světlý motiv
+ Tmavý motiv
Služba Warpinatoru běží
Zastavit
- Všechny přenosy byly dokončeny
%1$.1f%%, %2$d přenosů, %3$s/s
- %s posílá soubory
- %s posílá zprávu
- %d souborů
+ %s posílá soubory
Služba běží
Příchozí přenos
Průběh přenosu
- Pravděpodobně tuto aplikaci spouštíte poprvé. Vyberte prosím složku kam má Warpinator ukládat soubory.
- Vyberte složku pro ukládání souborů v nastavení
Oznámení o bežící službě
- Žádná zařízení se kterými by bylo možné sdílet
- Přijali jsme příkaz který není podporován
- Nepovedlo se nám získat certifikát od %1$s. Ujistěte se že máte povolen protokol UDP (i TCP) na portu %2$d ve firewallu protějšku.
- Varování
- Toto zařízení není ve stejné podsíti. Pravděpodobně nebude možné se k němu připojit. Zkontrolujte v nastavení obou zařízení že Warpinator používá správné síťové rozhraní.
-
- - Připojeno
- - Odpojeno
- - Připojuje se
- - Připojení selhalo
- - Čeká se na přpojení druhého zařízení
-
-
- - Inicializuje se
- - Čeká na přijetí…
- - Odmítnuto
- - Probíhá
- - Pozastaveno
- - Zastaveno
- - Selhalo (stisknutím zobrazíte chyby)
- - Nenapravitelná chyba
- - Soubor nenalezen
- - Dokončeno
- - Dokončeno s chybami (stisknutím zobrazíte)
-
- Síť není dostupná - zkuste hotspot?
- Služba neběží
- Problémy s připojením
- Posílané soubory:
-
- Znovu se pokusit o připojení
- Stav spojení
- Profilový obrázek
- Ikona aplikace
- Ikona smutný obličej
- Vybraný obrázek
- Odmítnout přenos
- Přijmout přenos
- Zastavit přenos
- Znovu odeslat
- Zkopírovat zprávu
- QR kód
- Změna sítě - restartuji službu…
- Služba bude automaticky zastavena po odstranění z nedávných nebo době bez aktivity
- Služba poběží dokud nebude ručně zastavena
- Vyberte složku k odeslání
- Jiné než výchozí umístění nepodporují zachování časové známky poslední změny. Výchozí vrátíte tlačítkem reset.
- Vrátit výchozí hodnotu
- Odstranit ukončené přenosy
+ Problémy s připojením
+ Znovu se pokusit o připojení
+ Zkopírovat zprávu
+ Služba bude automaticky zastavena po odstranění z nedávných nebo době bez aktivity
+ Služba poběží dokud nebude ručně zastavena
Naskenujte QR kód pomocí libovolné aplikace a otevřete odkaz nebo použijte následující adresu:
- Nové spojení
- Zkuste Ruční spojení v menu
- Zařízení musí být ve stejné síti a mít stejný kód skupiny
- Připojit k \'%s\'?
- %d v dosahu
- Druhé zařízení nevytvořilo zpětné spojení. Možná nás nevidí?
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 8315f38d..a88e57ae 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1,52 +1,28 @@
-
- Transfers
- Dateien senden
- Beenden
- Über
- Einstellungen
- Freigeben für
- Verfügbare Geräte
+ Beenden
+ Über
+ Einstellungen
+ Freigeben für
+ Verfügbare Geräte
Keine anderen Geräte gefunden
- Verbindung fehlgeschlagen: falscher Gruppenschlüssel
Verbindungsfehler
- Sie sind mit keinem WiFi oder LAN verbunden. Nutzen Sie den Hotspot, wenn nichts anderes verfügbar ist.
- Starten Sie die Anwendung neu, wenn Sie verbunden sind.
-
- Auf Genehmigung wird gewartet …
- (Dateien werden möglicherweise überschrieben!)
- verbleibend
- ein paar Sekunden
- , Dienst nicht verfügbar
- Fehler während der Übertragung:
- Das Beenden dieser Aktivität während der Freigabe kann dazu führen, dass die Übertragung fehlschlägt.
-
+ Auf Genehmigung wird gewartet …
Version: %1$s
- Das ist eine inoffizielle Umsetzung von Warpinator.
-\nSenden und Empfangen von Dateien im lokalen Netzwerk.
- Dieses Programm ist eine quelloffene Anwendung und lizenziert unter der <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">»GNU General Public License«</a>.<br> Sie können den Quellcode über <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a> beziehen.<br> <br> Dieses Programm kommt mit keinerlei Garantien.<br>Für mehr Details, sehen Sie sich bitte die Bedingungen der Lizenz an.
-
- Geräte zur Freigabe
- Keine verfügbaren Geräte zur Freigabe
- Wir haben eine nicht unterstützte Aktion erhalten
- Nichts zu teilen
-
Identität
- Transfereinstellungen
- Netzwerk
- Aussehen
+ Transfereinstellungen
+ Netzwerk
+ Aussehen
Anzeigename
Profilbild
Transferverzeichnis
Benachrichtigung über eingehende Dateien anzeigen
Überschreiben erlauben
Transfer automatisch akzeptieren
- Der Transfer wird automatisch angenommen
- Jeder Transfer muss von Dir bestätigt werden
- Im Hintergrund ausführen
+ Der Transfer wird automatisch angenommen
+ Jeder Transfer muss von Dir bestätigt werden
Automatisch starten
- Fehlerprotokoll in eine Datei schreiben
+ Fehlerprotokoll in eine Datei schreiben
Gruppenschlüssel
Port für Übertragungen
Thema
@@ -55,90 +31,28 @@
Die Änderung dieser Einstellung erfordert einen Neustart der Anwendung
Eingehende Dateien mit einem Namen, der bereits existiert, überschreiben die bestehenden
Eingehende Dateien mit einem Namen, der bereits existiert, werden umbenannt
- Benutzerdefiniert
- Sie haben einen nicht unterstützten Pfad/Datenträger ausgewählt. Bitte wählen Sie ein Verzeichnis aus dem internen Speicher.
-
- Systemstandard verwenden
- Helles Thema
- Dunkles Thema
-
+ Systemstandard verwenden
+ Helles Thema
+ Dunkles Thema
Warpinator-Dienst wird ausgeführt
Dienst anhalten
- Alle Transfers fertiggestellt
%1$.1f%%, %2$d Transfers, %3$s/s
- Eingehender Transfer von %s
- %d Dateien
+ Eingehender Transfer von %s
Dienst wird ausgeführt
Stände Benachrichtigung für Dienst
Eingehender Transfer
Transferfortschritt
-
- Neu verbinden
- Verbindungsstatus
- Profilbild
- Anwendungssymbol
- Entäuschtes Gesichtssymbol
- Ausgewähltes Bild
- Transfer ablehnen
- Transfer annehmen
- Transfer anhalten
- Transfer erneut starten
-
- Dies ist das erste Mal, dass Sie die Anwendung starten.
- Bitten wählen Sie ein Verzeichnis, in dem Warpinator Dateien speichern soll.
- Sie müssen in den Einstellungen ein Zielverzeichnis auswählen.
- Netzwerk nicht verfügbar - Hotspot versuchen?
- Dienst wird nicht ausgeführt
- Abruf des Zertifikats von %1$s fehlgeschlagen. Stellen Sie sicher, dass UDP (genauso wie TCP) Datenverkehr auf Port %2$d in der Firewall der Gegenstelle erlaubt ist.
-
- - Verbunden
- - Getrennt
- - Verbinde
- - Verbindung fehlgeschlagen
- - Warten auf Verbindung vom anderen Gerät
-
-
- - Initialisiere
- - Warte auf Genehmigung…
- - Abgelehnt
- - Übertrage
- - Pausiert
- - Angehalten
- - Fehlgeschlagen (Drücken um Fehler anzuzeigen)
- - Nicht behebbarer Fehler
- - Datei nicht gefunden
- - Fertiggestellt
- - Fertiggestellt mit Fehlern (Drücken zum Anzeigen)
-
- Verbindungsprobleme
- Neu ankündigen
- Erneut nach Geräten suchen
- Netzwerk geändert – Dienst wird neu gestartet …
- Dateien werden gesendet:
- Dienst nach Verlassen der App beenden
- Einmal gestartet, läuft der Dienst weiter, bis er manuell gestoppt wird
- Empfangene Datei konnte nicht geöffnet werden
- Ordner zum Senden auswählen
- Auf Standardwert zurücksetzen
- Erledigte Übertragungen löschen
- Der Dienst wird automatisch angehalten, wenn er aus den Wiedervorlagen gelöscht wird oder nach einer gewissen Zeit der Inaktivität
- Der Hersteller Ihres Telefons hat den erforderlichen Dialog nicht implementiert. Dies wird in einer zukünftigen Version behoben werden.
- Nicht standardmäßige Speicherorte unterstützen nicht die Beibehaltung von Zeitstempeln der letzten Änderung. Mit der Schaltfläche Zurücksetzen können Sie zu den Standardeinstellungen zurückkehren.
+ Neu verbinden
+ Verbindungsprobleme
+ Neu ankündigen
+ Erneut nach Geräten suchen
+ Dienst nach Verlassen der App beenden
+ Einmal gestartet, läuft der Dienst weiter, bis er manuell gestoppt wird
+ Der Dienst wird automatisch angehalten, wenn er aus den Wiedervorlagen gelöscht wird oder nach einer gewissen Zeit der Inaktivität
Port für die Registrierung
Nutzung von Kompression versuchen
- Geräte außerhalb Ihrer Gruppe: %1$d
- Protokoll speichern
- Neue Verbindung
- Manuelle Verbindung
+ Protokoll speichern
+ Manuelle Verbindung
Scannen Sie den obigen QR-Code mit einer Kamera-App und öffnen Sie den Link oder verwenden Sie die folgende Adresse, um die Verbindung vom anderen Gerät aus zu initiieren:
- %d verbunden
- Adresse und Port eingeben
- Manuelle Verbindung im Menü versuchen
- Mit »%s« verbinden?
- Adresse in die Zwischenablage kopiert
- QR-Code
- Die Geräte müssen sich im selben Netzwerk befinden und denselben Gruppencode verwenden
- Warnung
Bevorzugte Netzwerkschnittstelle
- , nicht im gleichen Subnetz
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index c2e6c6fe..f32b982e 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1,136 +1,57 @@
-
- Transferencias
- Enviar archivos
- Salir
- Problemas de conexión
- Volver a anunciar
- Buscar dispositivos
- Acerca de
- Ajustes
- Compartir con
- Dispositivos disponibles
+ Salir
+ Problemas de conexión
+ Volver a anunciar
+ Buscar dispositivos
+ Acerca de
+ Ajustes
+ Compartir con
+ Dispositivos disponibles
No se encontraron dispositivos
- Error de conexión: código de grupo incorrecto
Error de conexión
- No está conectado a la WiFi o LAN. Use su punto de acceso si ninguno está disponible. Reiniciar aplicación cuando esté conectado.
- Red cambiada. Reiniciando servicio…
-
- A la espera del permiso…
- (¡Los archivos pueden ser sobrescritos!)
- restante
- No se ha podido abrir el archivo recibido
- unos segundos
- , servicio no disponible
- Errores durante la transferencia:
- Cerrar esta actividad mientras se comparte puede hacer que la transferencia falle
- Archivos que se envían:
-
+ A la espera del permiso…
Versión: %1$s
- Este es un puerto no oficial de Warpinator. \nEnviar y recibir archivos a través de la red local.
- Este programa es un software de código abierto licenciado bajo la <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Puede obtener el código fuente en <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Este programa no trae ninguna garantía.<br>Consulte los términos de la licencia para obtener más detalles.
-
- Dispositivos con los que compartir
- No hay dispositivos disponibles para compartir
- Hemos recibido una acción de intención no admitida
- Nada que compartir
-
Identidad
- Ajustes de transferencia
- Red
- Aspecto
+ Ajustes de transferencia
+ Red
+ Aspecto
Nombre a mostrar
Imagen de perfil
Directorio de descargas
Notificar archivos entrantes
Permitir sobrescribir
Aceptar las transferencias automáticamente
- Las transferencias se aceptarán automáticamente
- Cada transferencia debe ser aceptada por usted
- Ejecutar en segundo plano
+ Las transferencias se aceptarán automáticamente
+ Cada transferencia debe ser aceptada por usted
Iniciar automáticamente
- Registrar depuración en archivo
+ Registrar depuración en archivo
Código de grupo
Puerto para transferencias
Tema
Esto puede verse afectado por la configuración de notificaciones de Android
El número de puerto debe estar entre 1024 y 65535
Para cambiar esta configuración es necesario reiniciar la aplicación
- El proveedor de su teléfono no implementó un diálogo requerido. Esto se solucionará en una versión futura.
Los archivos entrantes con un nombre que ya existe sobrescribirán los existentes
Los archivos entrantes con un nombre que ya existe serán renombrados
- Personalizado
- Ha seleccionado un proveedor de contenido no compatible. Seleccione un directorio en su almacenamiento interno.
-
- Tema de sistema
- Tema claro
- Tema oscuro
-
+ Tema de sistema
+ Tema claro
+ Tema oscuro
Servicio de Warpinator en ejecución
Detener servicio
- Transferencias completadas
%1$.1f%%, %2$d transferencias, %3$s/s
- Transferencia entrante de %s
- %d archivos
+ Transferencia entrante de %s
Ejecutando servicio
Notificación de servicio persistente
Transferencia entrante
Progreso de transferencia
-
- Conectar de nuevo
- Estado de conexión
- Imagen de perfil
- Icono de aplicación
- Icono de cara decepcionada
- Imagen seleccionada
- Rechazar transferencia
- Aceptar transferencia
- Detener transferencia
- Reintentar transferencia
-
- Es probable que sea la primera vez que inicie esta aplicación.
- Seleccione un directorio en el que desea que Warpinator guarde los archivos.
- Debe seleccionar un directorio de descarga en los ajustes
- La red no está disponible. ¿Desea probar con un punto de acceso\?
- El servicio no se está ejecutando
- Error al recibir el certificado de %1$s. Asegúrese de permitir el tráfico UDP (así como TCP) en el puerto %2$d en el firewall remoto.
-
- - Conectado
- - Desconectado
- - Conectando
- - Conexión fallida
- - Esperando dúplex
-
-
- - Inicializando
- - Esperando permisoâŚ
- - Rechazado
- - Transfiriendo
- - En pausa
- - Detenido
- - Fallido (pulse para ver los errores)
- - Fallo irrecuperable
- - Archivo no encontrado
- - Terminado
- - Terminado con errores (pulse para ver)
-
- Detener el servicio después de salir de la aplicación
- El servicio se detendrá automáticamente cuando se borre de los registros o tras un periodo de inactividad
- Selecciona una carpeta para enviar
- Limpiar transferencias terminadas
- Una vez iniciado, el servicio seguirá funcionando hasta que se detenga manualmente
- Ubicaciones no predeterminadas no permiten conservar marcas de tiempo modificadas recientemente. Puedes volver a los valores por defecto con el botón de reinicio.
- Restablecer el valor predeterminado
+ Conectar de nuevo
+ Detener el servicio después de salir de la aplicación
+ El servicio se detendrá automáticamente cuando se borre de los registros o tras un periodo de inactividad
+ Una vez iniciado, el servicio seguirá funcionando hasta que se detenga manualmente
Pruebe a usar la compresión
Puerto para el registro
- Dispositivos fuera del grupo: %1$d
- Iniciar la conexión
Escanee el código QR anterior con una aplicación de cámara y abra el enlace o utilice la siguiente dirección para iniciar la conexión desde el otro dispositivo:
- Conexión manual
- Guardar el registro
- Introduzca la dirección y el puerto
- Intente la conexión manual en el menú
- ¿Conectar con \'%s\'?
- %d conectado
+ Conexión manual
+ Guardar el registro
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 587516a9..00adc377 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -1,110 +1,57 @@
- ارسال پرونده ها
- خروج
- مشکلات اتصال
- ذخیره گزارش
- تنظیمات
- اشتراک گذاری با
- دستگاههای موجود
- جستجو دوباره برای دستگاهها
+ خروج
+ مشکلات اتصال
+ ذخیره گزارش
+ تنظیمات
+ اشتراک گذاری با
+ دستگاههای موجود
+ جستجو دوباره برای دستگاهها
دستگاه دیگری یافت نشد
خطا در اتصال
- دستگاه خارج از گروه شماست: %1$d
- تلاش برای بازکردن فایل دریافتی ناموفق بود
- چند ثانیه
- ، خدمات در دسترس نیست
- خطا حین انتقال:
- بستن این فعالیت حین اشتراک گذاری ممکن است باعث عدم موفقیت انتقال شود
- پرونده های ارسال شده:
- شبکه تغییر کرد - راهاندازی دوباره سامانه…
- دستگاه هایی که با آنها به اشتراک می گذارید
- دستگاهی برای به اشتراک گذاری پرونده ها در دسترس نیست
- ما یک اقدام پشتیبانی نشده دریافت کردیم
هویت
- تنظیمات انتقال
- جلوه
+ تنظیمات انتقال
+ جلوه
تصویر نمایه
پوشه بارگیری ها
برای پرونده های ورودی آگاهساز بفرست
اجازه بازنویسی
- انتقالات به طور خودکار پذیرفته میشود
- تمام انتقالات نیاز به موافقت شما دارند
- بستن برنامه هنگامی که از آن خارج میشوید
- نوشتن گزارش عیبیابی در یک پرونده متنی
+ انتقالات به طور خودکار پذیرفته میشود
+ تمام انتقالات نیاز به موافقت شما دارند
+ بستن برنامه هنگامی که از آن خارج میشوید
+ نوشتن گزارش عیبیابی در یک پرونده متنی
شناسه گروه
پورت ثبتنام
پوسته
این ممکن است تحت تأثیر تنظیمات اعلان اندروید باشد
پورت باید بین ۱۰۲۴ تا ۶۵۵۳۵ باشد
- عرضه کننده دستگاه شما مکالمه مورد نیاز را اجرا نکرد. این عمل در نسخه آینده کار خواهد کرد.
- پس از آغاز ، سامانه تا زمانی که به صورت دستی متوقف شود ادامه خواهد یافت
- سفارشی
- شما یک ارائه دهنده محتوای پشتیبانی نشده را انتخاب کرده اید. لطفاً پوشه ای در حافظه داخلی تان را انتخاب کنید.
- استفاده از پیشفرض سیستم
- روشن
- تیره
+ پس از آغاز ، سامانه تا زمانی که به صورت دستی متوقف شود ادامه خواهد یافت
+ استفاده از پیشفرض سیستم
+ روشن
+ تیره
Warpinator در حال اجراست
توقف سامانه
- تمام انتقالات به اتمام رسیدند
%1$.1f%%, %2$d انتقالات, %3$s/s
آگاهسازی مداوم سامانه
انتقالات دریافتی
پیشرفت انتقال
- وضعیت اتصال
- تصویر نمایه
- نماد ناامید چهره
- تصویر انتخاب شده
- رد کردن انتقال
- موافقت با انتقال
- انتقال مجدد
- بازگشت به مقدار پیشفرض
- شما باید در تنظیمات یک پوشه بارگیری را انتخاب کنید
- شبکه در دسترس نیست - از نقظه اتصال استفاده شود؟
- سامانه در حال اجرا نیست
- دریافت گواهی از %1$s انجام نشد. حتماً اجازه ترافیک UDP (و همچنین TCP) را در پورت %2$d در فایروال از راه دور اجازه دهید.
- پاکسازی انتقالات پایان یافته
- آغاز اتصال
- انتقال
- اتصال دستی
- دوباره
- درباره
- اتصال ناموفق: نام گروه اشتباه است
- شما به WiFi یا LAN متصل نیستید. اگر در دسترس نیست از نقطه اتصال خود استفاده کنید. پس از اتصال، برنامه را باز راهاندازی کنید.
- در انتظار دسترسی…
- (پرونده ها ممکن است بازنویسی شده باشند!)
- باقیمانده
- پوشه ای را برای ارسال انتخاب کنید
+ اتصال دستی
+ دوباره
+ درباره
+ در انتظار دسترسی…
نسخه: %1$s
به طور خودکار نقل و انتقالات را بپذیرید
- این یک نسخه غیر رسمی از Warpinator است.
-\nارسال و دریافت پرونده در شبکه محلی.
- این برنامه نرم متن باز با مجوز <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> شما میتوانید سورس کد این برنامه را از <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> بیابید. این نرمافزار بدون هیچگونه ضمانتی عرضه میگردد.<br>برای جزئیات بیشتر به شرایط مجوز مراجعه کنید .
- چیزی برای به اشتراک گذاشتن نیست
- شبکه
+ شبکه
نام نمایشی
تلاش برای فسرده سازی
- اجرا در پسزمینه
آغاز خودکار
پورت انتقالات
تغییر این گزینه نیازمند راهاندازی مجدد میباشد
پرونده های ورودی با نامی که از قبل وجود دارد موارد موجود را رونویسی می کند
پرونده های ورودی با نامی که از قبل وجود دارد تغییر نام خواهند یافت
- مکان های غیر پیش فرض از حفظ آخرین زمان بندی های اصلاح شده پشتیبانی نمی کنند. با دکمه تنظیم مجدد می توانید به پیش فرض برگردید.
- این احتمالاً اولین بار است که شما این برنامه را راه اندازی می کنید. لطفاً پوشه ای که می خواهید Warpinator برای ذخیره پرونده ها از آن استفاده کند را انتخاب کنید.
- انتقالات دریافتی از %s
- %d پرونده
+ انتقالات دریافتی از %s
سامانه در حال اجرا
- اتصال دوباره
- نماد برنامه
- توقف انتقال
+ اتصال دوباره
رمزینه بالا را با دوربین کاوش کنید و پیوند را باز کنید یا از آدرس زیر برای شروع اتصال از دستگاه دیگر استفاده کنید:
- سامانه هنگام پاکسازی از برنامه های اخیر یا پس از مدتی عدم فعالیت به طور خودکار متوقف می شود
- نشانی در بریدهدان رونوشت شد
- دستگاه ها باید در یک شبکه باشد
- اتصال به \'%s\'؟
- %d متصل شد
- اتصال دستی را در این فهرست آزمایش کنید
- رمزینه QR
- نشانی و درگاه رایانامه
+ سامانه هنگام پاکسازی از برنامه های اخیر یا پس از مدتی عدم فعالیت به طور خودکار متوقف می شود
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index d48db4ad..8a56d534 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1,147 +1,60 @@
-
- Transferts
- Envoyer des fichiers
- Quitter
- Problèmes de connexion
- Annoncer à nouveau
- Rechercher à nouveau des appareils
- À propos
- Réglages
- Partager avec
- Appareils disponibles
+ Quitter
+ Problèmes de connexion
+ Annoncer à nouveau
+ Rechercher à nouveau des appareils
+ À propos
+ Réglages
+ Partager avec
+ Appareils disponibles
Aucun autre appareil trouvé
- Échec de la connexion : code de groupe erroné
Erreur de connexion
- Vous n\'êtes pas connecté au Wi-Fi ou à un réseau local. Utilisez votre point d\'accès Wi-Fi si aucun n\'est disponible.
- Redémarrez l\'application lorsque vous serez connecté.
- Réseau changé - redémarrage du service…
-
- Attente de la permission…
- (Les fichiers peuvent être écrasés !)
- restants
- Échec de l\'ouverture du fichier reçu
- quelques secondes
- , service indisponible
- Erreurs lors du transfert :
- L\'arrêt de cette activité pendant le partage peut provoquer l\'échec du transfert
- Fichiers en cours d\'envoi :
-
+ Attente de la permission…
Version : %1$s
- Ceci est un portage non officiel de Warpinator. \nEnvoyer et recevoir des fichiers à travers le réseau local.
- Ce programme est un logiciel libre publié sous la <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">Licence publique générale GNU</a>.<br> Vous pouvez obtenir le code source depuis <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Ce programme est distribué sans aucune garantie.<br>Voyez les termes de la licence pour davantage de détails.
-
- Appareils avec lesquels partager
- Aucun appareil disponible pour le partage
- Nous avons reçu une action d\'intention non prise en charge
- Rien à partager
-
Identité
- Réglages des transferts
- Réseau
- Apparence
+ Réglages des transferts
+ Réseau
+ Apparence
Nom d\'affichage
Image de profil
Répertoire des téléchargements
Afficher des notifications au sujet des fichiers entrants
Autoriser l\'écrasement
Accepter les transferts automatiquement
- Les transferts seront acceptés automatiquement
- Chaque transfert devra être accepté par vous
- Fonctionner en arrière-plan
+ Les transferts seront acceptés automatiquement
+ Chaque transfert devra être accepté par vous
Démarrer automatiquement
- Arrêter le service après avoir quitté l\'application
- Le service sera arrêté automatiquement lorsqu\'il sera enlevé des éléments récents ou suite à une période d\'inactivité
- Une fois démarré, le service continuera à s\'exécuter jusqu\'à ce qu\'il soit arrêté manuellement
- Écrire le journal de débogage dans le fichier
+ Arrêter le service après avoir quitté l\'application
+ Le service sera arrêté automatiquement lorsqu\'il sera enlevé des éléments récents ou suite à une période d\'inactivité
+ Une fois démarré, le service continuera à s\'exécuter jusqu\'à ce qu\'il soit arrêté manuellement
+ Écrire le journal de débogage dans le fichier
Code de groupe
Port pour les transferts
Thème
Cela peut être affecté par les réglages de notification d\'Android
Le numéro de port doit être compris entre 1024 et 65535
La modification de ce réglage requiert un redémarrage de l\'application
- Le fournisseur de votre téléphone n\'a pas implémenté le dialogue nécessaire. Cela sera contourné dans une prochaine version.
Les fichiers entrants avec un nom déjà existant écraseront les fichiers présents
Les fichiers entrants avec un nom déjà existant seront renommés
- Personnalisé
- Vous avez sélectionné un fournisseur de contenu non pris en charge. Veuillez sélectionner un répertoire dans votre stockage interne.
-
- Utiliser les valeurs par défaut du système
- Thème clair
- Thème sombre
-
+ Utiliser les valeurs par défaut du système
+ Thème clair
+ Thème sombre
Le service Warpinator est en cours d\'exécution
Arrêter le service
- Tous les transferts se sont terminés
%1$.1f%%, %2$d transferts, %3$s/s
- Transfert entrant depuis %s
- %d fichiers
+ Transfert entrant depuis %s
Service en cours d\'exécution
Notification de service persistante
Transfert entrant
Progression du transfert
-
- Reconnexion
- État de la connexion
- Image de profil
- Icône d\'application
- Icône de visage déçu
- Image sélectionnée
- Décliner le transfert
- Accepter le transfert
- Arrêter le transfert
- Réessayer le transfert
-
- C\'est probablement la première fois que vous lancez cette application.
- Veuillez sélectionner un répertoire dans lequel Warpinator enregistrera les fichiers.
- Vous devez choisir un répertoire de téléchargement dans les réglages
- Réseau non disponible - essayer le point d\'accès Wi-Fi ?
- Le service n\'est pas en cours d\'exécution
- Échec de la réception du certificat depuis %1$s. Assurez-vous d\'autoriser le trafic UDP (ainsi que TCP) sur le port %2$d sur le pare-feu de l\'appareil distant.
-
- - Connecté
- - Déconnecté
- - Connexion
- - Échec de la connexion
- - Attente du duplex
-
-
- - Initialisation
- - Attente de la permission…
- - Décliné
- - Transfert en cours
- - En pause
- - Arrêté
- - Échec (toucher pour voir les erreurs)
- - Échec non récupérable
- - Fichier introuvable
- - Terminé
- - Terminé avec des erreurs (toucher pour voir)
-
- Choisissez un répertoire à envoyer
- Les emplacements non par défaut ne prennent pas en charge la préservation de l\'horodatage de dernière modification. Vous pouvez revenir à la valeur par défaut avec le bouton de réinitialisation.
- Réinitialiser à la valeur par défaut
- Nettoyer les transferts terminés
+ Reconnexion
Essayer d\'utiliser la compression
Port pour l\'enregistrement
- Appareils en dehors de votre groupe : %1$d
- Nouvelle connexion
Analysez le code QR ci-dessus avec une application de caméra et ouvrez le lien ou bien utilisez l\'adresse suivante pour lancer la connexion depuis l\'autre appareil :
- Connexion manuelle
- %d connectés
- Enregistrer le journal
- Saisir l\'adresse et le port
- Essayez la connexion manuelle dans le menu
- Se connecter à \"%s\" ?
- Adresse copiée dans le presse-papiers
- Code QR
- Les appareils doivent être sur le même réseau et utiliser le même code de groupe
+ Connexion manuelle
+ Enregistrer le journal
Interface réseau préférée
%s (revient en automatique si indisponible)
- L\'autre appareil n\'a pas établi de connexion. Peut-être ne nous voit-il pas ?
- , pas sur le même sous-réseau
Le démarrage automatique n\'est pas disponible sur Android 15+
- Avertissement
- Cet appareil n\'est pas sur le même sous-réseau. La connexion risque d\'échouer. Veuillez vérifier que Warpinator utilise l\'interface réseau correcte dans les réglages des deux appareils.
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index f852540f..fc654196 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -1,36 +1,29 @@
- लॉग सहेजें
- डिवाइसों के लिए पुनः स्कैन करें
- उपलब्ध डिवाइस
+ लॉग सहेजें
+ डिवाइसों के लिए पुनः स्कैन करें
+ उपलब्ध डिवाइस
कोई अन्य डिवाइस नहीं मिला
- कनेक्शन विफल: ग़लत समूह कोड
संपर्क त्रुटि
- नेटवर्क बदल गया - सेवा पुनः प्रारंभ हो रही है…
- आपके समूह के बाहर के डिवाइस: %1$d
वर्शन: %1$s
- शेयर करने योग्य डिवाइस
- शेयर करने के लिए कोई डिवाइस उपलब्ध नहीं है
- शेयर करने के लिए कुछ नहीं
पहचान
- स्थानांतरण सेटिंग्स
- इस के साथ शेयर करें
- पहलू
+ स्थानांतरण सेटिंग्स
+ इस के साथ शेयर करें
+ पहलू
प्रदर्शित होने वाला नाम
प्रोफ़ाइल फोटो
आने वाली फाइलों के बारे में सूचना दिखाएँ
डाउनलोड डॉयरेक्टरी
स्वचालित रूप से स्थानांतरण स्वीकार करें
- स्थानांतरण स्वचालित रूप से स्वीकार किए जाएंगे
- प्रत्येक स्थानांतरण को आपके द्वारा स्वीकार किया जाना आवश्यक है
+ स्थानांतरण स्वचालित रूप से स्वीकार किए जाएंगे
+ प्रत्येक स्थानांतरण को आपके द्वारा स्वीकार किया जाना आवश्यक है
ओवरराइटिंग की अनुमति दें
संपीड़न का उपयोग करने का प्रयास करें
- पृष्ठभूमि में चलाएँ
स्वतः प्रारंभ करें
- फाइल में डिबग लॉग लिखें
- ऐप छोड़ने के बाद सेवा बंद करें
- हाल ही में साफ़ होने पर या निष्क्रियता की अवधि के बाद सेवा स्वचालित रूप से बंद हो जाएगी
- एक बार शुरू होने के बाद, सेवा मैन्युअल रूप से बंद होने तक चलती रहेगी
+ फाइल में डिबग लॉग लिखें
+ ऐप छोड़ने के बाद सेवा बंद करें
+ हाल ही में साफ़ होने पर या निष्क्रियता की अवधि के बाद सेवा स्वचालित रूप से बंद हो जाएगी
+ एक बार शुरू होने के बाद, सेवा मैन्युअल रूप से बंद होने तक चलती रहेगी
समूह कोड
पंजीकरण हेतु पोर्ट
पहले से मौजूद नाम वाली आने वाली फाइलें मौजूदा फाइलों को अधिलेखित कर देंगी
@@ -39,69 +32,26 @@
पोर्ट संख्या 1024 और 65535 के बीच होनी चाहिए
इस सेटिंग को बदलने के लिए एप्लिकेशन को पुनरारंभ करना आवश्यक है
यह Android की अधिसूचना सेटिंग्स से प्रभावित हो सकता है
- आपके फ़ोन के विक्रेता ने आवश्यक संवाद लागू नहीं किया. भविष्य में रिलीज़ में इस पर काम किया जाएगा।
- कस्टम
- आपने एक असमर्थित सामग्री प्रदाता का चयन किया है. कृपया अपने इन्टर्नल स्टोरेज पर एक डॉयरेक्टरी चुनें।
- गैर-डिफ़ॉल्ट स्थान अंतिम संशोधित टाइमस्टैम्प को संरक्षित करने का समर्थन नहीं करते हैं। आप रीसेट बटन से डिफ़ॉल्ट पर वापस लौट सकते हैं।
- सिस्टम डिफॉल्ट का प्रयोग करें
- सभी स्थानांतरण पूर्ण हो गए हैं
- %s से आने वाला स्थानांतरण
+ सिस्टम डिफॉल्ट का प्रयोग करें
+ %s से आने वाला स्थानांतरण
सेवा चालू है
आवक स्थानांतरण
स्थानांतरण प्रगति
- स्थानांतरण पुनः प्रयास करें
- डिफ़ॉल्ट मान पर रीसेट करें
- कनेक्शन आरंभ करें
सेवा रोकें
- हल्की थीम
+ हल्की थीम
Warpinator सेवा चल रही है
- गहरी थीम
- अनुमति की प्रतीक्षा है…
- शेष
- कुछ ही सेकंड
- , सेवा अनुपलब्ध है
- (फाइलें ओवरराइट की जा सकती हैं!)
- प्राप्त फाइल खोलने में विफल
- स्थानांतरण के दौरान त्रुटियाँ:
- फाइलें भेजी जा रही हैं:
- भेजने के लिए एक फोल्डर चुनें
- यह Warpinator का एक अनौपचारिक पोर्ट है।
-\nस्थानीय नेटवर्क पर फाइलें भेजें और प्राप्त करें।
- संपर्क स्थिति
- प्रोफ़ाइल फोटो
- रिकनेक्ट
- एप्लिकेशन आइकन
- चुनी गई तस्वीर
- स्थानांतरण अस्वीकार करें
- निराश चेहरे का आइकन
- स्थानांतरण स्वीकार करें
- समाप्त स्थानान्तरण साफ़ करें
- नेटवर्क अनुपलब्ध - हॉटस्पॉट आज़माएं?
- सेवा नहीं चल रही है
- पुनःघोषणा
- बारे में
- सेटिंग्स
- स्थानांतरण
- फाइलें भेजें
- छोड़ें
- कनेक्शन के मुद्दे
- मैन्युअल कनेक्शन
- आप WiFi या LAN से कनेक्ट नहीं हैं। यदि कोई भी उपलब्ध नहीं है तो अपने हॉटस्पॉट का उपयोग करें। जब आप कनेक्ट हो जाएं तो एप्लिकेशन को पुनरारंभ करें।
- साझा करते समय इस गतिविधि को बंद करने से स्थानांतरण विफल हो सकता है
- यह प्रोग्राम <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a> के तहत लाइसेंस प्राप्त ओपन सोर्स सॉफ्टवेयर है।<br> आप स्रोत कोड प्राप्त कर सकते हैं < a href=https://github.com/slowscript/warpinator-android/>GitHub।<br> <br> यह प्रोग्राम बिल्कुल बिना किसी वारंटी के आता है।<br>अधिक जानकारी के लिए लाइसेंस की शर्तें देखें ।
- हमें एक असमर्थित आशय कार्रवाई प्राप्त हुई
- नेटवर्क
+ गहरी थीम
+ अनुमति की प्रतीक्षा है…
+ रिकनेक्ट
+ पुनःघोषणा
+ बारे में
+ सेटिंग्स
+ छोड़ें
+ कनेक्शन के मुद्दे
+ मैन्युअल कनेक्शन
+ नेटवर्क
स्थानान्तरण के लिए पोर्ट
%1$.1f%%, %2$d स्थानांतरण, %3$s/s
- %d फाइलें
निरंतर सेवा अधिसूचना
- स्थानांतरण रोकें
- यह संभवतः पहली बार है जब आप यह एप्लिकेशन लॉन्च कर रहे हैं। कृपया उस डॉयरेक्टरी का चयन करें जहाँ आप चाहते हैं कि Warpinator फ़ाइलें सहेजें।
- आपको सेटिंग्स में एक डाउनलोड डायरेक्टरी का चयन करना होगा
- %1$s से प्रमाणपत्र प्राप्त करने में विफल. रिमोट के फ़ायरवॉल में पोर्ट %2$d पर UDP (साथ ही TCP) ट्रैफ़िक की अनुमति देना सुनिश्चित करें।
उपरोक्त QR कोड को कैमरा ऐप से स्कैन करें और लिंक खोलें या अन्य डिवाइस से कनेक्शन शुरू करने के लिए निम्नलिखित पते का उपयोग करें:
- पता और पोर्ट दर्ज करें
- मेनू में मैन्युअल कनेक्शन का प्रयास करें
- \'%s\' से कनेक्ट करें?
- %d जुड़ा
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
index 06e34f72..278d96ab 100644
--- a/app/src/main/res/values-hr/strings.xml
+++ b/app/src/main/res/values-hr/strings.xml
@@ -1,148 +1,60 @@
-
-
- Prijenosi
- Pošalji datoteke
- Zatvori aplikaciju
- Problemi povezivanja
- Ponovo najavi
- Ponovo traži uređaje
- O Warpinatoru
- Postavke
- Dijeli s
- Dostupni uređaji
+ Zatvori aplikaciju
+ Problemi povezivanja
+ Ponovo najavi
+ Ponovo traži uređaje
+ O Warpinatoru
+ Postavke
+ Dijeli s
+ Dostupni uređaji
Nema dostupnih uređaja
- Neuspjela veza: pogrešan kod grupe
Greška veze
- Niste povezani s WiFi ili LAN mrežom. Koristite svoju pristupnu točku ako nijedna od tih mreža nije dostupna. Ponovo pokrenite aplikaciju kad ste povezani.
- Mreža je promijenjena – usluga se ponovo pokreće …
-
- Čeka se odobrenje …
- (Datoteke se mogu prepisati!)
- preostalo
- Neuspjelo otvaranje primljene datoteke
- nekoliko sekundi
- , usluga je nedostupna
- Greške tijekom prijenosa:
- Zatvaranje ove aktvnosti tijekom dijeljenja može prouzročiti neuspješan prijenos
- Poslane datoteke:
-
+ Čeka se odobrenje …
Verzija %1$s
- Ovo je neslužbeni priključak Warpinatora.
-\nŠaljite i primajte datoteke putem lokalne mreže.
- Ovaj program je softver otvorenog koda licenciran pod uvjetima <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU Opće Javne Licence</a>.<br> Izvorni kod možete preuzeti s <a href=\"https://github.com/slowscript/warpinator-android/\">GitHuba</a>.<br> <br> Ovaj se program nudi bez jamstva.<br>Pogledajte uvjete licence za više pojedinosti.
-
- Uređaji za dijeljenje
- Nema dostupnih uređaja za dijeljenje
- Primljena je nepodržana namjera radnje
- Nema ničega za dijeljenje
-
Identitet
- Postavke prijenosa
- Mreža
- Izgled
+ Postavke prijenosa
+ Mreža
+ Izgled
Prikazano ime
Slika profila
Direktorij preuzimanja
Prikaži obavijesti o dolaznim datotekama
Dopusti prepisivanje
Automatski prihvati prijenose
- Prijenosi će se automatski prihvatit
- Morate prihvatiti svaki prijenos
- Pokreći u pozadini
+ Prijenosi će se automatski prihvatit
+ Morate prihvatiti svaki prijenos
Pokreni automatski
- Zaustavi uslugu nakon zatvaranja aplikacije
- Zapiši zapis otklanjanje grešaka u datoteku
+ Zaustavi uslugu nakon zatvaranja aplikacije
+ Zapiši zapis otklanjanje grešaka u datoteku
Kȏd grupe
Priključak za prijenose
Teme
Ovo može biti obuhvaćeno postavkama Android obavijesti
Broj priključka mora biti između 1024 i 65535
Mijenjanje ove postavke zahtijeva ponovno pokretanje aplikacije
- Vaš proizvođač telefona nije implementirao potreban dijalog. Ovo će se ispraviti u budućem izdanju.
Dolazne datoteke s već postojećim imenima će prepisati postojeće datoteke
Dolazne datoteke s imenom koje već postoji će se preimnovati
- Prilagođeno
- Odabrali ste nepodržanog pružatelja sadržaja. Odaberite direktorij u vašoj unutarnjoj pohrani.
-
- Koristi standard sustava
- Svijetla tema
- Tamna tema
-
+ Koristi standard sustava
+ Svijetla tema
+ Tamna tema
Warpinator usluga je pokrenuta
Prekini uslugu
- Svi prijenosi su završeni
%1$.1f%%, %2$d prijenosa, %3$s/s
- Dolazni prijenos od %s
- %d datoteke/a
+ Dolazni prijenos od %s
Usluga je pokrenuta
Obavijest trajne usluge
Dolazni prijenos
Napredak prijenosa
-
- Ponovo poveži
- Stanje povezivanja
- Slika profila
- Ikona aplikacije
- Ikona razočaranog lica
- Odabrana slika
- Odbij prijenos
- Prihvati prijenos
- Prekini prijenos
- Ponovo pokreni prijenos
-
- Vjerojatno prvi puta pokrećete ovu aplikaciju.
- Odaberite direktorij u koji će Warpinator spremati datoteke.
- Direktorij preuzimanja morate odabrati u postavkama
- Mreža je nedostupna – pokušati s pristupnom točkom\?
- Usluga nije pokrenuta
- Neuspjelo preuzimanje certifikata od %1$s. Dozvolite UDP (kao i TCP) promet na priključku %2$d u udaljenom vatrozidu.
-
- - Povezano
- - Nepovezano
- - Povezivanje
- - Neuspjelo povezivanje
- - Čekanje dvostrukog povezivanja
-
-
- - Pokretanje
- - Čekanje odobrenja…
- - Odbijeno
- - Prijenos u tijeku
- - Pauzirano
- - Zaustavljeno
- - Neuspjelo (dodirnite za prikaz greški)
- - Nepovratni neuspjeh
- - Datoteka nije pronađena
- - Završeno
- - Završeno s greškama (dodirnite za prikaz)
-
- Nakon pokretanja, usluga će raditi sve dok se ručno ne zaustavi
- Usluga će automatski prekinuti rad kad se ukloni iz nedavnih ili nakon razdoblja neaktivnosti
- Ukloni završene prijenose
- Odaberi mapu za slanje
- Vrati izvornu vrijednost
- Ne standardne lokacije ne podržavaju očuvanje zadnjih promijenjenih vremenskih oznaka. Standardne lokacije možete vratiti pomoću gumba za resetiranje.
+ Ponovo poveži
+ Nakon pokretanja, usluga će raditi sve dok se ručno ne zaustavi
+ Usluga će automatski prekinuti rad kad se ukloni iz nedavnih ili nakon razdoblja neaktivnosti
Priključak za registraciju
- Uređaji izvan vaše grupe: %1$d
Pokušaj koristiti komprimiranje
- Nova veza
Skeniraj gornji QR kod pomoću aplikacije kamere i otvori poveznicu ili koristi sljedeću adresu za pokretanje veze s drugog uređaja:
- Ručno povezivanje
- Spremi dnevnik radnji
- Upišite adresu i priključak
- Pokušajte ručno povezivanje u izborniku
- Povezati se na \'%s\'?
- Povezano: %d
- Adresa kopirana u međuspremnik
- , nije na istoj podmreži
+ Ručno povezivanje
+ Spremi dnevnik radnji
Automatsko pokretanje nije dostupno na Androidu 15+
Preferirano mrežno sučelje
%s (vraća se na automatski način ako nije dostupno)
- QR kod
- Uređaji moraju biti na istoj mreži i koristiti isti grupni kod
- Drugi uređaj se nije ponovo povezao. Možda nas ne vidi?
- Upozorenje
- Ovaj uređaj nije na istoj podmreži. Povezivanje s njim vjerojatno neće uspjeti. Provjeri u postavkama oba uređaja je li Warpinator koristi ispravno mrežno sučelje.
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 4d378358..abcf1525 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -1,51 +1,28 @@
-
- Átvitelek
- Fájlok küldése
- Kilépés
- Névjegy
- Beállítások
- Megosztás a következővel
- Választható eszközök
+ Kilépés
+ Névjegy
+ Beállítások
+ Megosztás a következővel
+ Választható eszközök
Nem található más eszköz
- A kapcsolat sikertelen: Helytelen csoportkód
Csatlakozási hiba
- Nincs csatlakoztatva a WiFi-hez vagy a helyi hálózathoz. Használja az elérési pontját, ha egyik sem érhető el.
- Indítsa újra az alkalmazást, amikor csatlakozik.
-
- Engedélyre vár…
- (A fájlok felülírhatók!)
- maradt
- néhány másodperc
- , a szolgáltatás nem elérhető
- Átviteli hibák:
- A tevékenység bezárása megosztás közben az átvitel sikertelenségét okozhatja
-
+ Engedélyre vár…
Verzió: %1$s
- Ez a Warpinator nem hivatalos portja.\nFájlok küldése és fogadása helyi hálózaton keresztül.
- Ez a program nyílt forráskódú szoftver, amelyet a <a href=\"https://hu.wikisource.org/wiki/GNU_General_Public_License_(magyar)\">GNU általános nyilvános licencével </a>licenceltek.<br> A forráskódot a következő helyeken szerezheti be: <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Ez a program semmilyen garanciával nem jár.<br>További részletekért tekintse meg a licenc feltételeit.
-
- Eszközök a megosztáshoz
- Nincs elérhető eszköz, amellyel meg lehetne osztani
- Kaptunk egy nem támogatott szándékú műveletet
- Nincs mit megosztani
-
Azonosító
- Átviteli beállítások
- Hálózat
- Oldalarány
+ Átviteli beállítások
+ Hálózat
+ Oldalarány
Megjelenítendő név
Profilkép
Letöltési könyvtár
Értesítés megjelenítése a bejövő fájlokról
Felülírás engedélyezése
Átvitelek automatikus fogadása
- Áviteleket automatikus el fogja fogadni
- Kérjen minden átvitelről jóváhagyást
- Futtatás a háttérben
+ Áviteleket automatikus el fogja fogadni
+ Kérjen minden átvitelről jóváhagyást
Automatikus indítás
- Hibakeresési napló írása fájlba
+ Hibakeresési napló írása fájlba
Csoportkód
Port az átvitelekhez
Téma
@@ -54,75 +31,24 @@
A beállítás megváltoztatásához újra kell indítani az alkalmazást
A már létező nevű bejövő fájlok felülírják a meglévő fájlokat
A már létező nevű bejövő fájlokat átnevezzük
- Egyéni
- Nem támogatott tartalomszolgáltatót választott. Válasszon egy könyvtárat a belső tárhelyről.
-
- A rendszer alapértelmezett használata
- Világos téma
- Sötét téma
-
+ A rendszer alapértelmezett használata
+ Világos téma
+ Sötét téma
A Warpinator szolgáltatás fut
Szolgáltatás leállítása
- Minden átvitel befejeződött
%1$.1f%%, %2$d átvitel, %3$s/s
- Bejövő átvitel innen: %s
- %d fájl
+ Bejövő átvitel innen: %s
A szolgáltatás fut
Tartós szolgáltatási értesítés
Bejövő átvitel
Az átvitel előrehaladása
-
- Valószínűleg ez az első alkalom, amikor elindítja ezt az alkalmazást.
- Kérjük, válasszon egy könyvtárat, ahová a Warpinator menti a fájlokat.
- A beállításokban ki kell választania egy letöltési könyvtárat
-
- - Csatlakoztatva
- - Kapcsolat bontva
- - Csatlakozás…
- - Sikertelen csatlakozás
- - Kétirányú várakozása…
-
-
- - Előkészítés
- - Engedélyre vár…
- - Elutasítva
- - Átvitel folyamatban
- - Szüneteltetve
- - Leállítva
- - Sikertelen (érintse meg a hibák megtekintéséhez)
- - Helyrehozhatatlan hiba
- - Nem található a fájl
- - Befejezve
- - Befejezve hibákkal (érintse meg a hibák megtekintéséhez)
-
- Csatlakozási problémák
- Újra bejelentés
- Eszközök keresése újra
- Elküldött fájlok:
- Alkalmazás elhagyása után a szolgáltatás leállítása
- Újracsatlakozás
- Kapcsolat állapota
- Alkalmazás ikon
- Csalódott arc ikon
- Választott kép
- Átvitel elutasítása
- Átvitel elfogadása
- Átvitel leállítása
- A hálózat nem elérhető – kipróbáljuk a hotspotot\?
- Fogadott fájl nem megnyitható
- Küldeni kívánt mappa kiválasztása
- Indítást követően a szolgáltatás a kézi leállításig tovább fut
- A telefon forgalmazója nem valósította meg a szükséges párbeszédpanelt. Ezt egy jövőbeli kiadásban meg fogják oldani.
- Profilkép
- Szolgáltatás nem fut
- A hálózat megváltozott – szolgáltatás újraindítása…
+ Csatlakozási problémák
+ Újra bejelentés
+ Eszközök keresése újra
+ Alkalmazás elhagyása után a szolgáltatás leállítása
+ Újracsatlakozás
+ Indítást követően a szolgáltatás a kézi leállításig tovább fut
Próbáljon tömörítést használni
Port a regisztrációhoz
- Kész átvitelek törlése
- Visszaállítás alapértelmezettre
- Csoporton kívüli eszközök: %1$d
- Szolgáltatás automatikusan leáll, ha törli a legutóbbi tartalmakat, vagy egy ideig inaktív
- A nem alapértelmezett helyek nem támogatják az utoljára módosított időbélyegek megőrzését. A visszaállítás gombbal visszaállíthatja az alapértelmezést.
- Átvitel újrapróbállása
- Nem sikerült fogadni a tanúsítványt a következőtől: %1$s. Győződjünk meg arról, hogy engedélyeztük az UDP (valamint a TCP) forgalmat a távvezérlő tűzfalának %2$d portján.
+ Szolgáltatás automatikusan leáll, ha törli a legutóbbi tartalmakat, vagy egy ideig inaktív
diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml
index cad1b7b0..3cecda84 100644
--- a/app/src/main/res/values-id/strings.xml
+++ b/app/src/main/res/values-id/strings.xml
@@ -1,52 +1,27 @@
-
-
- Transfer
- Mengirim berkas
- Keluar
- Tentang
- Pengaturan
- Bagikan dengan
- Perangkat yang tersedia
+ Keluar
+ Tentang
+ Pengaturan
+ Bagikan dengan
+ Perangkat yang tersedia
Tidak ada perangkat yang ditemukan
- Koneksi gagal: Salah kode grup
Koneksi gagal
- Anda tidak terkoneksi ke WiFi atau LAN. Gunakan hotspot jika keduanya tidak tersedia.
- Jalankan ulang aplikasi ketika anda sudah terkoneksi.
-
- Menunggu perizinan…
- (Berkas mungkin akan tertimpa!)
- tersisa
- Beberapa detik
- , Layanan tidak tersedia
- Kesalahan saat melakukan transfer:
- Menutup aktivitas ini saat sedang berbagi mungkin akan menimbulkan kegagalan transfer berkas
-
Versi: %1$s
- Ini adalah port tidak resmi dari Warpinator. \nKirim dan terima berkas melintasi jaringan lokal .
- Program ini merupakan perangkat lunak open source dilisensikan di bawah <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Anda dapat mendapatkan kode sumbernya dari <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Program ini dihadirkan tanpa garansi sama sekali.<br>Lihat ketentuan lisensinya untuk keterangan lebih rinci.
-
- Perangkat untuk berbagi
- Tidak ada perangkat yang dapat digunakan untuk berbagi
- Aplikasi mendapatkan perintah aksi yang tidak didukung
- Tidak ada hal untuk dibagikan
-
Identitas
- Pengaturan transfer
- Jaringan
- Aspek
+ Pengaturan transfer
+ Jaringan
+ Aspek
Nama yang ditampilkan
Foto profil
Direktori unduhan
Tampilkan notifkasi tentang berkas yang sedang dikirim
Perbolehkan menimpa berkas
Terima transfer secara otomatis
- Transfer berkas akan diterima secara otomatis
- Setiap aktivitas transfer perlu anda setujui
- Tetap berjalan di latar belakang
+ Transfer berkas akan diterima secara otomatis
+ Setiap aktivitas transfer perlu anda setujui
Mulai secara otomatis
- Tulis catatan debug pada file
+ Tulis catatan debug pada file
Kode grup
Port untuk pemindahan
Tema
@@ -55,93 +30,30 @@
Pergantian pengaturan ini membutuhkan aplikasi di jalankan ulang
Berkas yang masuk dengan nama berkas yang sudah ada sebelumnya akan ditimpa
Berkas yang masuk dengan nama berkas yang sudah ada akan diganti namanya
- Custom
- Anda telah memilih penyedia konten yang tidak didukung. Tolong pilih direktori yang ada pada penyimmpanan internal anda.
-
- Gunakan default sistem
- Tema Terang
- Tema Gelap
-
+ Gunakan default sistem
+ Tema Terang
+ Tema Gelap
Layanan Warpinator sedang berjalan
Hentikan layanan
- Seluruh transfer sudah selesai
%1$.1f%%, %2$d transfer, %3$s/s
- Transfer masuk dari %s
- %d berkas
+ Transfer masuk dari %s
Layanan sedang berjalan
Notifikasi layanan tetap
Tranfer masuk
Kemajuan transfer
-
- Ini mungkin baru pertama kalinya anda menjalankan aplikasi.
- Tolong pilih direktori dimana kamu ingin Warpinator untuk menyimpan berkas.
- Anda harus memilih direktori unduhan pada pengaturan
-
- - Tersambung
- - Terputus
- - Sedang menyambungkan
- - Sambungan gagal
- - Menunggu duplex
-
-
- - Inisialisasi
- - Menunggu perizinan…
- - Ditolak
- - Sedang mengirim
- - Dijeda
- - Dihentikan
- - Gagal (ketuk untuk melihat error)
- - Kegagalan yang tidak dapat dipulihkan
- - File tidak ditemukan
- - Selesai
- - Selesai dengan error (ketuk untuk melihat)
-
- Masalah Koneksi
- Scan Ulang Perangkat
- Melepaskan
- Gagal membuka berkas yang diterima
- Berkas yang sedang dikirim:
- Sekali mulai, layanan akan berjalan terus sampai dihentikan secara manual
- Hentikan layanan setelah meninggalkan aplikasi
- Layanan ini akan berhenti otomatis saat dibersihkan dari recents dan setelah periode tidak aktif
- Dialog belum didukung pada perangkat anda. Akan bekerja di rilis yang akan datang
- Koneksi ulang
- Status koneksi
- Gambar profil
- Gambar yang dipilih
- Menolak transfer
- Menerima transfer
- Hentikan transfer
- Mencoba transfer ulang
- Jaringan tidak tersedia - coba hotspot\?
- Layanan tidak berjalan
- Gagal mendapatkan sertifikat dari %1$s. Pastikan port trafik UDP dan TCP %2$d di remote firewall.
- Jaringan berubah - layanan distart ulang…
- Ikon Applikasi
- Ikon wajah kecewa
- Perangkat-perangkat di luar grup anda: %1$d
- Pilih folder untuk mengirim
+ Masalah Koneksi
+ Scan Ulang Perangkat
+ Melepaskan
+ Sekali mulai, layanan akan berjalan terus sampai dihentikan secara manual
+ Hentikan layanan setelah meninggalkan aplikasi
+ Layanan ini akan berhenti otomatis saat dibersihkan dari recents dan setelah periode tidak aktif
+ Koneksi ulang
Coba gunakan kompresi
Port untuk pendaftaran
- Lokasi non-default tidak mendukung penyimpanan timestamps yang terakhir dimodifikasi. Anda dapat kembali ke default dengan tombol reset.
- Atur ulang ke nilai default
- Hapus transfer yang sudah selesai
- Koneksi manual
- Simpan log
- Alamat disalin ke papan klip
+ Koneksi manual
+ Simpan log
Pemulaian otomatis tidak tersedia di Android 15+
Antarmuka jaringan yang disukai
%s (akan kembali ke otomatis jika tidak tersedia)
- Kode QR
Pindai kode QR di atas dengan aplikasi kamera dan buka tautan atau gunakan alamat berikut untuk memulai koneksi dari perangkat lain:
- Koneksi baru
- Masukkan alamat dan port
- Perangkat harus berada di jaringan yang sama dan menggunakan kode grup yang sama
- Coba Koneksi manual di menu
- Sambungkan ke \'%s\'?
- %d tersambung
- Perangkat lain tidak menyambung kembali. Mungkin perangkat tersebut tidak melihat kita?
- Peringatan
- Perangkat ini tidak berada pada subnet yang sama. Koneksi ke perangkat ini kemungkinan akan gagal. Harap periksa di pengaturan kedua perangkat bahwa Warpinator menggunakan antarmuka jaringan yang benar.
- , tidak berada pada subnet yang sama
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 370032e7..95e2876a 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1,138 +1,57 @@
-
-
- Trasferimenti
- Invia file
- Chiudi
- Problemi di connessione
- Esporre di nuovo
- Ricerca dispositivi
- Informazioni su
- Impostazioni
- Condividi con
- Dispositivi disponibili
+ Chiudi
+ Problemi di connessione
+ Esporre di nuovo
+ Ricerca dispositivi
+ Informazioni su
+ Impostazioni
+ Condividi con
+ Dispositivi disponibili
Nessun altro dispositivo trovato
- Connessione non riuscita: Codice gruppo errato
Errore di connessione
- Non si è connessi alla rete WiFi o LAN. Utilizzare l\'hotspot se nulla è disponibile.
- Riavviare l\'applicazione quando si è connessi.
- Rete cambiata - riavvio del servizio…
-
- In attesa di autorizzazione…
- (I file potrebbero essere sostituiti!)
- restante
- Impossibile aprire il file ricevuto
- pochi secondi
- , servizio non disponibile
- Errori durante il trasferimento:
- La chiusura di questa attività durante la condivisione potrebbe causare il fallimento del trasferimento
- File da inviare:
-
+ In attesa di autorizzazione…
Versione: %1$s
- Questo è un progetto non ufficiale di Warpinator. \nInvia e ricevi file attraverso la rete locale.
- Questo programma è un software open source con licenza <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> È possibile ottenere il codice sorgente da <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Questo programma non ha alcuna garanzia.<br>Per maggiori dettagli, consultare i termini della licenza.
-
- Dispositivi con cui condividere
- Nessun dispositivo disponibile con cui condividere
- Abbiamo ricevuto un\'azione intenzionale non accettata
- Niente da condividere
-
Identità
- Impostazioni di trasferimento
- Rete
- Aspetto
+ Impostazioni di trasferimento
+ Rete
+ Aspetto
Nome visualizzato
Foto profilo
Cartella dei download
Mostra notifiche sui file in arrivo
Consenti sostituzione
Accetta automaticamente i trasferimenti
- I trasferimenti verranno accettati automaticamente
- Ogni trasferimento deve essere accettato da te
- Esegui in background
+ I trasferimenti verranno accettati automaticamente
+ Ogni trasferimento deve essere accettato da te
Avvia automaticamente
- Interrompi il servizio dopo aver lasciato l\'app
- Il servizio viene interrotto automaticamente quando viene eliminato dai Recenti o dopo un periodo di inattività
- Una volta avviato, il servizio continuerà a funzionare fino all\'arresto manuale
- Scrivere il registro di debug su file
+ Interrompi il servizio dopo aver lasciato l\'app
+ Il servizio viene interrotto automaticamente quando viene eliminato dai Recenti o dopo un periodo di inattività
+ Una volta avviato, il servizio continuerà a funzionare fino all\'arresto manuale
+ Scrivere il registro di debug su file
Codice gruppo
Porta per trasferimenti
Aspetto
Ciò potrebbe essere influenzato dalle impostazioni di notifica di Android
Il numero della porta deve essere compreso tra 1024 e 65535
La modifica di questa impostazione richiede il riavvio dell\'applicazione
- Il fornitore del telefono non ha implementato la finestra di dialogo richiesta. Questo problema sarà risolto in una versione futura.
I file in arrivo con lo stesso nome di quelli già esistenti sostituiscono quelli già esistenti
I file in arrivo con lo stesso nome di quelli già esistenti saranno rinominati
- Personalizzate
- È stato selezionato un fornitore di contenuti non compatibile. Selezionare una cartella nella memoria interna.
-
- Aspetto del sistema
- Aspetto chiaro
- Aspetto scuro
-
+ Aspetto del sistema
+ Aspetto chiaro
+ Aspetto scuro
Il servizio Warpinator è in esecuzione
Interrompi servizio
- Tutti i trasferimenti sono stati completati
%1$.1f%%, %2$d trasferimenti, %3$s/s
- Trasferimento in arrivo da %s
- %d file
+ Trasferimento in arrivo da %s
Servizio in esecuzione
Notifica permanente del servizio
Trasferimento in arrivo
Avanzamento del trasferimento
-
- Riconnetti
- Stato connessione
- Foto profilo
- Icona dell\'applicazione
- Icona faccia delusa
- Immagine selezionata
- Rifiuta il trasferimento
- Accetta il trasferimento
- Interrompi il trasferimento
- Riprova il trasferimento
-
- Questa è probabilmente la prima volta che avvii questa applicazione.
- Selezionare la cartella in cui si desidera che Warpinator salvi i file.
- È necessario selezionare una cartella di download nelle impostazioni
- Rete non disponibile - provare l\'hotspot?
- Servizio non in esecuzione
- Impossibile ricevere il certificato da %1$s. Assicurarsi di consentire il traffico UDP (oltre che TCP) sulla porta %2$d nel firewall remoto.
-
- - Connesso
- - Disconnesso
- - Connessione
- - Connessione fallita
- - In attesa bidirezionale
-
-
- - Inizializzazione
- - In attesa di autorizzazione…
- - Rifiutato
- - Trasferimento
- - In pausa
- - Arrestato
- - Non riuscito (tocca per visualizzare gli errori)
- - Errore irreversibile
- - File non trovato
- - Completato
- - Completato con errori (tocca per visualizzare)
-
- Le posizioni non predefinite non permettono di conservare la data dell\'ultima modifica. È possibile tornare alle impostazioni predefinite con il pulsante di ripristino.
- Seleziona una cartella da inviare
- Ripristina il valore predefinito
- Cancella i trasferimenti completati
+ Riconnetti
Usa la compressione
Porta per iscrizione
- Dispositivi esterni al tuo gruppo: %1$d
- Avvia la connessione
Scansionare il codice QR qui sopra con un\'app fotocamera e aprire il collegamento o utilizzare il seguente indirizzo per stabilire la connessione dall\'altro dispositivo:
- Connessione manuale
- Salva il registro
- Inserire l\'indirizzo e la porta
- Provare la connessione manuale
- Connettersi a \'%s\'?
- %d connesso
+ Connessione manuale
+ Salva il registro
diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml
index ab57b187..3e076a8c 100644
--- a/app/src/main/res/values-kab/strings.xml
+++ b/app/src/main/res/values-kab/strings.xml
@@ -1,40 +1,25 @@
- Tuffɣa
- d-yegran
+ Tuffɣa
Tamagit
- Ales tuqqna
- Sekles aɣmis
- Iɣewwaren
- Azen ifuyla
- Tuqqna s ufus
- Bḍu d
- Ibenkan i yellan
- Anadi ɣef ibenkan imaynuten
- kra n tasinin kan
+ Ales tuqqna
+ Sekles aɣmis
+ Iɣewwaren
+ Tuqqna s ufus
+ Bḍu d
+ Ibenkan i yellan
+ Anadi ɣef ibenkan imaynuten
Tuccḍa n tuqqna
- Fren akaram akken ad tazneḍ
- Ibenkan akked wara tebḍuḍ d yifuyla-k·m
- Aẓeṭṭa
+ Aẓeṭṭa
Lqem: %1$s
- Ulac d acu ara tebḍuḍ
Tugna n umaɣnu
Akaram n isidar
Asentel
Sekker s wudem awurman
Tangalt n wegraw
- Udmawan
- Asentel aceɛlal
- Asentel ubrik
- %d n yifuyla
- Seqdec imezwura n unagraw
- Tugna n umaɣnu
- Tignit n wesnas
- Ɛreḍ tuqqna s ufus seg wumuɣ
- %d i yettwaqqnen
- Qqen ɣer \'%s\'?
- Ɣef
- Udem
- Sekcem tansa akked tawwurt
- Ulac ibenkan d wara tebḍuḍ
+ Asentel aceɛlal
+ Asentel ubrik
+ Seqdec imezwura n unagraw
+ Ɣef
+ Udem
diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml
index 467dd02f..4b4ca371 100644
--- a/app/src/main/res/values-lv/strings.xml
+++ b/app/src/main/res/values-lv/strings.xml
@@ -1,49 +1,28 @@
- Nav savienojuma ar Wi-Fi vai LAN. Izmantojiet savu tīklāju, ja neviens no iepriekš minētajiem nav pieejams. Pēc savienojuma izveides restartējiet lietojumprogrammu.
- Tīkls
- Pārsūtījumi
- Sūtīt failus
- Iziet
- Atkārtot ierīces meklēšanu
- Dalīties ar
- Pieejamās ierīces
+ Tīkls
+ Iziet
+ Atkārtot ierīces meklēšanu
+ Dalīties ar
+ Pieejamās ierīces
Nav atrasta neviena ierīce
- Savienojums neizdevās: Nepareizs grupas kods
Savienojuma kļūda
- Gaida apstiprinājumu…
- (Faili var tikt pārrakstīti!)
- atlikuši
- Neizdevās atvērt saņemto failu
- vēl dažas sekundes
- , pakalpojums nav pieejams
- Pārsūtīšanas kļūdas:
- Šīs darbības aizvēršana koplietošanas laikā var izraisīt pārsūtīšanas neveiksmi
- Failu skaits: %d
+ Gaida apstiprinājumu…
Identitāte
- Saņemta neatbalstīta darbība
- Nosūtīto failu saraksts:
- Atlasiet mapi, kuru vēlaties nosūtīt
Versija: %1$s
- Šīs ir neoficiālais Warpinator ports.
-\nSūtiet un saņemiet failus lokālajā tīklā.
- Koplietošanas ierīces
- Nav pieejama neviena koplietošanas ierīce
- Nav ar ko dalīties
- Pārsūtīšanas iestatījumi
- Izskats
+ Pārsūtīšanas iestatījumi
+ Izskats
Parādāmais vārds
Profila attēls
Lejupielādes mape
Rādīt ienākošo failu paziņojumu
Atļaut pārrakstīt
Automātiski pieņemt pārsūtījumus
- Katrs pārsūtījums ir jāapstiprina
- Palaist fona režīmā
+ Katrs pārsūtījums ir jāapstiprina
Palaist automātiski
- Apturēt pakalpojumu, kad iziet no lietotnes
- Pēc palaišanas pakalpojums turpinās darboties, līdz tas tiks manuāli apturēts
- Rakstīt žurnālu failā
+ Apturēt pakalpojumu, kad iziet no lietotnes
+ Pēc palaišanas pakalpojums turpinās darboties, līdz tas tiks manuāli apturēts
+ Rakstīt žurnālu failā
Grupas kods
Ports
Tēma
@@ -51,46 +30,23 @@
Lai mainītu šo iestatījumu, lietojumprogramma ir jārestartē
Ienākošie faili ar tādu pašu nosaukumu, kas jau pastāv, pārrakstīs esošos
Ienākošie faili ar tādu pašu nosaukumu, kas jau pastāv, tiks pārdēvēti
- Atrašanās vietas, kas nav noklusējuma, neatbalsta pēdējo izmaiņu laikspiedolu saglabāšanu. Varat atgriezties pie noklusējuma iestatījumiem, izmantojot atiestatīšanas pogu.
- Visi pārsūtījumi pabeigti
%1$.1f%%, %2$d pārsūtījumi, %3$s/sek
Pakalpojums darbojas
Paziņojums par pakalpojuma statusu
Ienākošais pārsūtījums
Pārsūtīšanas progress
- Savienojuma statuss
- Profila attēls
- Programmas ikona
- Neapmierinātas sejas ikona
- Noraidīt pārsūtījumu
- Pieņemt pārsūtījumu
- Apturēt pārsūtījumu
- Atkārtoti pārsūtīt
- Visticamāk, jūs pirmo reizi palaižat šo lietojumprogrammu. Lūdzu, atlasiet mapi, kurā programma Warpinator saglabās failus.
- Jums ir janorāda lejupielādes mape iestatījumos
- Atiestatīt uz noklusējuma vērtību
- Notīrīt pabeigtos pārsūtījumus
- Tīkls nav pieejams - mēģināt tīklāju\?
- Pakalpojums nedarbojas
- Neizdevās iegūt sertifikātu no %1$s. Pārliecinieties, ka ir atļauts UDP (kā arī TCP) trafiks %2$d attālā ugunsmūra portā.
- Iestatījumi
- Šī programma ir atvērtā koda programmatūra, kas licencēta saskaņā ar <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU vispārējo publisko licenci</a>.<br>Avota kodu varat lejupielādēt no <a href=\"https://github.com/slowscript/warpinator-android\">GitHub</a> vietnes.<br> <br> Programmai netiek sniegta nekāda veida garantija.<br>Sīkāku informāciju skatiet licences noteikumos.
- Savienojuma problēmas
- Par
- Ierīces atkārtota reģistrēšana
- Pārsūtījumi tiek pieņemti automātiski
- Pakalpojums tiks automātiski apturēts, kad lietojumprogramma tiks notīrīta no pēdējām, kā arī pēc noteikta neaktivitātes perioda
- Gaiša tēma
+ Iestatījumi
+ Savienojuma problēmas
+ Par
+ Ierīces atkārtota reģistrēšana
+ Pārsūtījumi tiek pieņemti automātiski
+ Pakalpojums tiks automātiski apturēts, kad lietojumprogramma tiks notīrīta no pēdējām, kā arī pēc noteikta neaktivitātes perioda
+ Gaiša tēma
Porta numuram ir jābūt no 1024 līdz 65535
- Jūsu tālruņa ražotājs neieviesa nepieciešamo dialoglogu. Tas tiks atrisināts nākamajā laidiena versijā.
- Pielāgots
- Jūs esat izvēlējušies neatbalstītu satura nodrošinātāju. Lūdzu, atlasiet mapi savā iekšējā (lokālajā) krātuvē.
Warpinator pakalpojums darbojas
- Izmantot sistēmas noklusējuma iestatījumus
- Tumša tēma
+ Izmantot sistēmas noklusējuma iestatījumus
+ Tumša tēma
Apturēt pakalpojumu
- Tīkls mainīts - pakalpojuma restartēšana…
- Ienākošais pārsūtījums no %s
- Izvēlētais attēls
- Atkārtoti izveidot savienojumu
+ Ienākošais pārsūtījums no %s
+ Atkārtoti izveidot savienojumu
\ No newline at end of file
diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml
index 8cbbc5af..f1c6a616 100644
--- a/app/src/main/res/values-ml/strings.xml
+++ b/app/src/main/res/values-ml/strings.xml
@@ -1,53 +1,25 @@
-
-
- കൈമാറ്റങ്ങൾ
- ഫയലുകൾ അയയ്ക്കുക
- ഉപേക്ഷിക്കുക
- കുറിച്ച്
- ക്രമീകരണങ്ങൾ
- പങ്കിടുക
- ലഭ്യമായ ഉപകരണങ്ങൾ
+ ഉപേക്ഷിക്കുക
+ കുറിച്ച്
+ ക്രമീകരണങ്ങൾ
+ പങ്കിടുക
+ ലഭ്യമായ ഉപകരണങ്ങൾ
മറ്റ് ഉപകരണങ്ങളൊന്നും കണ്ടെത്തിയില്ല
- ബന്ധിപ്പിക്കുന്നത് പരാജയപ്പെട്ടു: തെറ്റായ സംഘ സംഖ്യ
- നിങ്ങൾ വൈഫൈ അല്ലെങ്കിൽ ലാനിലേക്ക് കണക്റ്റു ചെയ്തിട്ടില്ല. ഒന്നും ലഭ്യമല്ലെങ്കിൽ നിങ്ങളുടെ ഹോട്ട്സ്പോട്ട് ഉപയോഗിക്കുക.
- നിങ്ങൾ കണക്റ്റു ചെയ്തിരിക്കുമ്പോൾ അപ്ലിക്കേഷൻ പുനരാരംഭിക്കുക.
-
- അനുമതിക്കായി കാത്തിരിക്കുന്നു…
- (ഫയലുകൾ തിരുത്തപ്പെട്ടേക്കാം!)
- ബാക്കിയുള്ളത്
- ചില നിമിഷങ്ങൾ കൂടി
- , സേവനം ലഭ്യമല്ല
- കൈമാറ്റം ചെയ്യുമ്പോൾ പിശകുകൾ:
- പങ്കിടുമ്പോൾ ഈ പ്രവർത്തനം നിർത്തുന്നത് കൈമാറ്റം പരാജയപ്പെടാൻ കാരണമായേക്കാം
-
+ അനുമതിക്കായി കാത്തിരിക്കുന്നു…
പതിപ്പ്: %1$s
- ഇത് വാർപിനേറ്ററിന്റെ അനൗദ്യോഗിക ബഹുരൂപമാണ്. \nപ്രാദേശിക ശൃംഖലയിൽ ഫയലുകൾ അയക്കാം, സ്വീകരിക്കാം.
-
- ഈ പ്രോഗ്രാം ഓപ്പൺ സോഴ്സ് സോഫ്റ്റ്വെയറാണ് under <a href="https://www.gnu.org/licenses/gpl-3.0.html">ഗ്നു ജനറൽ പബ്ലിക് ലൈസൻസ് </a>.<br>
- നിങ്ങൾക്ക് എവിടെ നിന്ന് ഉറവിട കോഡ് ലഭിക്കും <a href="https://github.com/slowscript/warpinator-android/">GitHub</a>.<br>
- <br>
- ഈ പ്രോഗ്രാമിന് യാതൊരു വാറന്റിയുമില്ല.<br>See the terms of the license for more details.
-
- പങ്കിടാനുള്ള ഉപകരണങ്ങൾ
- പങ്കിടാൻ ലഭ്യമായ ഉപകരണങ്ങളൊന്നുമില്ല
- ഞങ്ങൾക്ക് പിന്തുണയ്ക്കാത്ത ഒരു ഇടപെടൽ ലഭിച്ചു
- പങ്കിടാൻ ഒന്നുമില്ല
-
വ്യക്തിത്വം
- കൈമാറ്റ ക്രമീകരണങ്ങൾ
- ശ്രിംഘല
- വീക്ഷണം
+ കൈമാറ്റ ക്രമീകരണങ്ങൾ
+ ശ്രിംഘല
+ വീക്ഷണം
പ്രദർശന നാമം
പ്രൊഫൈൽ ചിത്രം
ഡൗൺലോഡ് നുള്ള ഡയറക്ടറി
വരുന്ന ഫയലുകളെക്കുറിച്ചുള്ള അറിയിപ്പ് കാണിക്കുക
പുനരാലേഖനം അനുവദിക്കുക
കൈമാറ്റങ്ങൾ സ്വപ്രേരിതമായി സ്വീകരിക്കുക
- കൈമാറ്റങ്ങൾ സ്വപ്രേരിതമായി സ്വീകരിക്കും
- എല്ലാ കൈമാറ്റങ്ങളും നിങ്ങൾ അംഗീകരിക്കേണ്ടതുണ്ട്
- പശ്ചാത്തലത്തിൽ പ്രവർത്തിപ്പിക്കുക
+ കൈമാറ്റങ്ങൾ സ്വപ്രേരിതമായി സ്വീകരിക്കും
+ എല്ലാ കൈമാറ്റങ്ങളും നിങ്ങൾ അംഗീകരിക്കേണ്ടതുണ്ട്
യാന്ത്രികമായി ആരംഭിക്കുക
ഗ്രൂപ്പ് കോഡ്
കൈമാറ്റങ്ങൾക്കുള്ള പോർട്ട്
@@ -57,68 +29,21 @@
ഈ ക്രമീകരണം മാറ്റുന്നതിന് അപ്ലിക്കേഷൻ പുനരാരംഭിക്കേണ്ടതുണ്ട്
ഇതിനകം നിലവിലുള്ള ഒരു പേരിനൊപ്പം വരുന്ന ഫയലുകൾ നിലവിലുള്ളവയെ പുനരാലേഖനം ചെയ്യും
ഇതിനകം നിലവിലുള്ള ഒരു പേരുള്ള വരുന്ന ഫയലുകളുടെ പേരുമാറ്റപ്പെടും
- വ്യത്യസ്തം
- നിങ്ങൾ പിന്തുണയ്ക്കാത്ത ഉള്ളടക്ക ദാതാവിനെ തിരഞ്ഞെടുത്തു. നിങ്ങളുടെ ആന്തരിക സംഭരണത്തിൽ ഒരു ഡയറക്ടറി തിരഞ്ഞെടുക്കുക.
-
- സിസ്റ്റം സ്ഥിരസ്ഥിതി ഉപയോഗിക്കുക
- പ്രകാശമുള്ള ആവരണം
- ഇരുണ്ട ആവരണം
-
+ സിസ്റ്റം സ്ഥിരസ്ഥിതി ഉപയോഗിക്കുക
+ പ്രകാശമുള്ള ആവരണം
+ ഇരുണ്ട ആവരണം
വാർപിനേറ്റർ സേവനം പ്രവർത്തിക്കുന്നു
സേവനം നിർത്തുക
- എല്ലാ കൈമാറ്റങ്ങളും പൂർത്തിയായി
%1$.1f%%, %2$d കൈമാറ്റങ്ങൾ, %3$s/s
- %s നിന്ന് കൈമാറ്റം വരുന്നു
- %d ഫയലുകൾ
+ %s നിന്ന് കൈമാറ്റം വരുന്നു
സേവനം പ്രവർത്തിക്കുന്നു
സ്ഥിരമായ സേവന അറിയിപ്പ്
അകത്തേക്കുള്ള കൈമാറ്റം
കൈമാറ്റം പുരോഗതി
-
- നിങ്ങൾ ആദ്യമായാണ് ഈ അപ്ലിക്കേഷൻ സമാരംഭിക്കുന്നത്.
- ഫയലുകൾ സംരക്ഷിക്കാൻ വാർപിനേറ്റർ ആഗ്രഹിക്കുന്ന ഒരു ഡയറക്ടറി ദയവായി തിരഞ്ഞെടുക്കുക.
- ക്രമീകരണങ്ങളിൽ നിങ്ങൾ ഒരു ഡൗൺലോഡ് ഡയറക്ടറി തിരഞ്ഞെടുക്കണം
-
- - ബന്ധിപ്പിച്ചു
- - വിച്ഛേദിച്ചു
- - ബന്ധിപ്പിക്കുന്നു
- - ബന്ധിപ്പിക്കൽ പരാജയപ്പെട്ടു
- - ഡ്യുപ്ലെക്സിനായി കാത്തിരിക്കുന്നു
-
-
- - സമാരംഭിക്കുന്നു
- - അനുമതിക്കായി കാത്തിരിക്കുന്നു…
- - നിരസിച്ചു
- - കൈമാറുന്നു
- - താൽക്കാലികമായി നിർത്തി
- - നിർത്തി
- - പരാജയപ്പെട്ടു (പിശകുകൾ കാണാൻ തൊടുക)
- - വീണ്ടെടുക്കാനാവാത്ത പരാജയം
- - ഫയൽ കണ്ടെത്തിയില്ല
- - പൂർത്തിയായി
- - പിശകുകളോടെ പൂർത്തിയാക്കി (കാണാൻ തൊടുക))
-
- വീണ്ടും പ്രഖ്യാപിക്കുക
- നെറ്റ്വർക്ക് മാറി - സേവനം പുനരാരംഭിക്കുന്നു…
- കൈമാറ്റം വീണ്ടും ശ്രമിക്കുക
- കൈമാറ്റം നിർത്തുക
- നിങ്ങളുടെ സംഘത്തിന് പുറത്തുള്ള ഉപകരണങ്ങൾ: %1$d
- ഒരിക്കൽ ആരംഭിച്ചാൽ, സ്വമേധയാ നിർത്തുന്നത് വരെ സേവനം തുടരും
- പ്രയോഗം വിട്ടതിന് ശേഷം സേവനം നിർത്തുക
- അയയ്ക്കുന്ന ഫയലുകൾ:
+ വീണ്ടും പ്രഖ്യാപിക്കുക
+ ഒരിക്കൽ ആരംഭിച്ചാൽ, സ്വമേധയാ നിർത്തുന്നത് വരെ സേവനം തുടരും
+ പ്രയോഗം വിട്ടതിന് ശേഷം സേവനം നിർത്തുക
കംപ്രഷൻ ഉപയോഗിക്കാൻ ശ്രമിക്കുക
- പൂർത്തിയായ കൈമാറ്റങ്ങൾ മായ്ക്കുക
- നെറ്റ്വർക്ക് ലഭ്യമല്ല - ഹോട്ട്സ്പോട്ട് പരീക്ഷിക്കണോ\?
- നിരാശാജനകമായ മുഖത്തിന്റെ ബിംബം
- നിങ്ങളുടെ ഫോണിന്റെ വെണ്ടർ ആവശ്യമായ ഡയലോഗ് നടപ്പിലാക്കിയില്ല. ഭാവി റിലീസിൽ ഇത് പ്രവർത്തിക്കും.
- സമീപകാലങ്ങളിൽ നിന്ന് മായ്ക്കുമ്പോൾ അല്ലെങ്കിൽ ഒരു നിശ്ചിത സമയത്തിന് ശേഷം നിഷ്ക്രിയത്വത്തിന് ശേഷം സേവനം സ്വയമേവ നിർത്തപ്പെടും
- രൂപരേഖ ചരിത്രം
- സേവനം പ്രവർത്തിക്കുന്നില്ല
- പ്രയോഗ ബിംബം
- സ്ഥിര മൂല്യത്തിലേക്ക് പുനഃസജ്ജമാക്കുക
- കൈമാറ്റം സ്വീകരിക്കുക
- കൈമാറ്റം നിരസിക്കുക
- തിരഞ്ഞെടുത്ത ചിത്രം
- അയയ്ക്കാൻ ഒരു ഫോൾഡർ തിരഞ്ഞെടുക്കുക
- ബന്ധ പ്രശ്നങ്ങൾ
+ സമീപകാലങ്ങളിൽ നിന്ന് മായ്ക്കുമ്പോൾ അല്ലെങ്കിൽ ഒരു നിശ്ചിത സമയത്തിന് ശേഷം നിഷ്ക്രിയത്വത്തിന് ശേഷം സേവനം സ്വയമേവ നിർത്തപ്പെടും
+ ബന്ധ പ്രശ്നങ്ങൾ
\ No newline at end of file
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index 6c483449..f70a4117 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -1,51 +1,32 @@
- Om
- Lys drakt
- Overføringer
- Send filer
- Avslutt
- Tilkobling mislyktes: Feil gruppekode
+ Om
+ Lys drakt
+ Avslutt
Tilkoblingsfeil
- Venter på tilgang …
- (Filer kan bli overskrevet!)
- et par sekunder
- Feil under overføring:
+ Venter på tilgang …
Port
Drakt
Portnummer må være mellom 1024 og 65535
- Egendefinert
- Bruk systemforvalg
- Alle overføringer er fullført
+ Bruk systemforvalg
%1$.1f%%, %2$d overføringer, %3$s/s
- %d filer
Tjenesten kjører
Vedvarende tjenestemerknad
Innkommende overføring
- Tilkoblingsstatus
- Profilbilde
- Programikon
- Valgt bilde
- Avslå overføringen
- Godta overføringen
- Stopp overføringen
- Prøv å overføre igjen
- Tjenesten kjører ikke
- Tilkoblingsproblemer
- Se etter enheter igjen
- Reannonser
- Innstillinger
- Del med
- Tilgjengelige enheter
+ Tilkoblingsproblemer
+ Se etter enheter igjen
+ Reannonser
+ Innstillinger
+ Del med
+ Tilgjengelige enheter
Fant ingen andre enheter
Start automatisk
Versjon: %1$s
Tillat overskriving
- Kjør i bakgrunnen
- Mørk drakt
+ Mørk drakt
Stopp tjenesten
Warpinator-tjenesten kjører
- Innkommende overføring fra %s
+ Innkommende overføring fra %s
Overføringsfremdrift
- Koble til igjen
+ Koble til igjen
\ No newline at end of file
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000..530cc24c
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #141414
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml
deleted file mode 100644
index 74e78be9..00000000
--- a/app/src/main/res/values-night/styles.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index b40124fc..0b1be2a4 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -1,88 +1,44 @@
- Stuur bestanden
- Beëindigen
- Heraankondiging
- Over (...?)
- Instellingen
- Beschikbare apparaten
+ Beëindigen
+ Heraankondiging
+ Over (…?)
+ Instellingen
+ Beschikbare apparaten
Geen andere apparaten gevonden
- Verbinding mislukt: Foute groepscode
Verbindingsfout
- Apparaten buiten jouw groep: %1$d
- Wachtend op toestemming…
- Niets om te delen
- We ontvingen een niet ondersteunde actie
+ Wachtend op toestemming…
Toon notificatie van binnenkomende bestanden
- Licht kleurthema
+ Licht kleurthema
Stop de service
- Alle verzendingen zijn afgerond
- Dit is een onofficiële \'port\' van Warpinator. \nVerstuur en ontvang bestanden via het lokale netwerk.
- Verzending
- Verbindingsproblemen
- Bewaar logging
- Handmatige verbinding
- Scan opnieuw voor apparaten
- Deel met
- Je bent niet verbonden met wifi of een lokaal netwerk. Gebruik je hotspot als beide niet beschikbaar zijn. Herstart de applicatie als je verbinding hebt.
- Fouten tijdens verzending:
- Netwerk is veranderd - service wordt herstart…
- (Bestanden kunnen overschreven worden)
+ Verbindingsproblemen
+ Bewaar logging
+ Handmatige verbinding
+ Scan opnieuw voor apparaten
+ Deel met
Weergavenaam
Downloadfolder
Profielafbeelding
- Gebruik systeemdefault
+ Gebruik systeemdefault
%1$.1f%%, %2$d verzendingen, %3$s/s
Aanhoudende/Blijvende? servicenotificatie
- Adres is naar klipbord gekopieerd
- resterend(e)?
- Openen van ontvangen bestand is mislukt
- een paar seconden
- , service niet beschikbaar
- Het sluiten van deze activiteit tijdens het delen van een bestand, kan er toe leiden dat het verzenden fout gaat
- Bestanden die verzonden worden:
- Kies een map om te verzenden
Versie: %1$s
- Apparaten om mee te delen
- Geen beschikbare apparaten om mee te delen
Identiteit
- Verzendinstellingen
- Netwerk
- Aspect?
- Maatwerk?
- Donker kleurthema
+ Verzendinstellingen
+ Netwerk
+ Aspect?
+ Donker kleurthema
Warpinatorservice draait
- Binnenkomende verzending van %s
- %d bestanden
+ Binnenkomende verzending van %s
Service draait?
Binnenkomende verzending
Voortgang verzending
- Opnieuw verbinden
- Verbindingsstatus
- Profielafbeelding
- Applicatie-icon
- Icon teleurgesteld gezicht
- Stop de verzending
- Gekozen afbeelding
- Wijs de verzending af
- Accepteer de verzending
- Probeer de verzending opnieuw
- Zet terug naar standaard waarde
- Service draait niet
- QR-code
- Vul adres en poort in
- Netwerk niet beschikbaar - wil je een hotspot proberen?
- Verbind met \'%s\'?
- %d verbonden
- Verwijder afgeronde verzendingen
- Bereid opzetten verbinding voor
- Je moet een downloaddirectory selecteren bij de instellingen
- Verzendingen worden automatisch geaccepteerd
- Als de service eenmaal gestart is, blijft die draaien totdat ze handmatig gestopt wordt
+ Opnieuw verbinden
+ Verzendingen worden automatisch geaccepteerd
+ Als de service eenmaal gestart is, blijft die draaien totdat ze handmatig gestopt wordt
Sta overschrijven toe
Verzending automatisch accepteren
Probeer compressie te gebruiken
- Elke verzending moet door jou geaccepteerd worden
- De service wordt automatisch gestopt wanneer deze wordt verwijderd uit ?recents? of na een periode van inactiviteit.
- Dit programma is open source software met licentie onder <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> De broncode is beschikbaar oo <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Dit programma geeft absoluut geen garanties.<br>Zie de licentievoorwaarden voor meer informatie.
+ Elke verzending moet door jou geaccepteerd worden
+ De service wordt automatisch gestopt wanneer deze wordt verwijderd uit ?recents? of na een periode van inactiviteit.
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index b2df4c5b..2c99d133 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -1,148 +1,60 @@
-
-
- Przesyłanie plików
- Wysłane pliki
- Wyjdź
- Problemy z połączeniem
- Rozgłoś ponownie
- Ponownie wyszukaj urządzenia
- O programie
- Ustawienia
- Wyślij do
- Dostępne urządzenia
+ Wyjdź
+ Problemy z połączeniem
+ Rozgłoś ponownie
+ Ponownie wyszukaj urządzenia
+ O programie
+ Ustawienia
+ Wyślij do
+ Dostępne urządzenia
Nie znaleziono innych urządzeń
- Nie udało się połączyć: błędny kod grupy
Błąd połączenia
- Urządzenie nie jest połączone z siecią Wi-Fi lub LAN. Użyj hotspotu Wi-Fi, jeżeli inne połączenia nie są dostępne.
-Uruchom ponownie aplikację po połączeniu.
- Połączenie zostało zmienione — restartowanie usługi…
-
- Oczekiwanie na zgodę…
- (Pliki mogą zostać nadpisane!)
- pozostało
- Nie udało się otworzyć odebranego pliku
- kilka sekund
- , usługa niedostępna
- Błędy podczas przesyłania:
- Zamknięcie tej usługi podczas przesyłania plików może spowodować, że przesyłanie się nie powiedzie
- Przesyłane pliki:
-
+ Oczekiwanie na zgodę…
Wersja: %1$s
- To nieoficjalny port programu Warpinator. \nWysyłaj i odbieraj pliki przez sieć lokalną.
- Ta aplikacja jest otwartym oprogramowaniem, dostępnym na licencji <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Możesz pobrać kod źródłowy z serwisu <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Ta aplikacja jest dostarczona bez jakichkolwiek gwarancji.<br>Sprawdź zasady licencji w celu uzyskania szczegółowych informacji.
-
- Dostępne urządzenia
- Brak dostępnych urządzeń
- Otrzymano nieobsługiwane żądanie
- Nie ma nic do udostępnienia
-
Tożsamość
- Ustawienia przesyłania
- Sieć
- Wygląd
+ Ustawienia przesyłania
+ Sieć
+ Wygląd
Wyświetlana nazwa
Obrazek profilowy
Folder na odebrane pliki
Pokaż powiadomienie o przychodzących plikach
Zezwól na nadpisywanie
Automatycznie akceptuj pliki
- Przychodzące pliki będą automatycznie akceptowane
- Wszystkie przychodzące pliki muszą być zaakceptowane przez Ciebie
- Działaj w tle
+ Przychodzące pliki będą automatycznie akceptowane
+ Wszystkie przychodzące pliki muszą być zaakceptowane przez Ciebie
Uruchamiaj automatycznie
- Zapisz dziennik debugowania do pliku
+ Zapisz dziennik debugowania do pliku
Kod grupy
Port do transferów
Motyw aplikacji
Może zależeć od ustawień powiadomień Androida
Numer portu musi być pomiędzy 1024 a 65535
Zmaina tego ustawienia wymaga ponownego uruchomienia aplikacji
- Producent telefonu nie zaimplementował wymaganego okna dialogowego. Zostanie to poprawione w przyszłej wersji.
Przychodzące pliki nadpiszą istniejące pliki o takiej samej nazwie
Jeżeli istnieją już pliki o identycznej nazwie, nazwy przychodzących plików zostaną zmienione
- Własny
- Wybrałeś pliki z nieobsługiwanego źródła. Wybierz katalog w pamięci urządzenia.
-
- Użyj motywu systemu
- Jasny motyw
- Ciemny motyw
-
+ Użyj motywu systemu
+ Jasny motyw
+ Ciemny motyw
Warpinator działa w tle
Zatrzymaj
- Przesyłanie zakończone
%1$.1f%%, %2$d transfer(y), %3$s/s
- Przychodzące pliki od %s
- %d plik(i)
+ Przychodzące pliki od %s
Warpinator jest aktywny
Stałe powiadomienie o działaniu w tle
Przychodzące pliki
Postęp przesyłania plików
-
- Połącz ponownie
- Status połączenia
- Obrazek profilowy
- Ikona aplikacji
- Ikona rozczarowanej twarzy
- Wybrany obrazek
- Odmów zgody na przesłanie plików
- Wyraź zgodę na przesłanie plików
- Zatrzymaj przesyłanie plików
- Wznów przesyłanie plików
-
- Uruchamiasz aplikację po raz pierwszy.
- Wybierz folder, w którym Warpinator będzie zapisywał otrzymane pliki.
- Musisz wybrać folder dla pobieranych plików w ustawieniach aplikacji
- Sieć niedostępna — połączyć z hotspotem?
- Usługa nie jest uruchomiona
- Nie udało się odebrać certyfikatu od urządzenia %1$s. Upewnij się, że zezwolono na połączenia poprzez protokół UDP (oraz TCP) na porcie %2$d w ustawieniach zapory sieciowej drugiego urządzenia.
-
- - Połączony
- - Rozłączony
- - Łączenie
- - Połączenie się nie powiodło
- - Oczekiwanie na połączenie dwukierunkowe
-
-
- - Inicjalizacja
- - Oczekiwanie na zgodę…
- - Odmówiono zgody
- - Przesyłanie
- - Wstrzymane
- - Zatrzymane
- - Wystąpił błąd, dotknij aby poznać szczegóły
- - Błąd niemożliwy do naprawienia
- - Nie znaleziono pliku
- - Ukończone
- - Ukończono z błędami (dotknij aby poznać szczegóły)
-
- Usługa zostanie automatycznie zatrzymana po usunięciu z ostatnich lub po okresie bezczynności
- Po uruchomieniu usługa będzie działać do momentu ręcznego zatrzymania
- Wybierz folder do wysłania
- Zatrzymaj usługę po zamknięciu aplikacji
- Lokalizacje inne niż domyślne nie obsługują zachowywania sygnatur czasowych ostatniej modyfikacji. Możesz przywrócić ustawienia domyślne za pomocą przycisku resetowania.
- Przywróć wartość domyślną
- Wyczyść zakończone transfery
+ Połącz ponownie
+ Usługa zostanie automatycznie zatrzymana po usunięciu z ostatnich lub po okresie bezczynności
+ Po uruchomieniu usługa będzie działać do momentu ręcznego zatrzymania
+ Zatrzymaj usługę po zamknięciu aplikacji
Spróbuj użyć kompresji
Port do rejestracji
- Urządzenia spoza grupy: %1$d
- Nowe połączenie
Zeskanuj powyższy kod QR za pomocą aplikacji aparatu i otwórz łącze lub użyj poniższego adresu, aby zainicjować połączenie z innego urządzenia:
- Połączenie ręczne
- Zapisz dziennik
- Połączyć się z \'%s\'?
- Połączone: %d
- Wprowadź adres i port
- Wypróbuj połączenie ręczne w menu
- Adres skopiowano do schowka
- Kod QR
- Urządzenia muszą znajdować się w tej samej sieci i używać tego samego kodu grupy
- Inne urządzenie nie nawiązało połączenia. Może nas nie widzi?
+ Połączenie ręczne
+ Zapisz dziennik
Preferowany interfejs sieciowy
%s (wraca do trybu automatycznego, jeśli niedostępny)
Autostart niedostępny w systemie Android 15 i nowszych
- , nie w tej samej podsieci
- Uwaga
- To urządzenie nie znajduje się w tej samej podsieci. Połączenie z nim prawdopodobnie się nie powiedzie. Sprawdź w ustawieniach obu urządzeń, czy Warpinator korzysta z prawidłowego interfejsu sieciowego.
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index afc84819..338909c9 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -1,55 +1,29 @@
-
-
- Transferências
- Enviar arquivos
- Sair
- Problemas de conexão
- Sobre
- Configurações
- Compartilhe
- Dispositivos disponíveis
+ Sair
+ Problemas de conexão
+ Sobre
+ Configurações
+ Compartilhe
+ Dispositivos disponíveis
Nenhum dispositivo encontrado
- Conexão falhou: Código do grupo incorreto
Falha da conexão
- Você não está conectado ao WiFi ou LAN. Neste caso você pode criar um hotspot. Reinicie o app quando estiver conectado.
- Rede mudou - reiniciando serviço…
-
- Quer receber os arquivo(s)…
- (Arquivos podem ser substituídos!)
- falta
- alguns segundos
- , serviço indisponível
- Erros durante a transferência:
- Ao encerrar este processo enquanto está compartilhando arquivos você pode interromper conclusão de uma transferência
- Arquivos sendo enviados:
-
+ Quer receber os arquivo(s)…
Versão: %1$s
- Esta é uma modificação não-oficial do Warpinator.
-\nCompartilhe arquivos na rede local.
- O app é de software livre, licenciado sob <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">Licença Pública Geral GNU</a>.<br> Pode obter o código fonte no <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> O app vem sem absolutamente nenhuma garantia.<br>Veja os termos da licença para mais detalhes.
-
- Dispositivos disponível para compartilhar
- Nenhum dispositivo disponível para compartilhar
- Operação sem suporte
- Nada para compartilhar
-
Identificação
- Configurações de transferência
- Rede
- Aparência
+ Configurações de transferência
+ Rede
+ Aparência
Nome visível na rede
Foto do perfil
Pasta de arquivos
Mostrar notificações de arquivos recebidos
Permitir substituição
Aceitar todas transferências
- Transferências serão aceitas automaticamente
- Cada transferência precisa ser confirmada por você
- Executar em segundo plano
+ Transferências serão aceitas automaticamente
+ Cada transferência precisa ser confirmada por você
Iniciar automaticamente
- Salvar logs no arquivo
+ Salvar logs no arquivo
Código do grupo
Porto para transferências
Tema
@@ -58,73 +32,23 @@
Alteração desta configuração exigirá reinício do app
Arquivos recebidos que tem os mesmos nomes substituirão já existentes
Arquivos recebidos que tem os mesmos nomes serão renomeados
- Personalizar
- Você selecionou uma fonte não suportada. Por favor selecione uma pasta no armazenamento.
-
- Padrão do sistema
- Tema claro
- Tema escuro
-
+ Padrão do sistema
+ Tema claro
+ Tema escuro
Warpinator está funcionando
Encerrar serviço
- Todas transferências concluídas
%1$.1f%%, %2$d transferências, %3$s/s
- Transferência(s) recebida(s) de %s
- %d arquivo(s)
+ Transferência(s) recebida(s) de %s
Serviço funcionando
Notificação continua sobre serviço
Transferência recebida
Progresso da transferência
-
- Reconectar
- Status da conexão
- Foto do perfil
- Ícone do app
- Ícone do rosto desanimado
- Foto selecionada
- Recusar a transferência
- Aceitar a transferência
- Parar a transferência
- Retomar a transferência
-
- Deve ser a primeira vez que você usa este app.
- Por favor selecione uma pasta onde você quer que o Warpinator salve seus arquivos.
- Você tem que selecionar uma pasta para Downloads em configurações
- Rede indisponível - configure próprio hotspot\?
- Serviço não está funcionando
- Falha ao receber certificado de %1$s. Assegure-se de que o tráfego no firewall remoto para UDP (e TCP) na Porta %2$d está permitido.
-
- - Conectado
- - Desconectado
- - Conectando
- - Conexão falhou
- - Aguardando duplex
-
-
- - Iniciando
- - Aguardando a permissão…
- - Recusado
- - Transferindo
- - Pausado
- - Parado
- - Falhou (toque para ver os detalhes)
- - Falha irrecuperável
- - Arquivo não encontrado
- - Concluído
- - Concluído com erros (toque para detalhes)
-
- Divulgar de novo
- Descobrir dispositivos
- Falha ao abrir o arquivo recebido
- Parar serviço após saída do app
- Serviço será interrompido automaticamente ao remover de recentes ou após um período de inatividade
- Uma vez iniciado, o serviço ficará funcionando até ser interrompido manualmente
- Fabricante do seu telefone não implementou diálogo solicitado. Isto será solucionado em lançamento futuro.
+ Reconectar
+ Divulgar de novo
+ Descobrir dispositivos
+ Parar serviço após saída do app
+ Serviço será interrompido automaticamente ao remover de recentes ou após um período de inatividade
+ Uma vez iniciado, o serviço ficará funcionando até ser interrompido manualmente
Tente usar compressão
Porta para registar
- Redefinir para valor padrão
- Limpar transferências concluídas
- Selecione uma pasta a enviar
- Locais não padrões não oferecem suporte à preservação de marcações de data/hora da última modificação. Você pode reverter para o padrão com o botão de redefinição.
- Aparelhos fora do seu grupo: %1$d
\ No newline at end of file
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 34f4484a..e0830297 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1,107 +1,57 @@
- Transferências
- Enviar ficheiros
- Sair
- Problemas de conexão
+ Sair
+ Problemas de conexão
Porto para transferências
- Aparelhos fora do seu grupo: %1$d
- Fechar esta atividade durante o compartilhamento pode fazer com que a transferência falhe
- Selecione uma pasta a enviar
- Esta é uma modificação não-oficial do Warpinator.
-\nCompartilhe ficheiros na rede local.
- Aparelhos disponível para compartilhar
- Nenhum aparelho disponível para compartilhar
Tente usar compressão
Porta para registar
Tema
- Locais não padrões não oferecem suporte à preservação de carimbos de data/hora da última modificação. Pode reverter para o padrão com o botão de redefinição.
- Aceitar a transferência
- Parar a transferência
- Repor ao valor padrão
- Limpar transferências concluídas
- Divulgar de novo
- Re-descobrir aparelhos
- Sobre
- Configurações
- Compartilhe
- Aparelhos disponíveis
+ Divulgar de novo
+ Re-descobrir aparelhos
+ Sobre
+ Configurações
+ Compartilhe
+ Aparelhos disponíveis
Nenhum aparelho encontrado
- Conexão falhou: Código do grupo incorreto
Falha da conexão
- Não está conectado ao WiFi ou LAN. Neste caso pode criar um hotspot. Reinicie a aplicação quando estiver conectado.
- Rede mudou - reiniciando serviço…
- Quer receber os ficheiro(s)…
- (Ficheiros podem ser substituídos!)
- falta
- Falha ao abrir o ficheiro recebido
- alguns segundos
- , serviço indisponível
- Erros durante a transferência:
- Ficheiros a serem enviados:
+ Quer receber os ficheiro(s)…
Versão: %1$s
- O app é de software livre, licenciado sob <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">Licença Pública Geral GNU</a>.<br> Pode obter o código fonte no <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> O app vem sem absolutamente nenhuma garantia.<br>Veja os termos da licença para mais detalhes.
- Operação sem suporte
- Nada para compartilhar
Identificação
- Configurações de transferência
- Rede
- Aparência
+ Configurações de transferência
+ Rede
+ Aparência
Nome visível na rede
Foto do perfil
Pasta para descargas
Mostrar notificações de ficheiros recebidos
Permitir substituição
Aceitar todas transferências
- Transferências serão aceitas automaticamente
- Cada transferência precisa ser confirmada por so
- Executar em segundo plano
+ Transferências serão aceitas automaticamente
+ Cada transferência precisa ser confirmada por so
Iniciar automaticamente
- Parar serviço após saída do app
- Serviço será interrompido automaticamente ao remover de recentes ou após um período de inatividade
- Uma vez iniciado, o serviço ficará funcionando até ser interrompido manualmente
- Gravar logs no ficheiro
+ Parar serviço após saída do app
+ Serviço será interrompido automaticamente ao remover de recentes ou após um período de inatividade
+ Uma vez iniciado, o serviço ficará funcionando até ser interrompido manualmente
+ Gravar logs no ficheiro
Código do grupo
Pode ser afetado pelas configurações de notificações do Android
Defina número da Porta entre 1024 e 65535
Alteração desta configuração exigirá reinício do app
- O fabricante do seu telefone não implementou diálogo solicitado. Isto será solucionado num lançamento futuro.
Ficheiros recebidos que tem os mesmos nomes substituirão já existentes
Ficheiros recebidos que tem os mesmos nomes serão renomeados
- Personalizar
- Tema escuro
+ Tema escuro
Warpinator está em execução
- Selecionou uma fonte não suportada. Por favor selecione uma pasta no armazenamento.
- Padrão do sistema
- Tema claro
+ Padrão do sistema
+ Tema claro
Encerrar serviço
- Todas transferências concluídas
%1$.1f%%, %2$d transferências, %3$s/s
- Transferência(s) recebida(s) de %s
- %d ficheiro(s)
+ Transferência(s) recebida(s) de %s
Serviço funcionando
Notificação continua sobre serviço
Transferência recebida
Progresso da transferência
- Reconectar
- Status da conexão
- Foto do perfil
- Ícone do app
- Ícone do rosto desanimado
- Foto selecionada
- Recusar a transferência
- Retomar a transferência
- Deve ser a primeira vez que usa esta app. Por favor selecione uma pasta onde quer que o Warpinator salve os ficheiros dele.
- Tem que selecionar uma pasta para descargas nas configurações
- Rede indisponível - configure próprio hotspot\?
- Serviço não está em execução
- Falha ao receber certificado de %1$s. Assegure-se de que o tráfego no firewall remoto para UDP (e TCP) na Porta %2$d está permitido.
- Iniciar conexão
+ Reconectar
Digitalize o código QR acima com um aplicativo de câmara e abra a ligação ou use o seguinte endereço para iniciar a conexão do outro dispositivo:
- Conexão manual
- Gravar regito
- Tentar conexão manual no menu
- Insira endereço e porta
- Conectar a \'%s\'?
- %d conectado
+ Conexão manual
+ Gravar regito
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index b93cf356..bedc6de2 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -1,136 +1,260 @@
- Transferuri
- Trimite fișiere
- Închide
- Despre
- Reglări
- Trimite către
- Aparate disponibile
- Nu s-a găsit alt aparat
- Se așteaptă permisiunea…
- (Fișierele ar putea fi înlocuite!)
- Versiune: %1$s
- O versiune neoficială a Warpinator.
-\nTrimite și primește fișiere prin rețeaua locală.
- Acest program are codul sursă public cu licența <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Codul sursă se află aici <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Acest program nu oferă nicio garanție.<br>Citește licența pentru mai multe detalii.
- Indentitate
- Reglări de transfer
- Rețea
- Aspect
- Numele afișat
- Poza de profil
- Dosarul pentru descărcări
- Arată notificări la primirea fișierelor
- Menține activ
+ Înapoi
+ Deschide meniul
+ Copiază
+ Partajează
+ Șterge
+ Confirmă editarea
+ Închide
+ Salvează
+ Trimite
+ Gata
+ Extins
+ Restrâns
+ restrânge
+ extinde detaliile
+ IP: %1$s
+ Comută favorit
+ selectează dispozitivul
+ Șterge transferul
+ Acceptă transferul
+ Refuză transferul
+ Oprește transferul
+ Anulează transferul
+ Reîncearcă transferul
+ Deschide conținutul transferat
+ Copiază adresa
+ Trimite
+ Dispozitivul nu acceptă mesaje text
+ Dispozitivul este deconectat
+ Mesajul este gol
+ Mesaj trimis: %1$s
+ Mesaj primit: %1$s
+ Trimis la: %1$s
+ Primit la: %1$s
+ Șterge mesajul
+ Copiază mesajul
+ Partajează mesajul
+ Favorit
+ Nu este favorit
+ Poză de profil %1$d
+ Ieși
+ Probleme de conexiune
+ Conexiune manuală
+ Salvează log-ul
+ Reanunță
+ Rescanează dispozitivele
+ Despre
+ Setări
+ Dispozitive disponibile
+ Niciun alt dispozitiv găsit
+ Eroare de conexiune
+ Fără conexiune la internet
+ Conectează-te prin WiFi, LAN sau Hotspot, apoi repornește aplicația.
+ Reconectare
+ Șterge istoricul transferurilor
+ Elimină din favorite
+ Adaugă la favorite
+ Istoricul transferurilor
+ Niciun transfer încă
+ Acceptă
+ Refuză
+ Oprește
+ Reîncearcă
+ Deschide
+ Anulează
+ Trimite fișier
+ Trimite dosar
+ Trimite mesaj
+
+ - %1$d transfer
+ - %1$d transferuri
+ - %1$d de transferuri
+
+
+ - %1$d mesaj
+ - %1$d mesaje
+ - %1$d de mesaje
+
+ Trimite un mesaj
+ Plasează aici pentru a trimite
+ Scanează codul QR de mai sus cu camera și deschide linkul sau folosește următoarea adresă pentru a iniția conexiunea de pe celălalt dispozitiv:
+ Dispozitiv conectat
+ Se conectează la
+ Dispozitivul este deja conectat
+ Conexiunea a eșuat
+ Dispozitivul nu este în aceeași subrețea
+ Dispozitivul nu acceptă conexiunea manuală
+ Adaugă conexiune
+ Cod QR al unui URL de conexiune
+ Adresă IP
+ Selectează sau introdu o adresă validă
+ Dispozitive recente
+ Niciun dispozitiv recent
+ Inserează adresa copiată
+ Adresă copiată
+ Mesaje
+ Mesaj…
+ Istoric mesaje cu %1$s
+ Mesaj trimis
+ Mesaj primit
+ Ascunde ora
+ Afișează ora
+ Deschide opțiunile mesajului
+ Versiunea: %1$s
+ Acest software este licențiat sub GNU General Public License v3.0 și este disponibil open source pe GitHub.\n\nAcest program vine ABSOLUT FĂRĂ NICIO GARANȚIE. Consultă termenii licenței pentru mai multe detalii.
+ Ajută la traducerea Warpinator
+ Tradu Warpinator în limba ta
+ Evaluează pe Google Play
+ Îți place aplicația? Evaluează-o pe Google Play!
+ Vezi codul sursă
+ Contribuțiile sunt binevenite
+ Probleme
+ Ai o problemă? Raportează-o aici!
+ Licență
+ GNU General Public License v3.0
+ Profil
+ Transferuri
+ Aplicație
+ Rețea
+ Aspect
+ Nume afișat
+ Poză de profil
+ Dosarul de descărcări
+ Afișează notificări pentru fișierele primite
+ Permite suprascrierea
+ Acceptă transferurile automat
+ Transferurile vor fi acceptate automat
+ Fiecare transfer trebuie acceptat de tine
+ Încearcă compresia
+ Pornire automată
+ Pornirea automată nu este disponibilă pe Android 15+
+ Oprește serviciul la părăsirea aplicației
+ Serviciul va fi oprit când aplicația este închisă din recente sau după o perioadă de inactivitate
+ Odată pornit, serviciul va continua să ruleze până când este oprit manual
+ Scrie log-ul de depanare într-un fișier
Cod de grup
- Port pentru transferuri
- Aspect
- Acest lucru poate fi afectat de notificările sistemului Android
- Portul trebuie să fie între 1024 și 65535
- Schimbarea acestei reglări necesită repornirea aplicației
- Aspectul sistemului
- Aspect luminos
- Aspect întunecos
- Serviciul Warpinator funcționează
- Oprește
- Toate transferurile sunt complete
+ Portul pentru transferuri
+ Portul pentru înregistrare
+ Interfața rețea preferată
+ %s (revine la automat dacă nu este disponibilă)
+ Temă
+ Aceasta poate fi afectată de setările de notificări ale Android
+ Numărul portului trebuie să fie între 1024 și 65535
+ Modificarea acestei setări necesită repornirea aplicației
+ Fișierele primite cu un nume care există deja le vor suprascrie pe cele existente
+ Fișierele primite cu un nume care există deja vor fi redenumite
+ Implicit sistem
+ Temă luminoasă
+ Temă întunecată
+ Adaugă poză personalizată
+ Folosește culori dinamice
+ Integrează mesajele cu transferurile
+ Mesajele și transferurile de fișiere sunt afișate într-o singură listă
+ Mesajele și transferurile sunt păstrate în secțiuni separate
+ Schimbă poza de profil
+ Salvarea pozei de profil a eșuat: %1$s
+ Plasează imaginea aici pentru a seta
+ Serviciul Warpinator rulează
+ Oprește serviciul
%1$.1f%%, %2$d transferuri, %3$s/s
- Probabil că este prima dată când folosești această aplicație. Alege un dosar în care Warpinator să salveze fișierele.
-
- - Conectat
- - Deconectat
- - Se conectează
- - Conectare eșuată
- - Se așteaptă duplex-ul
-
-
- - Inițializare
- - Se așteaptă permisiunea…
- - Refuzat
- - Se transferă
- - Întrerupt
- - Oprit
- - Eșuat (atinge pentru detalii)
- - Eșec irecuperabil
- - Fișierul nu a fost găsit
- - Terminat
- - Terminat cu erori (atinge pentru detalii)
-
- Permite înlocuirea
- Fișierele primite vor înlocui fișierele existente cu același nume
- Fișierele primite care au un nume care există deja vor fi redenumite
- Pornește automat
- Personalizate
- Conectare nereușită: Codul grupului este greșit
- Aparate cu care să împărtășești
- Nu s-au găsit aparate cu care să împărtășești
- rămase
- câteva secunde
- , serviciu indisponibil
- Erori în timpul transferului:
- Am primit o comandă neacceptată
- Nimic de împărtășit
- Ai ales conținut de la un furnizor neacceptat. Alege un dosar din spațiul de stocare intern.
- %s trimite fișiere
- %d fișiere
- Serviciul funcționează
- Notificare permanentă a serviciului
+ Transfer primit de la %s
+
+ - %1$d fișier
+ - %1$d fișiere
+ - %1$d de fișiere
+
+ Serviciul rulează
+ Notificare persistentă a serviciului
Transferuri primite
Progresul transferului
- Eroare de conectare
- Nu ești conectat la WiFi sau LAN. Folosește un hotspot dacă niciuna nu este disponibilă. Repornește aplicația când ești conectat.
- Trebuie să alegi un dosar pentru descărcări
- Acceptă automat transferurile
- Transferurile vor fi acceptate automat
- Transferurile trebuie acceptate manual
- Probleme de conexiune
- Închiderea acestei pagini ar putea cauza eșecul transferului
- Scrie jurnalul de depanare într-un fișier
- Reconectează
- Starea conexiunii
- Poza de profil
- Simbolul aplicației
- Emoji față dezamăgită
- Imaginea aleasă
- Refuză transferul
- Acceptă transferul
- Oprește transferul
- Reîncearcă transferul
- Rețea indisponibilă - încerci hotspot\?
- Serviciul nu funcționează
- Certificatul de la %1$s nu a fost primit. Asigură-te că permiți trafic UDP și TCP prin portul %2$d în firewall-ul aparatului.
- Expune din nou
- Caută aparate
- Rețea schimbată - se repornește serviciul…
- Nu s-a putut deschide fișierul primit
- Fișiere de trimis:
- Oprește serviciul la părăsirea aplicației
- Serviciul va fi oprit automat dacă este eliminat din recente sau după o perioadă de inactivitate
- Odată pornit, serviciul va continua să funcționeze până când va fi oprit manual
- Furnizorul telefonului nu a implementat un dialog necesar. Acest lucru va fi rezolvat într-o versiune viitoare.
- Pozițiile care nu sunt prestabilite nu acceptă păstrarea datei ultimei modificări. Se poate reveni la valoarea prestabilită cu ajutorul butonului de restabilire.
- Alege un dosar de trimis
- Restabilește valoarea inițială
- Șterge transferurile terminate
- Folosește comprimarea
- Port pentru înscriere
- Aparate din afara grupului tău: %1$d
- Conexiune nouă
- Scanează codul QR de mai sus cu fotocamera și deschide adresa sau folosește următoarea adresă pentru a stabili legătura din celălalt aparat:
- Conectare manuală
- Salvează jurnalul
- Introdu adresa și portul
- %d conectat
- Încearcă conectarea manuală
- Conectezi la „%s”?
- Adresa a fost copiată
- , nu în aceeași subrețea
- Pornirea automată nu este disponibilă cu Android 15+
- Interfața de rețea preferată
- Cod QR
- Aparatele trebuie să fie în aceeași rețea și să aibă același cod de grup
- Celălalt aparat nu s-a reconectat. Poate că nu ne vede?
- Atenție
- Acest aparat nu se află în aceeași subrețea. Conectarea la acesta este posibil să nu reușească. Trebuie ca Warpinator să folosească interfața de rețea corectă.
-
+ Atinge pentru a deschide
+ Necunoscut
+ Mesaj nou de la %1$s
+ Mesaje primite
+
+ - %1$d conectat
+ - %1$d conectate
+ - %1$d conectate
+
+ Partajează cu
+
+ - %1$d fișier selectat
+ - %1$d fișiere selectate
+ - %1$d de fișiere selectate
+
+ Atinge pentru a edita
+
+ - %1$s, și încă unul
+ - %1$s, și încă %2$d
+ - %1$s, și încă %2$d
+
+ Se repornește Warpinator…
+ Se pornește Warpinator…
+ Se oprește Warpinator…
+ Pornirea Warpinator a eșuat
+ Rețeaua s-a schimbat
+ Eroare necunoscută
+ Reanunțarea a eșuat: %1$s
+ Rescanarea a eșuat: %1$s
+ Pornirea serverului GRPC a eșuat. Repornește dispozitivul sau ajustează numerele porturilor.
+ Serviciul de descoperire a eșuat. Este posibil ca alte dispozitive să nu te vadă.
+ A apărut o eroare de securitate la pornire. Contactează dezvoltatorii.
+ Se rulează în modul de compatibilitate. Unele funcții pot fi limitate.
+ Deînregistrarea a eșuat: %1$s
+ Au fost găsite dispozitive care folosesc coduri de grup diferite. Asigură-te că codul tău curent este corect.
+ Log-ul nu a putut fi salvat în fișier: %1$s
+ Log-ul a fost salvat în destinația selectată
+ Se așteaptă conexiunea duplex
+ Se conectează
+ Conectat
+ Deconectat
+ Conectarea a eșuat\n%1$s
+
+ - %1$d fișier
+ - %1$d fișiere
+ - %1$d de fișiere
+
+ %1$s / %2$s
+ %1$s / %2$s (%3$s, %4$s)
+ %1$s/s
+ se calculează…
+ mai mult de o zi rămasă
+ câteva secunde rămase
+
+ - %1$ds rămasă
+ - %1$ds rămase
+ - %1$ds rămase
+
+ %1$s %2$s rămase
+
+ - %1$d h
+ - %1$d h
+ - %1$d h
+
+
+ - %1$d min
+ - %1$d min
+ - %1$d min
+
+
+ - %1$d s
+ - %1$d s
+ - %1$d s
+
+ Se primește
+ Se trimite
+ Transfer trimis
+ Transfer primit
+ Se inițializează
+ Se așteaptă permisiunea…
+ Se așteaptă permisiunea… (Fișierul va fi suprascris)
+ Refuzat
+ Întrerupt
+ Oprit
+ Eșuat
+ Finalizat
+ Finalizat cu erori
+ Permite aplicației Warpinator să descopere alte dispozitive, să trimită și să primească fișiere și să mențină transferurile active în fundal, asigurând funcționarea fiabilă chiar și atunci când aplicația nu este utilizată.
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 966f76ef..6bb46011 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1,142 +1,59 @@
-
-
- Передачи
- Отправить файлы
- Выход
- Проблемы с подключением
- Повторная регистрация устройства
- Повторный поиск устройств
- О приложении
- Настройки
- Поделиться с
- Доступные устройства
+ Выход
+ Проблемы с подключением
+ Повторная регистрация устройства
+ Повторный поиск устройств
+ О приложении
+ Настройки
+ Поделиться с
+ Доступные устройства
Устройства не обнаружены
- Ошибка подключения: неверный групповой код
Ошибка подключения
- Вы не подключены к Wi-Fi или локальной сети. Используйте свою точку доступа, если ни одна из них не доступна. После подключения перезапустите приложение.
- Сеть изменена - перезапуск службы…
-
- Ожидание подтверждения…
- (Файлы могут быть перезаписаны!)
- осталось
- Не удалось открыть полученный файл
- ещё несколько секунд
- , служба недоступна
- Ошибки при передаче:
- Закрытие этой операции при совместном использовании может привести к сбою передачи
- Список передаваемых файлов:
-
+ Ожидание подтверждения…
Версия: %1$s
- Это неофициальный порт Warpinator. \nОтправляйте и получайте файлы по локальной сети.
- Это приложение представляет собой программное обеспечение с открытым исходным кодом, лицензированное под <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">Основной Общественной Лицензией GNU</a>.<br> Вы можете получить исходный код на <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Приложение поставляется без каких-либо гарантий.<br>Подробнее см. условия лицензии.
-
- Устройства, с которыми можно поделиться
- Нет доступных устройств, с которыми можно поделиться
- Получено неподдерживаемое действие
- Нечем поделиться
-
Идентичность
- Настройки передачи
- Сеть
- Внешний вид
+ Настройки передачи
+ Сеть
+ Внешний вид
Отображаемое имя
Изображение профиля
Папка для загрузок
Показывать уведомления
Разрешить перезапись
Автоподтверждение
- Передачи будут приняты автоматически
- Каждая передача должна быть подтверждена вами
- Работа в фоновом режиме
+ Передачи будут приняты автоматически
+ Каждая передача должна быть подтверждена вами
Запускать автоматически
- Автозавершение службы
- Служба будет автоматически остановлена при очистке приложения из недавних, а также после определённого промежутка бездействия
- После запуска служба будет продолжать работать до момента остановки вручную
- Записывать лог в файл
+ Автозавершение службы
+ Служба будет автоматически остановлена при очистке приложения из недавних, а также после определённого промежутка бездействия
+ После запуска служба будет продолжать работать до момента остановки вручную
+ Записывать лог в файл
Групповой код
Порт для переводов
Тема
Может зависеть от настроек уведомлений Android
Номер порта должен быть от 1024 до 65535
Для изменения этой настройки требуется перезапуск приложения
- Производитель вашего телефона не осуществил требуемый диалог. Это будет исправлено в следующем выпуске.
Входящие файлы с уже имеющимся именем перезапишут существующие
Входящие файлы с уже имеющимся именем будут переименованы
- Другое
- Вы выбрали неподдерживаемого поставщика контента. Пожалуйста, выберите каталог во внутренней памяти.
-
- Системная по умолчанию
- Светлая внешность
- Тёмная внешность
-
+ Системная по умолчанию
+ Светлая внешность
+ Тёмная внешность
Служба Warpinator запущена
Остановить
- Все передачи завершены
%1$.1f%%, кол-во передач: %2$d, %3$s/сек
- Входящая передача от %s
- Кол-во файлов: %d
+ Входящая передача от %s
Служба запущена
Постоянное уведомление о службе
Входящая передача
Ход передачи
-
- Переподключение
- Состояние подключения
- Изображение профиля
- Значок приложения
- Значок разочарованного лица
- Выбранное изображение
- Отклонить передачу
- Подтвердить передачу
- Остановить передачу
- Повторить передачу
-
- Скорее всего, вы впервые запустили это приложение. Пожалуйста, выберите папку, в который вы бы хотели, чтобы Warpinator сохранял файлы.
- Вы должны выбрать папку для загрузок в настройках
- Сеть недоступна - попробовать точку доступа?
- Служба не работает
- Не удалось получить сертификат от %1$s. Обязательно разрешите трафик UDP (а также TCP) через порт %2$d в удаленном брандмауэре.
-
- - Подключено
- - Отключено
- - Подключение
- - Ошибка подключения
- - Ожидание двусторонней связи
-
-
- - Инициализация
- - Ожидание подтверждения…
- - Отклонено
- - Передаётся
- - Приостановлено
- - Остановлено
- - Не выполнено (нажмите, чтобы посмотреть ошибки)
- - Неизвестная ошибка
- - Файл не найден
- - Завершено
- - Завершено с ошибками (нажмите, чтобы посмотреть)
-
- Расположение не по умолчанию не поддерживает сохранение временных меток последних изменений. Вы можете вернуться к настройкам по умолчанию с помощью кнопки сброса.
- Выберите папку для отправки
- Сбросить к значению по умолчанию
- Очистить завершенные передачи
+ Переподключение
Порт для регистрации
- Устройства за пределами вашей группы: %1$d
Попробуйте использовать сжатие
- Сохранить лог
+ Сохранить лог
Отсканируйте QR-код на другом устройстве и перейдите по ссылке или введите следующий адрес:
- Новое соединение
- Подключиться вручную
- Введите адрес и порт
- Адрес скопирован в буфер обмена
- QR-код
- Устройства должны находиться в одной сети
- Попробуйте ручное подключение в меню
- Подключиться к \'%s\'?
- %d подключен
+ Подключиться вручную
Предпочтительный сетевой интерфейс
%s (переходит на автоматический режим, если недоступно)
- Другое устройство не подключилось. Может быть, оно нас не видит?
diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml
index 55f257be..0789d32c 100644
--- a/app/src/main/res/values-sl/strings.xml
+++ b/app/src/main/res/values-sl/strings.xml
@@ -1,99 +1,54 @@
- Pošlji datoteke
- Po meri
- Počisti končane prenose
- Prenosi
- Težave s povezavo
- Odpiranje prejete datoteke ni uspelo
- Ponovno iskanje naprav
- Ponovna prijava
- Izhod
- Niste povezani v omrežje WiFi ali LAN. Če nobena od teh možnosti ni na voljo, uporabite dostopno točko. Ko boste vzpostavili povezavo, znova zaženite aplikacijo.
- Omrežje spremenjeno – ponovni zagon storitve…
- preostalo
- Razpoložljive naprave
- (Datoteke se lahko prepišejo!)
- O Warpinatorju
+ Težave s povezavo
+ Ponovno iskanje naprav
+ Ponovna prijava
+ Izhod
+ Razpoložljive naprave
+ O Warpinatorju
Druge naprave niso bile najdene
- Povezava ni uspela: Napačna koda skupine
Napaka povezave
- Čakanje na dovoljenje…
- Nastavitve
- Napake med prenosom:
- , storitev ni na voljo
- Pošiljanje datotek:
+ Čakanje na dovoljenje…
+ Nastavitve
Različica: %1$s
- nekaj sekund
- Zapiranje te dejavnosti med deljenjem lahko povzroči, da prenos ne bo uspešen
- Izberite mapo za pošiljanje
- Warpinator za Android je neuradna različica istoimenskega orodja za skupno rabo datotek Linux Mint.
-\nPošiljajte in prejemajte datoteke preko lokalnega omrežja.
- Ta program je odprtokodna programska oprema, licencirana pod <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Izvorno kodo si lahko ogledate na <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a>.<br> <br> Uporaba programa je brez kakršnega koli jamstva .<br>Za več podrobnosti si oglejte licenčne pogoje.
- Ni razpoložljivih naprav za uporabo medsebojnega deljenja
- Prejeli smo nepodprto namero dejanja
- Naprave z možnostjo medsebojnega deljenja
- Nastavitve prenosa
- Ničesar ni za skupno deljenje
+ Nastavitve prenosa
Identiteta
- Omrežje
- Tema
+ Omrežje
+ Tema
Prikazno ime
Slika profila
- Ustavi storitev po izhodu iz aplikacije
+ Ustavi storitev po izhodu iz aplikacije
Imenik prenosov
- Storitev se bo samodejno ustavila, ko je naprava izbrisana iz seznama nedavnih ali po nekem obdobju neaktivnosti
- Prenosi bodo sprejeti samodejno
+ Storitev se bo samodejno ustavila, ko je naprava izbrisana iz seznama nedavnih ali po nekem obdobju neaktivnosti
+ Prenosi bodo sprejeti samodejno
Dovoli prepisovanje
Pokaži obvestilo o dohodnih datotekah
- Vsak prenos je potrebno odobriti ročno
+ Vsak prenos je potrebno odobriti ročno
Samodejno sprejemanje prenosov
Samodejno zaženi aplikacijo
- Delovanje aplikacije v ozadju
- Ponovno poskusite s prenosom
- Ponastavi na privzeto vrednost
- To je verjetno prvi zagon aplikacije Warpinator. Prosim izberite imenik, v katerega želite, da Warpinator shranjuje datoteke.
- V nastavitvah morate izbrati imenik za prenos datotek
- Omrežje ni na voljo – poskusite dostopno točko\?
- Po zagonu storitve bo le ta delovala, dokler je ročno ne ustavimo
- V datoteko zapiši zaznamek odprave napak
+ Po zagonu storitve bo le ta delovala, dokler je ročno ne ustavimo
+ V datoteko zapiši zaznamek odprave napak
Koda skupine
Vrata za prenose
- Zaustavitev prenosa
Tema
- Izbrali ste nepodprtega ponudnika vsebine. Prosim izberite imenik v svojem notranjem pomnilniku.
- Ikona aplikacije
- Prodajalec vašega telefona ni implementiral zahtevanega pogovornega okna. Napako bomo poskušali rešiti v prihodnjih različicah.
Številka vrat mora biti nastavljena med 1024 in 65535
Na to lahko vplivajo nastavitve obvestil v sistemu Android
Spreminjanje te nastavitve zahteva ponovni zagon aplikacije
Dohodne datoteke z imenom, ki že obstaja v imeniku prenosov, bodo prepisale obstoječe
Dohodne datoteke z imenom, ki že obstaja v imeniku prenosov, bodo preimenovane
- Deli skupno rabo z
- Uporabi privzete sistemske nastavitve
- Svetla tema
- Neprivzete lokacije ne podpirajo ohranjanja časovnih žigov zadnjih sprememb. Na privzete nastavitve se lahko vrnete z pritiskom na gumb \"Ponastavi\".
+ Deli skupno rabo z
+ Uporabi privzete sistemske nastavitve
+ Svetla tema
%1$.1f%%, %2$d prenosi, %3$s/s
Storitev Warpinator deluje v ozadju
- Vsi prenosi so končani
- Temna tema
+ Temna tema
Ustavi storitev v ozadju
- Dohodni prenos iz naprave %s
- %d datoteke
+ Dohodni prenos iz naprave %s
Storitev se izvaja
- Status povezave
- Slika profila
Trajno obvestilo o storitvi
Dohodni prenos
- Ponovno vzpostavi povezavo
+ Ponovno vzpostavi povezavo
Napredek prenosa
- Ikona razočaranega obraza
- Izbrana slika
- Zavrni prenos
- Sprejmi zahtevo prenosa
- Storitev v ozadju se ne izvaja
- Prejem potrdila od %1$s ni uspela. Prepričajte se, da je v oddaljenem požarnem zidu promet UDP (kot tudi TCP) na varatih %2$d dovoljen.
Poskusite uporabiti stiskanje
Vrata za registracijo
- Naprave izven vaše skupine: %1$d
\ No newline at end of file
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 9524c697..831188dd 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -1,110 +1,57 @@
- Överföringar
- Mörkt Tema
- Mysslyckades att öppna mottagen fil
- Tillgängliga enheter
- Profilbild
- Välj mapp att sända
- Filer som sänds:
- Inget att dela
+ Mörkt Tema
+ Tillgängliga enheter
Inkommande överföring
- Överföringsinställningar
+ Överföringsinställningar
Visningsnamn
Tema
- Nätverk
- Anslutningsproblem
- Avsluta
- Återanslut
+ Nätverk
+ Anslutningsproblem
+ Avsluta
+ Återanslut
Inga andra enheter funna
- Väntar på behörighet…
+ Väntar på behörighet…
Version: %1$s
Identitet
- Om
- Skicka filer
- Ljust Tema
- Kör i bakgrunden
- Anslutningsstatus
- Appikon
+ Om
+ Ljust Tema
Starta automatiskt
- några sekunder
- Inställningar
+ Inställningar
Gruppkod
- återstående
Anslutningsfel
- Dela med
- (Filer kan skrivas över!)
- Anslutning misslyckades: Fel gruppkod
+ Dela med
Tjänst körs
- Använd systemstandard
- Acceptera överföring
- Detta är en inofficiell port av Warpinator.
-\nSkicka och ta emot filer över det lokala nätverket.
+ Använd systemstandard
Warpinator-tjänst är igång
- Du är inte ansluten till WiFi eller LAN. Använd din hotspot om ingendera är tillgänglig. Starta om applikationen när du är ansluten.
- Tjänsten stoppas automatiskt när den rensas från de senaste eller efter en period av inaktivitet
- Besviken ansikte ikon
- , tjänsten är inte tillgänglig
- Inga tillgängliga enheter att dela med
+ Tjänsten stoppas automatiskt när den rensas från de senaste eller efter en period av inaktivitet
Stoppa tjänst
- Inkommande överföring från %s
- Platser som inte är standard har inte stöd för att bevara senast ändrade tidsstämplar. Du kan återgå till standard med återställningsknappen.
- Skriv felsökningslogg till fil
- Det gick inte att ta emot certifikat från %1$s. Se till att tillåta UDP-trafik (liksom TCP) på port %2$d i fjärrens brandvägg.
+ Inkommande överföring från %s
+ Skriv felsökningslogg till fil
Tillåt överskrivning
- Stoppa överföring
- Sök efter enheter igen
+ Sök efter enheter igen
Visa aviseringar om inkommande filer
- Nätverket är inte tillgängligt – testa hotspot?
Profilbild
Detta kan påverkas av Androids aviseringsinställningar
%1$.1f%%, %2$d överföring, %3$s/s
- Du har valt en innehållsleverantör som inte stöds. Välj en katalog på ditt interna minne.
- %d filer
- Rensa färdiga överföringar
Port för överföringar
- Vald bild
- Denna applikationen är programvara med öppen källkod licensierad under <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a>.<br> Du kan erhålla källkoden från < a href=https://github.com/slowscript/warpinator-android/>GitHub.<br> <br> Det här programmet levereras med absolut ingen garanti.<br>Se villkoren för licensen för mer information .
- Tillkännage igen
- Fel under överföring:
+ Tillkännage igen
Inkommande filer med ett namn som redan finns kommer att döpas om
- Din telefons leverantör implementerade inte en obligatorisk dialogruta. Detta kommer att lösas i en framtida version.
- Överföringar kommer att accepteras automatiskt
- Aspekt
+ Överföringar kommer att accepteras automatiskt
+ Aspekt
Acceptera överföringar automatiskt
Port för registrering
- När den har startat fortsätter tjänsten att köras tills den stoppas manuellt
+ När den har startat fortsätter tjänsten att köras tills den stoppas manuellt
Överföringsförlopp
- Enheter utanför din grupp: %1$d
Portnumret måste vara mellan 1024 och 65535
Inkommande filer med ett namn som redan finns kommer att skriva över de befintliga
- Enheter att dela med
- Försök överföringen igen
- Det här är förmodligen första gången du startar den här applikationen. Välj en katalog där du vill att Warpinator ska spara filer.
Om du ändrar den här inställningen måste applikationen startas om
- Nätverket har ändrats - startar om tjänsten…
- Alla överföringar är klara
- Avvisa överföring
- Återställ till standardvärde
- Du måste välja en nedladdningskatalog i inställningarna
Beständig serviceavisering
Nedladdningskatalog
- Om du stänger den här aktiviteten medan du delar kan överföringen misslyckas
Prova att använda komprimering
- Tjänsten körs inte
- Vi fick en avsiktsåtgärd som inte stöds
- Varje överföring måste accepteras av dig
- Stoppa tjänsten efter att ha lämnat appen
- Anpassad
- Ny anslutning
+ Varje överföring måste accepteras av dig
+ Stoppa tjänsten efter att ha lämnat appen
Skanna QR-koden ovan med en kameraapp och öppna länken eller använd följande adress för att initiera anslutningen från den andra enheten:
- Manuell anslutning
- Spara logg
- Ange adress och port
- Prova Manuell anslutning i menyn
- Anslut till \'%s\'?
- %d ansluten
- Adress kopierad till urklipp
- Enheterna måste vara på samma nätverk
- QR-kod
+ Manuell anslutning
+ Spara logg
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index e6a6a343..f2393370 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -1,61 +1,37 @@
- தனிப்பயன்
- \'%s\' உடன் இணைக்கவா?
- இடமாற்றங்கள்
- கோப்புகளை அனுப்பவும்
- வெளியேறு
- இணைப்பு சிக்கல்கள்
- கையேடு இணைப்பு
- பதிவைச் சேமிக்கவும்
- மறுபயன்பாடு
- சாதனங்களுக்கு மீட்டமைக்கவும்
- பற்றி
- அமைப்புகள்
- பகிர்ந்து கொள்ளுங்கள்
- கிடைக்கும் சாதனங்கள்
+ வெளியேறு
+ இணைப்பு சிக்கல்கள்
+ கையேடு இணைப்பு
+ பதிவைச் சேமிக்கவும்
+ மறுபயன்பாடு
+ சாதனங்களுக்கு மீட்டமைக்கவும்
+ பற்றி
+ அமைப்புகள்
+ பகிர்ந்து கொள்ளுங்கள்
+ கிடைக்கும் சாதனங்கள்
வேறு சாதனங்கள் எதுவும் கிடைக்கவில்லை
- இணைப்பு தோல்வியுற்றது: தவறான குழு குறியீடு
இணைப்பு பிழை
- நீங்கள் வைஃபை அல்லது லானுடன் இணைக்கப்படவில்லை. எதுவும் கிடைக்கவில்லை என்றால் உங்கள் ஆட்ச்பாட்டைப் பயன்படுத்தவும். நீங்கள் இணைக்கப்படும்போது பயன்பாட்டை மறுதொடக்கம் செய்யுங்கள்.
- பிணையம் மாற்றப்பட்டது - சேவையை மறுதொடக்கம் செய்தல்…
- உங்கள் குழுவிற்கு வெளியே உள்ள சாதனங்கள்: %1$d
- இசைவு காத்திருக்கிறது…
- (கோப்புகள் மேலெழுதப்படலாம்!)
- மீதமுள்ள
- பெறப்பட்ட கோப்பைத் திறக்கத் தவறிவிட்டது
- சில வினாடிகள்
- , பணி கிடைக்கவில்லை
- பரிமாற்றத்தின் போது பிழைகள்:
- பகிர்வு போது இந்த செயல்பாட்டை மூடுவது பரிமாற்றம் தோல்வியடையும்
- கோப்புகள் அனுப்பப்படுகின்றன:
- அனுப்ப ஒரு கோப்புறையைத் தேர்ந்தெடுக்கவும்
+ இசைவு காத்திருக்கிறது…
பதிப்பு: %1$s
- இது அதிகாரப்பூர்வமற்ற போர்க்களம்.\n உள்ளக பிணையம் முழுவதும் கோப்புகளை அனுப்பவும் பெறவும்.
- இந்த நிரல் <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU பொது பொதுமக்கள் உரிமம்</a> இன் கீழ் உரிமம் பெற்ற திறந்த மூல மென்பொருளாகும்.<br> நீங்கள் <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a> இலிருந்து மூலக் குறியீட்டைப் பெறலாம்.<br><br> இந்த நிரல் எந்த உத்தரவாதமும் இல்லாமல் வருகிறது.<br>மேலும் விவரங்களுக்கு உரிமத்தின் விதிமுறைகளைப் பார்க்கவும்.
- பகிர்ந்து கொள்ள வேண்டிய சாதனங்கள்
- பகிர்ந்து கொள்ள கிடைக்கக்கூடிய சாதனங்கள் எதுவும் இல்லை
- நாங்கள் ஆதரிக்கப்படாத நோக்கம் நடவடிக்கை பெற்றோம்
- பகிர எதுவும் இல்லை
முற்றொருமை
- மாற்ற அமைப்புகள்
- பிணையம்
- நற்பொருத்தம்
+ மாற்ற அமைப்புகள்
+ பிணையம்
+ நற்பொருத்தம்
காட்சி பெயர்
சுயவிவர படம்
கோப்பகத்தை பதிவிறக்குகிறது
உள்வரும் கோப்புகள் பற்றிய அறிவிப்பைக் காட்டு
மேலெழுதலை அனுமதிக்கவும்
இடமாற்றங்களை தானாக ஏற்றுக்கொள்ளுங்கள்
- இடமாற்றங்கள் தானாக ஏற்றுக்கொள்ளப்படும்
- ஒவ்வொரு பரிமாற்றமும் நீங்கள் ஏற்றுக்கொள்ள வேண்டும்
+ இடமாற்றங்கள் தானாக ஏற்றுக்கொள்ளப்படும்
+ ஒவ்வொரு பரிமாற்றமும் நீங்கள் ஏற்றுக்கொள்ள வேண்டும்
சுருக்கத்தைப் பயன்படுத்த முயற்சிக்கவும்
- பின்னணியில் இயக்கவும்
தானாகத் தொடங்குங்கள்
- பயன்பாட்டை விட்டு வெளியேறிய பிறகு சேவையை நிறுத்துங்கள்
- ரெசென்ட்களிலிருந்து அழிக்கப்படும் போது அல்லது செயலற்ற காலத்திற்குப் பிறகு பணி தானாக நிறுத்தப்படும்
- தொடங்கியதும், கைமுறையாக நிறுத்தப்படும் வரை பணி தொடர்ந்து இயங்கும்
- கோப்புக்கு பிழைத்திருத்த பதிவை எழுதுங்கள்
+ பயன்பாட்டை விட்டு வெளியேறிய பிறகு சேவையை நிறுத்துங்கள்
+ ரெசென்ட்களிலிருந்து அழிக்கப்படும் போது அல்லது செயலற்ற காலத்திற்குப் பிறகு பணி தானாக நிறுத்தப்படும்
+ தொடங்கியதும், கைமுறையாக நிறுத்தப்படும் வரை பணி தொடர்ந்து இயங்கும்
+ கோப்புக்கு பிழைத்திருத்த பதிவை எழுதுங்கள்
குழு குறியீடு
இடமாற்றங்களுக்கான துறைமுகம்
பதிவு செய்வதற்கான துறைமுகம்
@@ -63,54 +39,22 @@
இது ஆண்ட்ராய்டு இன் அறிவிப்பு அமைப்புகளால் பாதிக்கப்படலாம்
துறைமுகம் எண் 1024 முதல் 65535 வரை இருக்க வேண்டும்
இந்த அமைப்பை மாற்றுவதற்கு பயன்பாட்டை மறுதொடக்கம் செய்ய வேண்டும்
- உங்கள் தொலைபேசியின் விற்பனையாளர் தேவையான உரையாடலை செயல்படுத்தவில்லை. எதிர்கால வெளியீட்டில் இது வேலை செய்யப்படும்.
ஏற்கனவே இருக்கும் பெயருடன் உள்வரும் கோப்புகள் ஏற்கனவே உள்ளவற்றை மேலெழுதும்
ஏற்கனவே இருக்கும் பெயருடன் உள்வரும் கோப்புகள் மறுபெயரிடப்படும்
- ஆதரிக்கப்படாத உள்ளடக்க வழங்குநரை நீங்கள் தேர்ந்தெடுத்துள்ளீர்கள். உங்கள் உள் சேமிப்பகத்தில் ஒரு கோப்பகத்தைத் தேர்ந்தெடுக்கவும்.
- தாக்குதல் அல்லாத இடங்கள் கடைசியாக மாற்றியமைக்கப்பட்ட நேர முத்திரைகளைப் பாதுகாப்பதை ஆதரிக்கவில்லை. மீட்டமை பொத்தானைக் கொண்டு நீங்கள் இயல்புநிலைக்கு திரும்பலாம்.
- கணினி இயல்புநிலையைப் பயன்படுத்தவும்
- ஒளி கருப்பொருள்
- இருண்ட கருப்பொருள்
+ கணினி இயல்புநிலையைப் பயன்படுத்தவும்
+ ஒளி கருப்பொருள்
+ இருண்ட கருப்பொருள்
வார்பினேட்டர் பணி இயங்குகிறது
சேவையை நிறுத்துங்கள்
- அனைத்து இடமாற்றங்களும் முழுமையானவை
%1$.1f %%, %2$d இடமாற்றங்கள், %3$s/s
- %s இலிருந்து உள்வரும் பரிமாற்றம்
- %d கோப்புகள்
+ %s இலிருந்து உள்வரும் பரிமாற்றம்
பணி இயங்கும்
தொடர்ச்சியான பணி அறிவிப்பு
உள்வரும் பரிமாற்றம்
இடமாற்ற முன்னேற்றம்
- மீண்டும் இணைக்கவும்
- இணைப்பு நிலை
- சுயவிவர படம்
- பயன்பாட்டு படவுரு
- ஏமாற்றமடைந்த முகம் படவுரு
- தேர்ந்தெடுக்கப்பட்ட படம்
- பரிமாற்றத்தை நிராகரிக்கவும்
- பரிமாற்றத்தை ஏற்றுக்கொள்ளுங்கள்
- பரிமாற்றத்தை நிறுத்துங்கள்
- பரிமாற்றத்தை மீண்டும் முயற்சிக்கவும்
- இயல்புநிலை மதிப்புக்கு மீட்டமைக்கவும்
- இந்த விண்ணப்பத்தை நீங்கள் தொடங்குவது இதுவே முதல் முறை. கோப்புகளைச் சேமிக்க வார்பினேட்டர் விரும்பும் ஒரு கோப்பகத்தைத் தேர்ந்தெடுக்கவும்.
- அமைப்புகளில் பதிவிறக்க கோப்பகத்தை நீங்கள் தேர்ந்தெடுக்க வேண்டும்
- பிணையம் கிடைக்கவில்லை - ஆட்ச்பாட்டை முயற்சிக்கிறதா?
- பணி இயங்கவில்லை
- %1$s இலிருந்து சான்றிதழைப் பெறுவதில் தோல்வி. ரிமோட்டின் ஃபயர்வாலில் துறைமுகம் %2$d இல் யுடிபி (அத்துடன் டி.சி.பி) போக்குவரத்தை அனுமதிப்பதை உறுதிசெய்க.
- முடிக்கப்பட்ட இடமாற்றங்கள்
+ மீண்டும் இணைக்கவும்
மேலே உள்ள QR குறியீட்டை கேமரா பயன்பாட்டுடன் ச்கேன் செய்து இணைப்பைத் திறக்கவும் அல்லது பிற சாதனத்திலிருந்து இணைப்பைத் தொடங்க பின்வரும் முகவரியைப் பயன்படுத்தவும்:
- புதிய இணைப்பு
- முகவரி மற்றும் துறைமுகத்தை உள்ளிடவும்
- பட்டியலில் கையேடு இணைப்பை முயற்சிக்கவும்
- %d இணைக்கப்பட்டுள்ளது
- முகவரி இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது
- சாதனங்கள் ஒரே பிணையத்தில் இருக்க வேண்டும் மற்றும் அதே குழு குறியீட்டைப் பயன்படுத்தவும்
- விப குறியீடு
- , ஒரே சப்நெட்டில் இல்லை
ஆண்ட்ராய்ட் 15+ இல் தானியங்கு தொடக்கம் கிடைக்கவில்லை
விருப்பமான நெட்வொர்க் இடைமுகம்
%s (கிடைக்கவில்லை என்றால் தானியங்கு நிலைக்குத் திரும்புகிறது)
- மற்ற சாதனம் மீண்டும் இணைக்கப்படவில்லை. ஒருவேளை அது நம்மைப் பார்க்காமல் இருக்கலாம்?
- முன்னறிவிப்பு
- இந்த சாதனம் ஒரே துணை வலையில் இல்லை. அதனுடன் இணைப்பது தோல்வியடைய வாய்ப்புள்ளது. Warpinator சரியான நெட்வொர்க் இடைமுகத்தைப் பயன்படுத்துகிறதா என்பதை இரண்டு சாதனங்களின் அமைப்புகளையும் சரிபார்க்கவும்.
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 84384108..928cfa8c 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1,148 +1,60 @@
-
- Warpinator
-
- Aktarımlar
- Dosyaları gönder
- Çık
- Bağlantı sorunları
- Yeniden duyurun
- Aygıtlar için yeniden tara
- Hakkında
- Ayarlar
- İle paylaş
- Kullanılabilir aygıtlar
+ Çık
+ Bağlantı sorunları
+ Yeniden duyurun
+ Aygıtlar için yeniden tara
+ Hakkında
+ Ayarlar
+ İle paylaş
+ Kullanılabilir aygıtlar
Diğer aygıt bulunamadı
- Bağlantı başarısız oldu: Yanlış grup kodu
Bağlantı hatası
- WiFi veya LAN\'a bağlı değilsiniz. Hiçbiri kullanılabilir değilse, etkin noktanızı kullanın. Bağlandığınızda uygulamayı yeniden başlatın.
- Ağ değişti - hizmet yeniden başlatılıyor…
-
- İzin bekleniyor…
- (Dosyaların üzerine yazılabilir!)
- geriye kalan
- Alınan dosya açılamadı
- birkaç saniye
- , hizmet kullanılamıyor
- Aktarım sırasındaki hatalar:
- Paylaşım sırasında bu etkinliği kapatmak, aktarımın başarısız olmasına neden olabilir
- Gönderilen dosyalar:
-
+ İzin bekleniyor…
Sürüm: %1$s
- Bu, Warpinator\'ün resmi olmayan bir uygulamasıdır. \nYerel ağ üzerinden dosya gönderin ve alın.
- Bu program, <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU Genel Kamu Lisansı</a> altında lisanslanan açık kaynaklı bir yazılımdır.<br> Kaynak kodunu <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a> sayfasından edinebilirsiniz.<br> <br> Bu program kesinlikle garanti içermez.<br>Daha fazla ayrıntı için lisans koşullarına bakın.
-
- Paylaşılacak aygıtlar
- Paylaşılacak uygun aygıt yok
- Desteklenmeyen bir niyet eylemi aldık
- Paylaşacak bir şey yok
-
Kimlik
- Aktarım ayarları
- Ağ
- Görünüm
+ Aktarım ayarları
+ Ağ
+ Görünüm
Ekran adı
Profil resmi
İndirilenler dizini
Gelen dosyalar hakkında bildirim göster
Üzerine yazmaya izin ver
Aktarımları otomatik olarak kabul et
- Aktarımlar otomatik olarak kabul edilecektir
- Her aktarımın sizin tarafınızdan kabul edilmesi gerekiyor
- Arka planda çalıştır
+ Aktarımlar otomatik olarak kabul edilecektir
+ Her aktarımın sizin tarafınızdan kabul edilmesi gerekiyor
Otomatik başlat
- Dosyaya hata ayıklama günlüğü yaz
+ Dosyaya hata ayıklama günlüğü yaz
Grup kodu
Aktarımlar için bağlantı noktası
Tema
Bu, Android\'in bildirim ayarlarından etkilenebilir
Bağlantı noktası numarası 1024 ile 65535 arasında olmalıdır
Bu ayarın değiştirilmesi uygulamanın yeniden başlatılmasını gerektirir
- Telefonunuzun satıcısı gerekli bir iletişim kutusunu uygulamadı. Bu, gelecekteki bir sürümde çözülecektir.
Zaten var olan bir ada sahip gelen dosyalar, mevcut olanların üzerine yazacak
Zaten var olan bir ada sahip gelen dosyalar yeniden adlandırılacak
- Özel
- Desteklenmeyen bir içerik sağlayıcı seçtiniz. Lütfen dahili depolama alanınızda bir dizin seçin.
-
- Sistem varsayılanını kullan
- Açık Tema
- Koyu Tema
-
+ Sistem varsayılanını kullan
+ Açık Tema
+ Koyu Tema
Warpinator hizmeti çalışıyor
Hizmeti durdur
- Tüm aktarımlar tamamlandı
%1$.1f%%, %2$d aktarımlar, %3$s/s
- %s\'den gelen aktarım
- %d dosyalar
+ %s\'den gelen aktarım
Hizmet çalışıyor
Kalıcı hizmet bildirimi
Gelen aktarım
Aktarım ilerlemesi
-
- Yeniden Bağlan
- Bağlantı durumu
- Profil resmi
- Uygulama simgesi
- Hayal kırıklığına uğramış yüz simgesi
- Seçilen resim
- Aktarımı reddet
- Aktarımı kabul et
- Aktarımı durdur
- Aktarımı yeniden dene
-
- Muhtemelen bu uygulamayı ilk kez başlatıyorsunuz.
- Lütfen Warpinator\'ün dosyaları kaydetmesini istediğiniz bir dizini seçin.
- Ayarlarda bir indirme dizini seçmelisiniz
- Ağ kullanılamıyor - etkin noktayı denemek ister misiniz?
- Hizmet çalışmıyor
- %1$s\'den sertifika alınamadı. Uzak\'ın güvenlik duvarında %2$d bağlantı noktasında UDP (ve ayrıca TCP) trafiğine izin verdiğinizden emin olun.
-
- - Bağlandı
- - Bağlantı kesildi
- - Bağlanıyor
- - Bağlantı başarısız
- - Dubleks bekleniyor
-
-
- - Başlatılıyor
- - İzin bekleniyor…
- - Reddedildi
- - Aktarılıyor
- - Duraklatıldı
- - Durduruldu
- - Başarısız (hataları görüntülemek için dokunun)
- - Kurtarılamaz hata
- - Dosya bulunamadı
- - Tamamlandı
- - Hatalarla tamamlandı (görüntülemek için dokunun)
-
- Uygulamadan çıktıktan sonra hizmeti durdur
- Son kullanılanlardan silindiğinde veya bir süre kullanılmadığında hizmet otomatik olarak durdurulacaktır
- Hizmet başlatıldıktan sonra elle durdurulana kadar çalışmaya devam edecektir
- Göndermek için bir klasör seçin
- Öntanımlı değere sıfırla
- Biten aktarımları temizle
- Öntanımlı olmayan konumlar, son değiştirilen zaman damgalarının korunmasını desteklemez. Sıfırla düğmesi ile öntanımlıya geri dönebilirsiniz.
+ Yeniden Bağlan
+ Uygulamadan çıktıktan sonra hizmeti durdur
+ Son kullanılanlardan silindiğinde veya bir süre kullanılmadığında hizmet otomatik olarak durdurulacaktır
+ Hizmet başlatıldıktan sonra elle durdurulana kadar çalışmaya devam edecektir
Kayıt için bağlantı noktası
Sıkıştırma kullanmayı dene
- Grubunuzun dışındaki aygıtlar: %1$d
- Yeni bağlantı
Yukarıdaki QR kodunu bir kamera uygulaması ile tarayın ve bağlantıyı açın veya başka aygıttan bağlantıyı başlatmak için aşağıdaki adresi kullanın:
- Elle bağlantı
- Kütüğü kaydet
- Adres ve bağlantı noktasını girin
- Menüden elle bağlantıyı deneyin
- \'%s\' adresine bağlanılsın mı?
- %d bağlandı
- Adres panoya kopyalandı
- QR kod
- Aygıtlar aynı ağda olmalı ve aynı grup kodunu kullanmalıdır
- , aynı alt ağda değil
+ Elle bağlantı
+ Kütüğü kaydet
Android 15 ve üstünde otomatik başlatma kullanılamıyor
Tercih edilen ağ arayüzü
%s ( yoksa otomatik seçeneği kullanılır)
- Diğer aygıt geri bağlanmadı. Belki bizi görmüyordur?
- Uyarı
- Bu aygıt aynı alt ağda değil. Bağlantı kurulamayabilir. Lütfen her iki aygıtın ayarlarında Warpinator\'ın doğru ağ arayüzünü kullandığını doğrulayın.
diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 00000000..d1f93ac9
--- /dev/null
+++ b/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 84ee861d..0dcdbc0c 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1,148 +1,60 @@
-
-
- 传输
- 发送文件
- 退出
- 连接问题
- 重新宣告
- 重新扫描设备
- 关于
- 设置
- 分享给
- 可用设备
+ 退出
+ 连接问题
+ 重新宣告
+ 重新扫描设备
+ 关于
+ 设置
+ 分享给
+ 可用设备
没有发现其他设备
- 连接失败: 错误的分组代码
连接错误
- 您没有连接到 WiFi 或 LAN。如果两者都不可用,请使用您的热点。
- 连接后重新启动应用。
- 网络已更改 - 正在重新启动服务…
-
- 正在等待批准……
- (文件可能会被覆盖!)
- 剩余时间
- 无法打开收到的文件
- 马上就好
- ,服务不可用
- 传输过程中的错误:
- 分享时关闭此 Activity 可能会导致转移失败
- 发送的文件:
-
+ 正在等待批准……
版本: %1$s
- 这是 Warpinator 的非官方移植版本。\n通过本地网络发送和接收文件。
- 此程序是在 <a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU General Public License</a> 许可下的开源软件。<br> 您可以从 <a href=\"https://github.com/slowscript/warpinator-android/\">GitHub</a> 获取源代码。<br> <br> 此程序绝对不提供任何担保。<br>有关更多详细信息,请参阅许可条款。
-
- 与之分享的设备
- 没有可以与之分享的设备
- 我们收到了不受支持的 Intent 操作
- 没有什么可分享的
-
身份识别
- 传输设置
- 网络
- 外观
+ 传输设置
+ 网络
+ 外观
显示名称
设备头像
下载目录
有文件传入时显示通知
允许覆盖
自动接受传输
- 会自动接受传输
- 每次传输都需要您确认
- 后台运行
+ 会自动接受传输
+ 每次传输都需要您确认
自动启动
- 将调试日志写入文件
+ 将调试日志写入文件
分组代码
传输端口
主题
这可能会受到 Android 的通知设置的影响
端口号必须介于 1024 和 65535 之间
更改此设置需要重新启动应用
- 您手机的系统没有实现所需的对话框。这将在未来的版本中解决。
传入文件重名时,传入文件会覆盖现有文件
传入文件重名时,传入文件会被重命名
- 自定义
- 你选择了不受支持的内容提供商。 请在内部存储上选择一个目录。
-
- 跟随系统
- 浅色模式
- 深色模式
-
+ 跟随系统
+ 浅色模式
+ 深色模式
Warpinator 服务正在运行
停止服务
- 已完成所有的传输
%1$.1f%%, %2$d 传输, %3$s/s
- 来自 %s 的传输
- %d 文件
+ 来自 %s 的传输
服务正在运行
服务通知常驻
传入的传输
传输进度
-
- 重新连接
- 连接状态
- 设备头像
- 应用图标
- 失望样子的图标
- 选中的图片
- 拒绝传输
- 接受传输
- 停止传输
- 重试传输
-
- 这可能是您第一次启动此应用。
- 请选择您希望 Warpinator 保存文件的目录。
- 您必须在设置中选择下载目录
- 网络不可用 - 开热点试试?
- 服务未运行
- 未能从 %1$s 接收证书。请确保远程防火墙允许 %2$d 端口上的 UDP(以及 TCP)流量。
-
- - 已连接
- - 已断开连接
- - 正在连接
- - 连接失败
- - 等待双工
-
-
- - 正在初始化
- - 正在等待批准……
- - 已拒绝
- - 正在传输
- - 已暂停
- - 已停止
- - 已失败(点击查看)
- - 不可恢复的故障
- - 未找到文件
- - 已完成
- - 已完成但有错误(点击查看)
-
- 一启动就一直运行,停止需手动操作
- 退出应用后停止服务
- 从最近使用的应用被清除或一段时间不活动后,该服务将自动停止
- 选择要发送的文件夹
- 非默认位置不支持保留上次修改的时间戳。 你可以使用重置按钮恢复默认。
- 重置为默认值
- 清除已完成的传输
+ 重新连接
+ 一启动就一直运行,停止需手动操作
+ 退出应用后停止服务
+ 从最近使用的应用被清除或一段时间不活动后,该服务将自动停止
尝试使用压缩
注册用端口
- 你的群组之外的设备:%1$d
- 发起连接
用相机应用扫描上方二维码并打开链接或使用下列地址从其他设备发起连接:
- 手动连接
- 保存日志
- 输入地址和端口
- 尝试菜单中的手动连接
- 连接到“%s“?
- %d 已连接
- 地址已复制到剪贴板
- 二维码
- 设备必须在相同网络上并使用相同的群组码
- 其他设备未回连。可能它没看到我们?
+ 手动连接
+ 保存日志
首选的网络接口
%s (如不可用将回退到自动)
- , 不在相同子网上
在 Android 15+ 上不可用
- 警告
- 此设备不在相同子网上。连接到设备可能失败。请检查两台设备的设置,确认 Warpinator 使用的是正确的网络接口。
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
deleted file mode 100644
index de9ab66c..00000000
--- a/app/src/main/res/values/arrays.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
- - sysDefault
- - lightTheme
- - darkTheme
-
-
- - @string/sysDefault
- - @string/lightTheme
- - @string/darkTheme
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..e282e489
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #F9F9F9
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
deleted file mode 100644
index 125df871..00000000
--- a/app/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- 16dp
-
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6c819732..8e0e1432 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,161 +2,416 @@
Warpinator
-
- Transfers
- Send files
- Quit
- Connection issues
- Manual connection
- Save log
- Reannounce
- Rescan for devices
- About
- Settings
- Share with
- Available devices
+
+
+
+
+ Back
+ Open menu
+ Copy
+ Share
+ Delete
+ Confirm edit
+ Close
+ Save
+ Send
+ Done
+
+
+
+
+
+ Expanded
+
+ Collapsed
+
+ collapse
+
+ expand details
+ IP: %1$s
+
+ Toggle favorite
+
+ select device
+
+ Delete transfer
+
+ Accept transfer
+
+ Decline transfer
+
+ Stop transfer
+
+ Cancel transfer
+
+ Retry transfer
+
+ Open transfer item
+ Copy URL
+
+ Send
+
+ Device does not support text messages
+
+ Device is disconnected
+
+ Message is empty
+ Sent message: %1$s
+ Received message: %1$s
+ Sent at: %1$s
+ Received at: %1$s
+
+ Delete message
+
+ Copy message
+
+ Share message
+
+ Favorite
+
+ Not favorite
+
+ Profile picture %1$d
+
+
+
+
+
+ Quit
+
+ Connection issues
+ Manual connection
+
+ Save log
+
+ Reannounce
+
+ Rescan for devices
+ About
+ Settings
+ Available devices
No other devices found
- Connection failed: Wrong group code
Connection error
- You are not connected to WiFi or LAN. Use your hotspot if neither is available.
- Restart the application when you are connected.
- Network changed - restarting service…
- Devices outside your group: %1$d
- Address copied to clipboard
-
- Waiting for permission…
- (Files may be overwritten!)
- remaining
- Failed to open received file
- a few seconds
- , service unavailable
- , not on the same subnet
- Errors during transfer:
- Closing this activity while sharing might cause the transfer to fail
- Files being sent:
- Select a folder to send
- Enter your message
- Your message
- Message copied
- Sent
- Received
-
+ No Internet Connection
+ Connect via WiFi, LAN, or Hotspot, then restart the app.
+
+
+
+
+ Reconnect
+ Clear transfer history
+ Remove from favorites
+ Add to favorites
+ Transfers history
+ No transfers yet
+ Accept
+ Decline
+ Stop
+ Retry
+ Open
+ Cancel
+ Send File
+ Send Folder
+ Send Message
+
+ - %1$d transfer
+ - %1$d transfers
+
+
+ - %1$d message
+ - %1$d messages
+
+ Send a message
+ Drop here to send
+
+
+
+
+ Scan the QR code above with a camera app and open the link or use the following address to initiate the connection from the other device:
+ Device connected
+ Connecting to
+ Device already connected
+ Connection failed
+ Device not on same subnet
+ Device does not support manual connection
+ Add connection
+ QR Code of a connection URL
+ IP Address
+ Please select or type a valid address
+ Recent remotes
+ No recent remotes
+ Paste address from clipboard
+ From clipboard
+
+
+
+
+ Messages
+ Message…
+ Message history with %1$s
+ Sent message
+ Received message
+ Hide timestamp
+ Show timestamp
+ Open message options
+
+
+
+
Version: %1$s
- This is an unofficial port of Warpinator. \nSend and receive files across local network.
-
- This program is open source software licensed under <a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU General Public License</a>.<br>
- You can obtain the source code from <a href="https://github.com/slowscript/warpinator-android/">GitHub</a>.<br>
- <br>
- This program comes with absolutely no warranty.<br>See the terms of the license for more details.
-
- Devices to share with
- No available devices to share with
- We received an unsupported intent action
- Nothing to share
- Edit message before sending:
-
+
+ This software is licensed under the GNU General Public License v3.0 and is available as open source on GitHub.\n\nThis program comes with ABSOLUTELY NO WARRANTY. See the terms of the license for more details.
+ Help translate Warpinator
+ Translate Warpinator in your language
+ Rate on Google Play
+ Enjoying the app? Rate it on Google Play!
+ See the source code
+ Contributions are welcome
+ Issues
+ Have an issue? Report it here!
+ License
+
+ GNU General Public License v3.0
+
+
+
+
Identity
- Transfer settings
- Network
- Aspect
+ Transfer settings
+ App behavior
+ Network
+ Aspect
Display name
Profile picture
Downloads directory
Show notification about incoming files
Allow overwriting
Automatically accept transfers
- Transfers will be accepted automatically
- Every transfer needs to be accepted by you
+ Transfers will be accepted automatically
+ Every transfer needs to be accepted by you
Try use compression
- Run in background
Start automatically
Autostart unavailable on Android 15+
- Stop service after leaving app
- The service will be stopped automatically when cleared from recents or after a period of inactivity
- Once started, the service will continue to run until manually stopped
- Write debug log to file
+ Stop service after leaving app
+ The service will be stopped automatically when cleared from recents or after a period of inactivity
+ Once started, the service will continue to run until manually stopped
+ Write debug log to file
Group code
Port for transfers
Port for registration
Preferred network interface
+
%s (falls back to automatic if unavailable)
Theme
This may be affected by Android\'s notification settings
Port number must be between 1024 and 65535
Changing this setting requires the application to be restarted
- Your phone\'s vendor did not implement a required dialog. This will be worked around in a future release.
Incoming files with a name that already exists will overwrite the existing ones
Incoming files with a name that already exists will be renamed
- Custom
- You have selected an unsupported content provider. Please select a directory on your internal storage.
- Non-default locations don\'t support preserving last modified timestamps. You can revert to default with the reset button.
-
- Use system default
- Light Theme
- Dark Theme
-
+ Use system default
+ Light Theme
+ Dark Theme
+ Add Custom Picture
+ Use dynamic colors
+ Integrate messages with transfers
+ Messages and file transfers are shown in a single list
+ Messages and transfers are being kept in separate views
+ Change profile picture
+ Failed to save profile picture: %1$s
+ Drop image here to set
+
+
+
+
Warpinator service is running
Stop service
- All transfers are complete
%1$.1f%%, %2$d transfers, %3$s/s
- Incoming transfer from %s
- Message from %s
- %d files
+ Incoming transfer from %s
+
+
+ - %1$d file
+ - %1$d files
+
+
Service running
Persistent service notification
Incoming transfer
Transfer progress
-
- Reconnect
- Connection status
- Profile picture
- Application icon
- Disappointed face icon
- Selected picture
- Decline the transfer
- Accept the transfer
- Stop the transfer
- Retry the transfer
- Copy message
- Reset to default value
- QR code
-
- This is likely the first time you are launching this application.
- Please select a directory where you want Warpinator to save files into.
- You have to select a download directory in the settings
- Network unavailable - try hotspot?
- Service not running
- Failed to receive certificate from %1$s. Make sure to allow UDP (as well as TCP) traffic on port %2$d in remote\'s firewall.
- Clear finished transfers
- Scan the QR code above with a camera app and open the link or use the following address to initiate the connection from the other device:
- New connection
- Enter address and port
- Devices have to be on the same network and use the same group code
- Try Manual connection in the menu
- Connect to \'%s\'?
- %d connected
- The other device did not connect back. Maybe it doesn\'t see us?
- Warning
- This device is not on the same subnet. Connecting to it is likely to fail.
- Please check in the settings of both devices that Warpinator is using the correct network interface.
-
- - Connected
- - Disconnected
- - Connecting
- - Connection failed
- - Waiting for the other device to connect
-
-
- - Initializing
- - Waiting for permission…
- - Declined
- - Transferring
- - Paused
- - Stopped
- - Failed (tap to view errors)
- - Unrecoverable failure
- - File not found
- - Finished
- - Finished with errors (tap to view)
-
-
\ No newline at end of file
+ Tap to open
+ Unknown
+ New message from %1$s
+ Messages
+
+
+ - %1$d connected
+
+
+
+
+
+ Share with
+
+ - %1$d file selected
+ - %1$d files selected
+
+ Tap to edit
+
+ - %1$s, and %2$d more
+
+
+
+
+
+ Restarting Warpinator…
+ Starting Warpinator…
+ Stopping Warpinator…
+ Failed to start Warpinator
+
+ Network changed
+ Unknown error
+ Reannounce failed: %1$s
+ Rescan failed: %1$s
+ Failed to start GRPC server. Please reboot the device or adjust port numbers.
+ Discovery service failed. Other devices might not see you.
+ A security error occurred while starting. Please contact the developers.
+ Running in legacy mode. Some features may be limited.
+ Unregistering failed: %1$s
+ Found devices using different group codes. Make sure your current code is correct.
+ Could not save log to file: %1$s
+ Dumped log file to selected destination
+
+
+
+
+ Awaiting duplex
+ Connecting
+ Connected
+ Disconnected
+ Failed to connect\n%1$s
+
+
+
+
+
+
+ - %1$d file
+ - %1$d files
+
+
+
+ %1$s / %2$s
+
+ %1$s / %2$s (%3$s, %4$s)
+
+
+ %1$s/s
+
+
+ calculating…
+
+
+ more than a day remaining
+
+
+ a few seconds remaining
+
+
+
+ - %1$ds remaining
+ - %1$ds remaining
+
+
+
+ %1$s %2$s remaining
+
+
+
+ - %1$dh
+
+
+
+ - %1$dm
+
+
+
+ - %1$ds
+
+
+ Receiving
+ Sending
+ Outgoing transfer
+ Incoming transfer
+
+ Initializing
+ Waiting for permission…
+ Waiting for permission… (File will be overwritten)
+ Declined
+ Paused
+ Stopped
+ Failed
+ Finished
+ Finished with errors
+
+
+
+
+ Enables Warpinator to discover other devices, send and receive files, and maintain active transfers in the background, ensuring reliable operation even when the app is not in use.
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
deleted file mode 100644
index eda2ce7e..00000000
--- a/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..02423d89
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml
deleted file mode 100644
index 5b47c1f5..00000000
--- a/app/src/main/res/xml/root_preferences.xml
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/test/java/slowscript/warpinator/core/utils/PreferenceDataSerializationTest.kt b/app/src/test/java/slowscript/warpinator/core/utils/PreferenceDataSerializationTest.kt
new file mode 100644
index 00000000..2d67b194
--- /dev/null
+++ b/app/src/test/java/slowscript/warpinator/core/utils/PreferenceDataSerializationTest.kt
@@ -0,0 +1,74 @@
+package slowscript.warpinator.core.utils
+
+import org.junit.Test
+import slowscript.warpinator.core.model.preferences.RecentRemote
+import slowscript.warpinator.core.model.preferences.SavedFavourite
+import slowscript.warpinator.core.model.preferences.recentRemotesFromJson
+import slowscript.warpinator.core.model.preferences.savedFavouritesFromJson
+import slowscript.warpinator.core.model.preferences.toJson
+
+class PreferenceDataSerializationTest {
+ @Test
+ fun `Set SavedFavourite toJson serialization check`() {
+ val set = setOf(SavedFavourite("uuid1"), SavedFavourite("uuid2"))
+ val json = set.toJson()
+ assert(json == "[{\"uuid\":\"uuid1\"},{\"uuid\":\"uuid2\"}]")
+ }
+
+ @Test
+ fun `Set SavedFavourite toJson empty set handling`() {
+ val set = setOf()
+ val json = set.toJson()
+ assert(json == "[]")
+ }
+
+ @Test
+ fun `List RecentRemote toJson serialization check`() {
+ val list = listOf(RecentRemote("host1", "hostname1"), RecentRemote("host2", "hostname2"))
+ val json = list.toJson()
+ assert(json == "[{\"host\":\"host1\",\"hostname\":\"hostname1\"},{\"host\":\"host2\",\"hostname\":\"hostname2\"}]")
+ }
+
+ @Test
+ fun `List RecentRemote toJson empty list handling`() {
+ val list = listOf()
+ val json = list.toJson()
+ assert(json == "[]")
+ }
+
+ @Test
+ fun `Set SavedFavourite fromJson deserialization check`() {
+ val json = "[{\"uuid\":\"uuid1\"},{\"uuid\":\"uuid2\"}]"
+ val set = savedFavouritesFromJson(json)
+ assert(set.contains(SavedFavourite("uuid1")))
+ assert(set.contains(SavedFavourite("uuid2")))
+ }
+
+ @Test
+ fun `Set SavedFavourite fromJson malformed json exception`() {
+ val json = "[{\"uuid\":\"uuid1\"},{\"uuid\":\"uuid2\""
+ try {
+ savedFavouritesFromJson(json)
+ assert(false)
+ } catch (e: Exception) {
+ assert(true)
+ }
+ }
+
+ @Test
+ fun `Set SavedFavourite fromJson empty array input`() {
+ val json = "[]"
+ val set = savedFavouritesFromJson(json)
+ assert(set.isEmpty())
+ }
+
+ @Test
+ fun `List RecentRemote fromJson deserialization check`() {
+ val json =
+ "[{\"host\":\"host1\",\"hostname\":\"hostname1\"},{\"host\":\"host2\",\"hostname\":\"hostname2\"}]"
+ val list = recentRemotesFromJson(json)
+ assert(list.contains(RecentRemote("host1", "hostname1")))
+ assert(list.contains(RecentRemote("host2", "hostname2")))
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/slowscript/warpinator/core/utils/RemoteDisplayInfoTest.kt b/app/src/test/java/slowscript/warpinator/core/utils/RemoteDisplayInfoTest.kt
new file mode 100644
index 00000000..af9d29fb
--- /dev/null
+++ b/app/src/test/java/slowscript/warpinator/core/utils/RemoteDisplayInfoTest.kt
@@ -0,0 +1,226 @@
+package slowscript.warpinator.core.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RemoteDisplayInfoTest {
+ private fun assertLabel(
+ displayName: String? = null,
+ userName: String? = null,
+ hostname: String? = null,
+ address: String? = null,
+ expectedTitle: String,
+ expectedSubtitle: String,
+ expectedLabel: String?,
+ ) {
+ val result = RemoteDisplayInfo.fromValues(displayName, userName, hostname, address)
+
+ assertEquals("Title mismatch", expectedTitle, result.title)
+ assertEquals("Subtitle mismatch", expectedSubtitle, result.subtitle)
+ assertEquals("Label mismatch", expectedLabel, result.label)
+ }
+
+ // When all 4 values are present test case
+ // title: My Phone
+ // subtitle: admin@pixel-6
+ // label: 192.168.0.50
+ @Test
+ fun `Case 1 - All values present returns DisplayName, User@Host, and Address`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = "admin",
+ hostname = "pixel-6",
+ address = "192.168.0.50",
+ expectedTitle = "My Phone",
+ expectedSubtitle = "admin@pixel-6",
+ expectedLabel = "192.168.0.50",
+ )
+ }
+
+ // --- Combinations of Size 3 ---
+
+ @Test
+ fun `Case 2 - Missing Address returns DisplayName and User@Host`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = "admin",
+ hostname = "pixel-6",
+ address = null,
+ expectedTitle = "My Phone",
+ expectedSubtitle = "admin@pixel-6",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 3 - Missing Hostname returns DisplayName and User@Address`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = "admin",
+ hostname = null,
+ address = "192.168.0.50",
+ expectedTitle = "My Phone",
+ expectedSubtitle = "admin@192.168.0.50",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 4 - Missing UserName returns DisplayName, Hostname, and Address`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = null,
+ hostname = "pixel-6",
+ address = "192.168.0.50",
+ expectedTitle = "My Phone",
+ expectedSubtitle = "pixel-6",
+ expectedLabel = "192.168.0.50",
+ )
+ }
+
+ @Test
+ fun `Case 5 - Missing DisplayName returns UserName, Hostname, and Address`() {
+ assertLabel(
+ displayName = null,
+ userName = "admin",
+ hostname = "pixel-6",
+ address = "192.168.0.50",
+ expectedTitle = "admin",
+ expectedSubtitle = "pixel-6",
+ expectedLabel = "192.168.0.50",
+ )
+ }
+
+ // --- Combinations of Size 2 ---
+
+ @Test
+ fun `Case 6 - DisplayName and UserName present returns DisplayName and UserName`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = "admin",
+ hostname = null,
+ address = null,
+ expectedTitle = "My Phone",
+ expectedSubtitle = "admin",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 7 - DisplayName and Address present returns DisplayName and Address`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = null,
+ hostname = null,
+ address = "192.168.0.50",
+ expectedTitle = "My Phone",
+ expectedSubtitle = "192.168.0.50",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 8 - DisplayName and Hostname present returns DisplayName and Hostname`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = null,
+ hostname = "pixel-6",
+ address = null,
+ expectedTitle = "My Phone",
+ expectedSubtitle = "pixel-6",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 9 - UserName and Hostname present returns UserName and Hostname`() {
+ assertLabel(
+ displayName = null,
+ userName = "admin",
+ hostname = "pixel-6",
+ address = null,
+ expectedTitle = "admin",
+ expectedSubtitle = "pixel-6",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 10 - UserName and Address present returns UserName and Address`() {
+ assertLabel(
+ displayName = null,
+ userName = "admin",
+ hostname = null,
+ address = "192.168.0.50",
+ expectedTitle = "admin",
+ expectedSubtitle = "192.168.0.50",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 11 - Hostname and Address present returns Hostname and Address`() {
+ // This is a special fallback where Host becomes the Title
+ assertLabel(
+ displayName = null,
+ userName = null,
+ hostname = "pixel-6",
+ address = "192.168.0.50",
+ expectedTitle = "pixel-6",
+ expectedSubtitle = "192.168.0.50",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 12 - Only DisplayName present repeats DisplayName as Subtitle`() {
+ assertLabel(
+ displayName = "My Phone",
+ userName = null,
+ hostname = null,
+ address = null,
+ expectedTitle = "My Phone",
+ expectedSubtitle = "My Phone",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 13 - Only UserName present repeats UserName as Subtitle`() {
+ assertLabel(
+ displayName = null,
+ userName = "admin",
+ hostname = null,
+ address = null,
+ expectedTitle = "admin",
+ expectedSubtitle = "admin",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 14 - Only Hostname present returns Unknown Title and Hostname Subtitle`() {
+ assertLabel(
+ displayName = null,
+ userName = null,
+ hostname = "pixel-6",
+ address = null,
+ expectedTitle = "Unknown",
+ expectedSubtitle = "pixel-6",
+ expectedLabel = null,
+ )
+ }
+
+ @Test
+ fun `Case 15 - Only Address present returns Unknown Title and Address Subtitle`() {
+ assertLabel(
+ displayName = null,
+ userName = null,
+ hostname = null,
+ address = "192.168.0.50",
+ expectedTitle = "Unknown",
+ expectedSubtitle = "192.168.0.50",
+ expectedLabel = null,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/slowscript/warpinator/core/utils/ZlibCompressorTest.kt b/app/src/test/java/slowscript/warpinator/core/utils/ZlibCompressorTest.kt
new file mode 100644
index 00000000..7a621054
--- /dev/null
+++ b/app/src/test/java/slowscript/warpinator/core/utils/ZlibCompressorTest.kt
@@ -0,0 +1,63 @@
+package slowscript.warpinator.core.utils
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.zip.Deflater
+import kotlin.random.Random
+
+class ZlibCompressorTest {
+
+ @Test
+ fun `compress and decompress returns identical byte array`() {
+ val originalString = "Hello, World! This is a test string to verify Zlib compression."
+ val inputBytes = originalString.toByteArray(Charsets.UTF_8)
+
+ val compressedBytes = ZlibCompressor.compress(
+ inputBytes,
+ inputBytes.size,
+ Deflater.BEST_COMPRESSION,
+ )
+ val decompressedBytes = ZlibCompressor.decompress(compressedBytes)
+
+ assertArrayEquals("Decompressed bytes should match original", inputBytes, decompressedBytes)
+
+ val decompressedString = String(decompressedBytes, Charsets.UTF_8)
+ assertEquals(originalString, decompressedString)
+ }
+
+ @Test
+ fun `handles data larger than buffer size`() {
+ val largeSize = 5000 // ~5KB
+ val inputBytes = Random.nextBytes(largeSize)
+
+ val compressed =
+ ZlibCompressor.compress(inputBytes, inputBytes.size, Deflater.DEFAULT_COMPRESSION)
+ val decompressed = ZlibCompressor.decompress(compressed)
+
+ assertArrayEquals(inputBytes, decompressed)
+ }
+
+ @Test
+ fun `compress respects the length parameter`() {
+ val fullArray = "PartToCompress_PartToIgnore".toByteArray()
+ val partToCompress = "PartToCompress".toByteArray()
+ val length = partToCompress.size
+
+ val compressed = ZlibCompressor.compress(fullArray, length, Deflater.DEFAULT_COMPRESSION)
+ val decompressed = ZlibCompressor.decompress(compressed)
+
+ assertArrayEquals(partToCompress, decompressed)
+ }
+
+ @Test
+ fun `compress reduces size of repetitive text`() {
+ val original = "A".repeat(1000).toByteArray()
+
+ val compressed = ZlibCompressor.compress(original, original.size, Deflater.BEST_COMPRESSION)
+
+ assert(compressed.size < original.size) {
+ "Compressed data (${compressed.size}) should be smaller than original (${original.size})"
+ }
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 8fc51446..4d2c1a25 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,20 +1,31 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
-
+
+ ext {
+ kotlin_version = '2.2.0'
+ }
repositories {
google()
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.13.0'
- classpath 'com.google.protobuf:protobuf-gradle-plugin:0.9.5'
+ classpath 'com.android.tools.build:gradle:8.13.2'
+ classpath 'com.google.protobuf:protobuf-gradle-plugin:0.9.6'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
+plugins {
+ id 'com.google.dagger.hilt.android' version '2.57.2' apply false
+ id 'com.google.devtools.ksp' version '2.3.4' apply false
+ id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0'
+}
+
allprojects {
repositories {
google()