first commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'de.netclip.app'
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "de.netclip.app"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
// Zum Testen mit dem Debug-Schluessel signieren; fuer echte Verteilung eigenen Keystore eintragen
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
testOptions {
|
||||
unitTests.all { systemProperty 'interop', project.findProperty('interop') ?: '' }
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-dontwarn okhttp3.internal.platform.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
@@ -0,0 +1,136 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:name=".NetClipApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.NetClip"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="35">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:exported="true"
|
||||
android:parentActivityName=".MainActivity">
|
||||
<!-- netclip://connect?... (QR-Code mit beliebiger Scanner-App) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="netclip" />
|
||||
</intent-filter>
|
||||
<!-- .netclip-Datei oeffnen -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:pathPattern=".*\\.netclip" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ShareActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:label="@string/send_label"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/Theme.NetClip.Invisible">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".SendClipboardActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:noHistory="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.NetClip.Invisible" />
|
||||
|
||||
<!-- QR-Scanner (zxing) auch im Hochformat -->
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".SyncService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Haelt die Verbindung zum eigenen Zwischenablage-Server, um Clips sofort zu empfangen" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".ClipTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_stat"
|
||||
android:label="@string/tile_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".ActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,276 @@
|
||||
package de.netclip.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.IntentCompat
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import de.netclip.app.databinding.ActivityMainBinding
|
||||
import de.netclip.app.databinding.ActivitySettingsBinding
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var b: ActivityMainBinding
|
||||
private lateinit var adapter: ArrayAdapter<Entry>
|
||||
private val listener: () -> Unit = { refresh() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
b = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(b.root)
|
||||
|
||||
adapter = object : ArrayAdapter<Entry>(this, R.layout.item_clip, R.id.title) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val v = super.getView(position, convertView, parent)
|
||||
val e = getItem(position)!!
|
||||
v.findViewById<TextView>(R.id.title).text = e.title
|
||||
v.findViewById<TextView>(R.id.subtitle).text = e.subtitle
|
||||
v.findViewById<ImageView>(R.id.icon).setImageResource(when (e.kind) {
|
||||
"image" -> R.drawable.ic_image
|
||||
"files" -> R.drawable.ic_folder
|
||||
else -> R.drawable.ic_text
|
||||
})
|
||||
return v
|
||||
}
|
||||
}
|
||||
b.list.adapter = adapter
|
||||
b.list.emptyView = b.empty
|
||||
b.list.setOnItemClickListener { _, _, pos, _ -> Repo.fetch(adapter.getItem(pos)!!, Action.COPY) }
|
||||
b.list.setOnItemLongClickListener { _, _, pos, _ -> showActions(adapter.getItem(pos)!!); true }
|
||||
b.send.setOnClickListener { startActivity(Intent(this, SendClipboardActivity::class.java)) }
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
|
||||
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 2)
|
||||
}
|
||||
if (!Repo.cfg.configured) startActivity(Intent(this, SettingsActivity::class.java))
|
||||
else SyncService.start(this)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
/** Aus Benachrichtigung: "Teilen" fuer einen Clip. */
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
val id = intent?.getStringExtra("id") ?: return
|
||||
val action = intent.getStringExtra("action") ?: return
|
||||
Repo.history.firstOrNull { it.id == id }?.let { Repo.fetch(it, Action.valueOf(action)) }
|
||||
intent.removeExtra("id")
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Repo.addListener(listener)
|
||||
if (Repo.cfg.configured) SyncService.start(this)
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Repo.removeListener(listener)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
adapter.clear()
|
||||
adapter.addAll(Repo.history)
|
||||
b.status.text = when {
|
||||
!Repo.cfg.configured -> "Nicht eingerichtet"
|
||||
Repo.connected -> if (Repo.devices.isEmpty()) "Verbunden · keine weiteren Geräte online"
|
||||
else "Verbunden · ${Repo.devices.joinToString(", ")}"
|
||||
else -> "Getrennt" + if (Repo.lastError.isNotEmpty()) ": ${Repo.lastError}" else " – verbinde …"
|
||||
}
|
||||
b.progress.visibility = if (Repo.progress != null) View.VISIBLE else View.GONE
|
||||
b.progressText.visibility = b.progress.visibility
|
||||
b.progressText.text = Repo.progress
|
||||
}
|
||||
|
||||
private fun showActions(e: Entry) {
|
||||
val labels = arrayOf("In Zwischenablage", "Speichern (Download/NetClip)", "Teilen", "Löschen")
|
||||
AlertDialog.Builder(this).setTitle(e.title).setItems(labels) { _, which ->
|
||||
when (which) {
|
||||
0 -> Repo.fetch(e, Action.COPY)
|
||||
1 -> Repo.fetch(e, Action.SAVE)
|
||||
2 -> Repo.fetch(e, Action.SHARE)
|
||||
3 -> Repo.delete(e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.main, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.settings -> startActivity(Intent(this, SettingsActivity::class.java))
|
||||
R.id.clear -> AlertDialog.Builder(this).setMessage("Gesamten Verlauf für alle Geräte löschen?")
|
||||
.setPositiveButton("Löschen") { _, _ -> Repo.clear() }.setNegativeButton("Abbrechen", null).show()
|
||||
R.id.quit -> {
|
||||
startService(Intent(this, SyncService::class.java).setAction(SyncService.ACTION_STOP))
|
||||
finishAffinity()
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var b: ActivitySettingsBinding
|
||||
private val cfg get() = Repo.cfg
|
||||
|
||||
private val scan = registerForActivityResult(ScanContract()) { result ->
|
||||
result.contents?.let { importText(it) }
|
||||
}
|
||||
private val openFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri?.let { importUri(it) }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
b = ActivitySettingsBinding.inflate(layoutInflater)
|
||||
setContentView(b.root)
|
||||
title = "Einstellungen"
|
||||
load()
|
||||
b.scan.setOnClickListener {
|
||||
scan.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false)
|
||||
.setOrientationLocked(false).setPrompt("QR-Code aus dem Desktop-Client scannen\n(Einstellungen → QR-Code für Android)"))
|
||||
}
|
||||
b.importFile.setOnClickListener { openFile.launch(arrayOf("*/*")) }
|
||||
b.save.setOnClickListener { save() }
|
||||
|
||||
// netclip://connect?... aus QR-Code einer anderen App oder einem Link, bzw. geoeffnete .netclip-Datei
|
||||
intent?.data?.let { uri -> if (uri.scheme == "netclip") importText(uri.toString()) else importUri(uri) }
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
b.server.setText(cfg.server)
|
||||
b.token.setText(cfg.token)
|
||||
b.serverKey.setText(cfg.serverKey)
|
||||
b.verifyTls.isChecked = cfg.verifyTls
|
||||
b.deviceName.setText(cfg.deviceName)
|
||||
b.autoCopy.isChecked = cfg.autoCopy
|
||||
b.maxAuto.setText(cfg.maxAutoMb.toString())
|
||||
b.notifications.isChecked = cfg.notifications
|
||||
}
|
||||
|
||||
private fun importText(text: String) {
|
||||
val tmp = Config(this)
|
||||
val before = listOf(tmp.server, tmp.token, tmp.serverKey, tmp.verifyTls)
|
||||
if (tmp.importConnection(text)) {
|
||||
load()
|
||||
Repo.toast("Verbindungsdaten übernommen")
|
||||
if (before != listOf(tmp.server, tmp.token, tmp.serverKey, tmp.verifyTls)) SyncService.restart(this)
|
||||
} else {
|
||||
Repo.toast("Keine gültigen NetClip-Verbindungsdaten")
|
||||
}
|
||||
}
|
||||
|
||||
private fun importUri(uri: Uri) {
|
||||
try {
|
||||
contentResolver.openInputStream(uri)?.use { importText(it.readBytes().toString(Charsets.UTF_8)) }
|
||||
} catch (e: Exception) {
|
||||
Repo.toast("Datei nicht lesbar: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
var server = b.server.text.toString().trim().trimEnd('/')
|
||||
if (server.isNotEmpty() && !server.contains("://")) server = "https://$server"
|
||||
cfg.server = server
|
||||
cfg.token = b.token.text.toString().trim()
|
||||
cfg.serverKey = b.serverKey.text.toString().trim()
|
||||
cfg.verifyTls = b.verifyTls.isChecked
|
||||
cfg.deviceName = b.deviceName.text.toString().trim().ifEmpty { cfg.deviceName }
|
||||
cfg.autoCopy = b.autoCopy.isChecked
|
||||
cfg.maxAutoMb = b.maxAuto.text.toString().toIntOrNull() ?: 20
|
||||
cfg.notifications = b.notifications.isChecked
|
||||
if (cfg.configured) SyncService.restart(this)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/** Empfaengt "Teilen" aus anderen Apps (Text, Bilder, Dateien) und markierten Text (PROCESS_TEXT). */
|
||||
class ShareActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val content: Outgoing? = when (intent.action) {
|
||||
Intent.ACTION_PROCESS_TEXT -> intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.let { Outgoing.Text(it.toString()) }
|
||||
Intent.ACTION_SEND -> {
|
||||
val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
when {
|
||||
stream != null && intent.type?.startsWith("image/") == true -> Outgoing.Image(stream)
|
||||
stream != null -> Outgoing.Files(listOf(stream))
|
||||
else -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let { Outgoing.Text(it) }
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE ->
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.takeIf { it.isNotEmpty() }?.let { Outgoing.Files(it) }
|
||||
else -> null
|
||||
}
|
||||
if (!Repo.cfg.configured) {
|
||||
Repo.toast("NetClip ist noch nicht eingerichtet")
|
||||
} else if (content == null) {
|
||||
Repo.toast("Nichts zum Senden gefunden")
|
||||
} else {
|
||||
Repo.send(content)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Zwischenablage und sendet sie. Android erlaubt das nur einer App im Vordergrund,
|
||||
* daher diese unsichtbare Activity (aufgerufen von Kachel, Benachrichtigung und Button).
|
||||
*/
|
||||
class SendClipboardActivity : Activity() {
|
||||
private var done = false
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (!hasFocus || done) return
|
||||
done = true
|
||||
val cm = getSystemService(ClipboardManager::class.java)
|
||||
val clip = cm.primaryClip
|
||||
val content: Outgoing? = if (clip == null || clip.itemCount == 0) null else {
|
||||
val desc = clip.description
|
||||
val uris = (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri }
|
||||
when {
|
||||
uris.isNotEmpty() && (0 until desc.mimeTypeCount).any { desc.getMimeType(it).startsWith("image/") } &&
|
||||
uris.size == 1 -> Outgoing.Image(uris[0])
|
||||
uris.isNotEmpty() -> Outgoing.Files(uris)
|
||||
else -> clip.getItemAt(0).coerceToText(this)?.toString()?.takeIf { it.isNotEmpty() }?.let { Outgoing.Text(it) }
|
||||
}
|
||||
}
|
||||
when {
|
||||
!Repo.cfg.configured -> Repo.toast("NetClip ist noch nicht eingerichtet")
|
||||
content == null -> Repo.toast("Zwischenablage ist leer")
|
||||
else -> Repo.send(content)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.netclip.app
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.BufferedSink
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
typealias Progress = (done: Long, total: Long) -> Unit
|
||||
|
||||
class Api(private val cfg: Config, val keys: Keys) {
|
||||
private val base = cfg.server.trimEnd('/')
|
||||
val roomUrl = "$base/api/v1/rooms/${keys.roomId}"
|
||||
|
||||
val client: OkHttpClient = OkHttpClient.Builder().apply {
|
||||
connectTimeout(20, TimeUnit.SECONDS)
|
||||
readTimeout(10, TimeUnit.MINUTES)
|
||||
writeTimeout(10, TimeUnit.MINUTES)
|
||||
pingInterval(30, TimeUnit.SECONDS)
|
||||
if (!cfg.verifyTls) {
|
||||
// Selbstsigniertes Zertifikat ohne Pruefung (nur im eigenen Netz verwenden!)
|
||||
val trustAll = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
|
||||
}
|
||||
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf<TrustManager>(trustAll), null) }
|
||||
sslSocketFactory(ssl.socketFactory, trustAll)
|
||||
hostnameVerifier { _, _ -> true }
|
||||
}
|
||||
addInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
if (cfg.serverKey.isNotEmpty()) req.header("Authorization", "Bearer ${cfg.serverKey}")
|
||||
chain.proceed(req.build())
|
||||
}
|
||||
}.build()
|
||||
|
||||
private fun check(code: Int, message: String) {
|
||||
if (code == 401) throw IOException("Server-Schluessel falsch")
|
||||
if (code == 413) throw IOException("Zu gross fuer den Server")
|
||||
if (code !in 200..299) throw IOException("HTTP $code $message")
|
||||
}
|
||||
|
||||
fun listClips(): JSONArray =
|
||||
client.newCall(Request.Builder().url("$roomUrl/clips").build()).execute().use {
|
||||
check(it.code, it.message)
|
||||
JSONObject(it.body!!.string()).getJSONArray("clips")
|
||||
}
|
||||
|
||||
fun upload(meta: JSONObject, blob: File, progress: Progress?) {
|
||||
val metaBytes = keys.encryptMeta(meta).toByteArray(Charsets.US_ASCII)
|
||||
val total = 4L + metaBytes.size + blob.length()
|
||||
val body = object : RequestBody() {
|
||||
override fun contentType() = "application/octet-stream".toMediaType()
|
||||
override fun contentLength() = total
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
sink.write(ByteBuffer.allocate(4).putInt(metaBytes.size).array())
|
||||
sink.write(metaBytes)
|
||||
var done = 4L + metaBytes.size
|
||||
blob.inputStream().use { input ->
|
||||
val buf = ByteArray(256 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
sink.write(buf, 0, n)
|
||||
done += n
|
||||
progress?.invoke(done, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val req = Request.Builder().url("$roomUrl/clips").header("X-Device", cfg.deviceId).post(body).build()
|
||||
client.newCall(req).execute().use { check(it.code, it.message) }
|
||||
}
|
||||
|
||||
fun download(id: String, dest: File, progress: Progress?) {
|
||||
client.newCall(Request.Builder().url("$roomUrl/clips/$id/blob").build()).execute().use { resp ->
|
||||
check(resp.code, resp.message)
|
||||
val body = resp.body!!
|
||||
val total = body.contentLength()
|
||||
var done = 0L
|
||||
body.byteStream().use { input ->
|
||||
dest.outputStream().use { out ->
|
||||
val buf = ByteArray(256 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
done += n
|
||||
progress?.invoke(done, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(id: String) =
|
||||
client.newCall(Request.Builder().url("$roomUrl/clips/$id").delete().build()).execute()
|
||||
.use { check(it.code, it.message) }
|
||||
|
||||
fun clear() =
|
||||
client.newCall(Request.Builder().url("$roomUrl/clips").delete().build()).execute()
|
||||
.use { check(it.code, it.message) }
|
||||
|
||||
fun openSocket(listener: WebSocketListener): WebSocket {
|
||||
val wsUrl = roomUrl.replaceFirst("https://", "wss://").replaceFirst("http://", "ws://") +
|
||||
"/ws?device=" + URLEncoder.encode(cfg.deviceId, "UTF-8")
|
||||
return client.newWebSocket(Request.Builder().url(wsUrl).build(), listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.netclip.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** Einstellungen in SharedPreferences. */
|
||||
class Config(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("netclip", Context.MODE_PRIVATE)
|
||||
|
||||
var server: String
|
||||
get() = prefs.getString("server", "")!!
|
||||
set(v) = prefs.edit().putString("server", v).apply()
|
||||
var token: String
|
||||
get() = prefs.getString("token", "")!!
|
||||
set(v) = prefs.edit().putString("token", v).apply()
|
||||
var serverKey: String
|
||||
get() = prefs.getString("server_key", "")!!
|
||||
set(v) = prefs.edit().putString("server_key", v).apply()
|
||||
var verifyTls: Boolean
|
||||
get() = prefs.getBoolean("verify_tls", true)
|
||||
set(v) = prefs.edit().putBoolean("verify_tls", v).apply()
|
||||
var deviceName: String
|
||||
get() = prefs.getString("device_name", null) ?: "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
set(v) = prefs.edit().putString("device_name", v).apply()
|
||||
var autoCopy: Boolean
|
||||
get() = prefs.getBoolean("auto_copy", true)
|
||||
set(v) = prefs.edit().putBoolean("auto_copy", v).apply()
|
||||
var maxAutoMb: Int
|
||||
get() = prefs.getInt("max_auto_mb", 20)
|
||||
set(v) = prefs.edit().putInt("max_auto_mb", v).apply()
|
||||
var notifications: Boolean
|
||||
get() = prefs.getBoolean("notifications", true)
|
||||
set(v) = prefs.edit().putBoolean("notifications", v).apply()
|
||||
|
||||
val deviceId: String
|
||||
get() = prefs.getString("device_id", null) ?: ByteArray(8).also { SecureRandom().nextBytes(it) }
|
||||
.joinToString("") { "%02x".format(it) }.also { prefs.edit().putString("device_id", it).apply() }
|
||||
|
||||
val configured get() = server.isNotBlank() && token.isNotBlank()
|
||||
|
||||
/** Fingerabdruck der Verbindungsdaten: aendert er sich, muss neu verbunden werden. */
|
||||
val connectionKey get() = listOf(server, token, serverKey, verifyTls, deviceName).joinToString("|")
|
||||
|
||||
/** Uebernimmt netclip://connect?... (QR-Code / .netclip-Datei) oder JSON. */
|
||||
fun importConnection(text: String): Boolean {
|
||||
val t = text.trim()
|
||||
val values: Map<String, String?> = if (t.startsWith("{")) {
|
||||
val o = org.json.JSONObject(t)
|
||||
mapOf("server" to o.optString("server"), "token" to o.optString("token"),
|
||||
"key" to o.optString("server_key"), "insecure" to if (o.optBoolean("verify_tls", true)) null else "1")
|
||||
} else {
|
||||
val uri = Uri.parse(t)
|
||||
if (uri.scheme != "netclip") return false
|
||||
mapOf("server" to uri.getQueryParameter("server"), "token" to uri.getQueryParameter("token"),
|
||||
"key" to uri.getQueryParameter("key"), "insecure" to uri.getQueryParameter("insecure"))
|
||||
}
|
||||
val s = values["server"].orEmpty()
|
||||
val tok = values["token"].orEmpty()
|
||||
if (s.isBlank() || tok.isBlank()) return false
|
||||
server = s
|
||||
token = tok
|
||||
serverKey = values["key"].orEmpty()
|
||||
verifyTls = values["insecure"] != "1"
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.netclip.app
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.EOFException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Ende-zu-Ende-Verschluesselung, identisch zu client-desktop/netclip/crypto.py:
|
||||
* master = PBKDF2-HMAC-SHA256(token, "netclip-v1", 200000, 32)
|
||||
* room_id = hex(HMAC(master, "room-id")), key = HMAC(master, "enc-key")
|
||||
* Meta = base64(nonce12 || AES-GCM(json, aad="meta"))
|
||||
* Blob = "NCB1" || prefix8 || { flag1 || len4 || AES-GCM(prefix||counter4, chunk, aad=flag) }
|
||||
*/
|
||||
class Keys(token: String) {
|
||||
val roomId: String
|
||||
private val key: SecretKeySpec
|
||||
private val random = SecureRandom()
|
||||
|
||||
init {
|
||||
val master = pbkdf2(token.toByteArray(Charsets.UTF_8), "netclip-v1".toByteArray(), 200_000)
|
||||
roomId = hmac(master, "room-id".toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
key = SecretKeySpec(hmac(master, "enc-key".toByteArray()), "AES")
|
||||
}
|
||||
|
||||
private fun cipher(mode: Int, nonce: ByteArray, aad: ByteArray): Cipher =
|
||||
Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||
init(mode, key, GCMParameterSpec(128, nonce))
|
||||
updateAAD(aad)
|
||||
}
|
||||
|
||||
fun encryptMeta(meta: JSONObject): String {
|
||||
val nonce = ByteArray(12).also { random.nextBytes(it) }
|
||||
val ct = cipher(Cipher.ENCRYPT_MODE, nonce, META_AAD).doFinal(meta.toString().toByteArray())
|
||||
return Base64.getEncoder().encodeToString(nonce + ct)
|
||||
}
|
||||
|
||||
fun decryptMeta(data: String): JSONObject? = try {
|
||||
val raw = Base64.getDecoder().decode(data)
|
||||
val plain = cipher(Cipher.DECRYPT_MODE, raw.copyOfRange(0, 12), META_AAD).doFinal(raw, 12, raw.size - 12)
|
||||
JSONObject(String(plain, Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun encryptLabel(name: String): String = encryptMeta(JSONObject().put("name", name))
|
||||
|
||||
fun encryptStream(src: InputStream, dst: OutputStream) {
|
||||
val prefix = ByteArray(8).also { random.nextBytes(it) }
|
||||
dst.write(MAGIC)
|
||||
dst.write(prefix)
|
||||
var counter = 0
|
||||
var buf = readChunk(src)
|
||||
while (true) {
|
||||
val next = if (buf.isNotEmpty()) readChunk(src) else ByteArray(0)
|
||||
val last = next.isEmpty()
|
||||
val flag = byteArrayOf(if (last) 1 else 0)
|
||||
val ct = cipher(Cipher.ENCRYPT_MODE, nonce(prefix, counter), flag).doFinal(buf)
|
||||
dst.write(flag)
|
||||
dst.write(ByteBuffer.allocate(4).putInt(ct.size).array())
|
||||
dst.write(ct)
|
||||
if (last) return
|
||||
counter++
|
||||
buf = next
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptStream(src: InputStream, dst: OutputStream) {
|
||||
val input = DataInputStream(src)
|
||||
val magic = ByteArray(4)
|
||||
input.readFully(magic)
|
||||
if (!magic.contentEquals(MAGIC)) throw IllegalStateException("kein NetClip-Blob")
|
||||
val prefix = ByteArray(8)
|
||||
input.readFully(prefix)
|
||||
var counter = 0
|
||||
while (true) {
|
||||
val flag = try { input.readByte() } catch (e: EOFException) { throw IllegalStateException("Blob abgeschnitten") }
|
||||
val len = input.readInt()
|
||||
if (len < 16 || len > CHUNK + 16) throw IllegalStateException("ungueltiger Chunk")
|
||||
val ct = ByteArray(len)
|
||||
input.readFully(ct)
|
||||
dst.write(cipher(Cipher.DECRYPT_MODE, nonce(prefix, counter), byteArrayOf(flag)).doFinal(ct))
|
||||
if (flag.toInt() == 1) return
|
||||
counter++
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHUNK = 1024 * 1024
|
||||
private val MAGIC = "NCB1".toByteArray()
|
||||
private val META_AAD = "meta".toByteArray()
|
||||
|
||||
private fun nonce(prefix: ByteArray, counter: Int) = prefix + ByteBuffer.allocate(4).putInt(counter).array()
|
||||
|
||||
private fun readChunk(src: InputStream): ByteArray {
|
||||
val buf = ByteArray(CHUNK)
|
||||
var n = 0
|
||||
while (n < CHUNK) {
|
||||
val r = src.read(buf, n, CHUNK - n)
|
||||
if (r < 0) break
|
||||
n += r
|
||||
}
|
||||
return if (n == CHUNK) buf else buf.copyOf(n)
|
||||
}
|
||||
|
||||
private fun hmac(key: ByteArray, data: ByteArray): ByteArray =
|
||||
Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) }
|
||||
|
||||
/** PBKDF2-HMAC-SHA256 mit genau einem Block (32 Byte), Passwort als UTF-8 wie in Python. */
|
||||
private fun pbkdf2(password: ByteArray, salt: ByteArray, iterations: Int): ByteArray {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(password, "HmacSHA256"))
|
||||
var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, 1))
|
||||
val t = u.copyOf()
|
||||
repeat(iterations - 1) {
|
||||
u = mac.doFinal(u)
|
||||
for (i in t.indices) t[i] = (t[i].toInt() xor u[i].toInt()).toByte()
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.netclip.app
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/** Ausgehender Inhalt. */
|
||||
sealed class Outgoing {
|
||||
data class Text(val text: String) : Outgoing()
|
||||
data class Image(val uri: Uri) : Outgoing()
|
||||
data class Files(val uris: List<Uri>) : Outgoing()
|
||||
}
|
||||
|
||||
/** Formate wie im Desktop-Client (payload.py): text=UTF-8, image=PNG, files=ZIP. */
|
||||
object Payload {
|
||||
fun received(ctx: Context) = File(ctx.cacheDir, "received").apply { mkdirs() }
|
||||
|
||||
private fun displayName(ctx: Context, uri: Uri): String {
|
||||
ctx.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
|
||||
if (c.moveToFirst() && !c.isNull(0)) return c.getString(0).replace('/', '_')
|
||||
}
|
||||
return uri.lastPathSegment?.substringAfterLast('/') ?: "datei"
|
||||
}
|
||||
|
||||
/** Schreibt die Klartext-Payload nach [out] und liefert die Meta-Daten. */
|
||||
fun build(ctx: Context, content: Outgoing, deviceName: String, out: File): JSONObject {
|
||||
val meta = JSONObject().put("v", 1).put("device", deviceName)
|
||||
when (content) {
|
||||
is Outgoing.Text -> {
|
||||
out.writeText(content.text, Charsets.UTF_8)
|
||||
meta.put("kind", "text").put("preview", content.text.take(300)).put("chars", content.text.length)
|
||||
}
|
||||
is Outgoing.Image -> {
|
||||
val mime = ctx.contentResolver.getType(content.uri)
|
||||
if (mime == "image/png") {
|
||||
ctx.contentResolver.openInputStream(content.uri)!!.use { i -> out.outputStream().use { i.copyTo(it) } }
|
||||
} else {
|
||||
val bmp = ctx.contentResolver.openInputStream(content.uri)!!.use { BitmapFactory.decodeStream(it) }
|
||||
?: throw IllegalArgumentException("Bild nicht lesbar")
|
||||
out.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bmp.recycle()
|
||||
}
|
||||
meta.put("kind", "image").put("mime", "image/png").put("preview", "Bild (${human(out.length())})")
|
||||
}
|
||||
is Outgoing.Files -> {
|
||||
val names = JSONArray()
|
||||
val used = mutableSetOf<String>()
|
||||
ZipOutputStream(out.outputStream().buffered()).use { zip ->
|
||||
zip.setLevel(Deflater.BEST_SPEED)
|
||||
for (uri in content.uris) {
|
||||
var name = displayName(ctx, uri)
|
||||
if (!used.add(name)) {
|
||||
val base = name.substringBeforeLast('.')
|
||||
val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
|
||||
var n = 2
|
||||
while (!used.add("$base ($n)$ext")) n++
|
||||
name = "$base ($n)$ext"
|
||||
}
|
||||
names.put(name)
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
ctx.contentResolver.openInputStream(uri)!!.use { it.copyTo(zip) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
meta.put("kind", "files").put("names", names).put("count", names.length())
|
||||
.put("preview", (0 until names.length()).joinToString(", ") { names.getString(it) }.take(300))
|
||||
}
|
||||
}
|
||||
meta.put("size", out.length())
|
||||
return meta
|
||||
}
|
||||
|
||||
/** Entpackt eine Dateien-Payload sicher nach [target] (kein Pfad-Traversal). */
|
||||
fun unzip(plain: File, target: File) {
|
||||
val part = File(target.parentFile, target.name + ".part")
|
||||
part.deleteRecursively()
|
||||
part.mkdirs()
|
||||
val root = part.canonicalPath + File.separator
|
||||
ZipInputStream(plain.inputStream().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val e = zip.nextEntry ?: break
|
||||
val dest = File(part, e.name)
|
||||
if (!dest.canonicalPath.startsWith(root)) continue
|
||||
if (e.isDirectory) {
|
||||
dest.mkdirs()
|
||||
} else {
|
||||
dest.parentFile?.mkdirs()
|
||||
dest.outputStream().use { zip.copyTo(it) }
|
||||
if (e.time > 0) dest.setLastModified(e.time)
|
||||
}
|
||||
}
|
||||
}
|
||||
part.renameTo(target)
|
||||
}
|
||||
|
||||
/** Alle Dateien unterhalb von dir (fuer Teilen/Zwischenablage). */
|
||||
fun listFiles(dir: File): List<File> = dir.walkTopDown().filter { it.isFile }.toList()
|
||||
|
||||
/** Speichert Datei/Ordner unter Download/NetClip/ (Ordnerstruktur bleibt erhalten). */
|
||||
fun saveToDownloads(ctx: Context, src: File, relDir: String = "NetClip") {
|
||||
if (src.isDirectory) {
|
||||
src.listFiles()?.forEach { saveToDownloads(ctx, it, "$relDir/${src.name}") }
|
||||
return
|
||||
}
|
||||
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(src.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, src.name)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mime)
|
||||
put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$relDir")
|
||||
}
|
||||
val uri = ctx.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IllegalStateException("Speichern nicht moeglich")
|
||||
ctx.contentResolver.openOutputStream(uri)!!.use { out -> src.inputStream().use { it.copyTo(out) } }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), relDir)
|
||||
dir.mkdirs()
|
||||
src.copyTo(File(dir, src.name), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun human(n: Long): String {
|
||||
var v = n.toDouble()
|
||||
for (unit in listOf("B", "KB", "MB")) {
|
||||
if (v < 1024) return if (unit == "B") "${n} B" else "%.1f %s".format(v, unit)
|
||||
v /= 1024
|
||||
}
|
||||
return "%.1f GB".format(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package de.netclip.app
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class Entry(clip: JSONObject, val meta: JSONObject, val own: Boolean) {
|
||||
val id: String = clip.getString("id")
|
||||
val ts: Long = clip.getLong("ts")
|
||||
val size: Long = clip.getLong("size")
|
||||
val kind: String = meta.optString("kind", "text")
|
||||
val device: String = meta.optString("device", "?")
|
||||
|
||||
val title: String
|
||||
get() = when (kind) {
|
||||
"text" -> meta.optString("preview").replace(Regex("\\s+"), " ").trim().take(120).ifEmpty { "(leer)" }
|
||||
"files" -> {
|
||||
val names = meta.optJSONArray("names")
|
||||
val list = (0 until (names?.length() ?: 0)).map { names!!.getString(it) }
|
||||
list.take(3).joinToString(", ") + if (list.size > 3) " (+${list.size - 3})" else ""
|
||||
}
|
||||
else -> meta.optString("preview")
|
||||
}
|
||||
|
||||
val subtitle: String
|
||||
get() {
|
||||
val time = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMANY).format(Date(ts))
|
||||
val extra = when (kind) {
|
||||
"files" -> " · ${meta.optInt("count")} Datei(en)"
|
||||
"text" -> " · ${meta.optInt("chars")} Zeichen"
|
||||
else -> ""
|
||||
}
|
||||
return "$device · $time · ${Payload.human(meta.optLong("size", size))}$extra"
|
||||
}
|
||||
|
||||
/** Kurze Texte stecken vollstaendig in der Vorschau. */
|
||||
val inlineText: String?
|
||||
get() = if (kind == "text" && meta.optInt("chars", -1) == meta.optString("preview").length)
|
||||
meta.optString("preview") else null
|
||||
}
|
||||
|
||||
enum class Action { COPY, SAVE, SHARE, AUTO }
|
||||
|
||||
/** Zentrale Logik: Verbindung, Verlauf, Senden und Empfangen. Wird von Service und Activities genutzt. */
|
||||
object Repo {
|
||||
lateinit var app: Context
|
||||
private set
|
||||
lateinit var cfg: Config
|
||||
private set
|
||||
|
||||
val history = mutableListOf<Entry>()
|
||||
var connected = false
|
||||
private set
|
||||
var devices: List<String> = emptyList()
|
||||
private set
|
||||
var lastError = ""
|
||||
private set
|
||||
var progress: String? = null
|
||||
private set
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<() -> Unit>()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val io = Executors.newFixedThreadPool(3)
|
||||
private var api: Api? = null
|
||||
private var apiKey = ""
|
||||
private var socket: WebSocket? = null
|
||||
private var active = false
|
||||
private var retryDelay = 2000L
|
||||
private var generation = 0
|
||||
|
||||
fun init(ctx: Context) {
|
||||
app = ctx.applicationContext
|
||||
cfg = Config(app)
|
||||
}
|
||||
|
||||
fun addListener(l: () -> Unit) = listeners.add(l)
|
||||
fun removeListener(l: () -> Unit) = listeners.remove(l)
|
||||
private fun changed() = main.post { listeners.forEach { it() } }
|
||||
|
||||
@Synchronized
|
||||
private fun api(): Api {
|
||||
if (!cfg.configured) throw IllegalStateException("NetClip ist nicht eingerichtet")
|
||||
if (api == null || apiKey != cfg.connectionKey) {
|
||||
api = Api(cfg, Keys(cfg.token)) // Schluesselableitung dauert ~1 s
|
||||
apiKey = cfg.connectionKey
|
||||
}
|
||||
return api!!
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Verbindung
|
||||
fun connect() {
|
||||
active = true
|
||||
val gen = ++generation
|
||||
socket?.cancel()
|
||||
socket = null
|
||||
if (!cfg.configured) {
|
||||
lastError = "Nicht eingerichtet"
|
||||
changed()
|
||||
return
|
||||
}
|
||||
io.execute {
|
||||
try {
|
||||
val a = api()
|
||||
if (gen == generation && active) socket = a.openSocket(Listener(gen, a))
|
||||
} catch (e: Exception) {
|
||||
lastError = e.message ?: e.toString()
|
||||
changed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
active = false
|
||||
generation++
|
||||
socket?.close(1000, null)
|
||||
socket = null
|
||||
connected = false
|
||||
changed()
|
||||
}
|
||||
|
||||
private class Listener(val gen: Int, val a: Api) : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (gen != generation) return webSocket.cancel()
|
||||
webSocket.send(JSONObject().put("type", "hello").put("label", a.keys.encryptLabel(cfg.deviceName)).toString())
|
||||
retryDelay = 2000L
|
||||
connected = true
|
||||
lastError = ""
|
||||
changed()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (gen != generation) return
|
||||
val msg = try { JSONObject(text) } catch (e: Exception) { return }
|
||||
main.post { handle(msg, a) }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = lost(reason)
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) =
|
||||
lost(if (response?.code == 401) "Server-Schluessel falsch" else t.message ?: t.toString())
|
||||
|
||||
private fun lost(reason: String) {
|
||||
if (gen != generation) return
|
||||
connected = false
|
||||
devices = emptyList()
|
||||
lastError = reason
|
||||
changed()
|
||||
if (active) {
|
||||
main.postDelayed({ if (gen == generation && active) connect() }, retryDelay)
|
||||
retryDelay = (retryDelay * 2).coerceAtMost(60_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun entry(clip: JSONObject, a: Api): Entry? {
|
||||
val meta = a.keys.decryptMeta(clip.getString("meta")) ?: return null
|
||||
return Entry(clip, meta, clip.getString("device") == cfg.deviceId)
|
||||
}
|
||||
|
||||
private fun handle(msg: JSONObject, a: Api) {
|
||||
when (msg.optString("type")) {
|
||||
"hello" -> {
|
||||
val clips = msg.getJSONArray("clips")
|
||||
history.clear()
|
||||
for (i in 0 until clips.length()) entry(clips.getJSONObject(i), a)?.let { history.add(it) }
|
||||
}
|
||||
"clip" -> {
|
||||
val e = entry(msg.getJSONObject("clip"), a) ?: return
|
||||
if (history.any { it.id == e.id }) return
|
||||
history.add(0, e)
|
||||
if (!e.own && cfg.autoCopy) {
|
||||
if (e.size <= cfg.maxAutoMb * 1024L * 1024L) fetch(e, Action.AUTO)
|
||||
else Notifier.clip(app, e, "Gross (${Payload.human(e.size)}) – zum Laden antippen")
|
||||
}
|
||||
}
|
||||
"deleted" -> history.removeAll { it.id == msg.optString("id") }
|
||||
"cleared" -> history.clear()
|
||||
"devices" -> {
|
||||
val list = msg.getJSONArray("devices")
|
||||
devices = (0 until list.length()).map { list.getJSONObject(it) }
|
||||
.filter { it.optString("device") != cfg.deviceId }
|
||||
.map { a.keys.decryptMeta(it.optString("label"))?.optString("name") ?: it.optString("device") }
|
||||
}
|
||||
}
|
||||
changed()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Senden
|
||||
fun send(content: Outgoing, onDone: ((String?) -> Unit)? = null) {
|
||||
io.execute {
|
||||
val plain = File.createTempFile("netclip", ".plain", app.cacheDir)
|
||||
val enc = File.createTempFile("netclip", ".enc", app.cacheDir)
|
||||
var error: String? = null
|
||||
try {
|
||||
setProgress("Packe …")
|
||||
val a = api()
|
||||
val meta = Payload.build(app, content, cfg.deviceName, plain)
|
||||
plain.inputStream().buffered().use { i -> enc.outputStream().buffered().use { o -> a.keys.encryptStream(i, o) } }
|
||||
plain.delete()
|
||||
a.upload(meta, enc, progressCb("Sende"))
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: e.toString()
|
||||
} finally {
|
||||
plain.delete()
|
||||
enc.delete()
|
||||
setProgress(null)
|
||||
}
|
||||
main.post {
|
||||
onDone?.invoke(error)
|
||||
toast(if (error == null) "Gesendet" else "Senden fehlgeschlagen: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Empfangen
|
||||
fun fetch(e: Entry, action: Action) {
|
||||
val inline = e.inlineText
|
||||
if (inline != null && (action == Action.COPY || action == Action.AUTO)) {
|
||||
copyText(inline, e, action == Action.AUTO)
|
||||
return
|
||||
}
|
||||
io.execute {
|
||||
try {
|
||||
val result = materialize(e)
|
||||
main.post { deliver(e, result, action) }
|
||||
} catch (ex: Exception) {
|
||||
main.post { toast("Laden fehlgeschlagen: ${ex.message}") }
|
||||
} finally {
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Laedt und entschluesselt einen Clip; Ergebnis: String (Text) oder File (Bild bzw. Ordner mit Dateien). */
|
||||
private fun materialize(e: Entry): Any {
|
||||
val dir = Payload.received(app)
|
||||
val target = File(dir, if (e.kind == "image") "${e.id}.png" else e.id)
|
||||
if (e.kind != "text" && target.exists()) return target
|
||||
val a = api()
|
||||
val enc = File.createTempFile("netclip", ".enc", app.cacheDir)
|
||||
val plain = File.createTempFile("netclip", ".plain", app.cacheDir)
|
||||
try {
|
||||
a.download(e.id, enc, progressCb("Lade von ${e.device}"))
|
||||
enc.inputStream().buffered().use { i -> plain.outputStream().buffered().use { o -> a.keys.decryptStream(i, o) } }
|
||||
return when (e.kind) {
|
||||
"text" -> plain.readText(Charsets.UTF_8)
|
||||
"image" -> { plain.renameTo(target) || plain.copyTo(target, true).exists(); target }
|
||||
else -> { Payload.unzip(plain, target); target }
|
||||
}
|
||||
} finally {
|
||||
enc.delete()
|
||||
plain.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deliver(e: Entry, result: Any, action: Action) {
|
||||
val clipboard = app.getSystemService(ClipboardManager::class.java)
|
||||
if (result is String) {
|
||||
when (action) {
|
||||
Action.SHARE -> startShare(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, result))
|
||||
Action.SAVE -> {
|
||||
val f = File(Payload.received(app), "netclip-${e.id.take(8)}.txt").apply { writeText(result) }
|
||||
save(f)
|
||||
}
|
||||
else -> copyText(result, e, action == Action.AUTO)
|
||||
}
|
||||
return
|
||||
}
|
||||
val file = result as File
|
||||
val files = if (file.isDirectory) Payload.listFiles(file) else listOf(file)
|
||||
val uris = files.map { uriFor(it) }
|
||||
when (action) {
|
||||
Action.SAVE -> if (file.isDirectory) file.listFiles()?.forEach { save(it) } else save(file)
|
||||
Action.SHARE -> {
|
||||
val intent = if (uris.size == 1) Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
|
||||
else Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
|
||||
intent.setType(if (e.kind == "image") "image/png" else "*/*")
|
||||
startShare(intent)
|
||||
}
|
||||
Action.COPY, Action.AUTO -> {
|
||||
// Bilder bzw. Dateien als content://-URIs in die Zwischenablage (Einfuegen in Chats, Mail usw.)
|
||||
val clip = ClipData.newUri(app.contentResolver, e.title, uris[0])
|
||||
uris.drop(1).forEach { clip.addItem(ClipData.Item(it)) }
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (action == Action.AUTO) Notifier.clip(app, e, if (e.kind == "image") "Bild in der Zwischenablage" else "Dateien bereit – Speichern oder Teilen")
|
||||
else toast("In die Zwischenablage kopiert")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyText(text: String, e: Entry, auto: Boolean) {
|
||||
app.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("NetClip", text))
|
||||
if (auto) Notifier.clip(app, e, e.title) else toast("In die Zwischenablage kopiert")
|
||||
}
|
||||
|
||||
private fun save(f: File) {
|
||||
io.execute {
|
||||
try {
|
||||
Payload.saveToDownloads(app, f)
|
||||
main.post { toast("Gespeichert unter Download/NetClip") }
|
||||
} catch (ex: Exception) {
|
||||
main.post { toast("Speichern fehlgeschlagen: ${ex.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uriFor(f: File): Uri = FileProvider.getUriForFile(app, "${app.packageName}.files", f)
|
||||
|
||||
private fun startShare(intent: Intent) {
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
app.startActivity(Intent.createChooser(intent, "Teilen").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Verwaltung
|
||||
fun delete(e: Entry) = io.execute { runCatching { api().delete(e.id) }.onFailure { main.post { toast(it.message ?: "Fehler") } } }
|
||||
fun clear() = io.execute { runCatching { api().clear() }.onFailure { main.post { toast(it.message ?: "Fehler") } } }
|
||||
|
||||
fun cleanupCache(days: Int = 7) = io.execute {
|
||||
val cutoff = System.currentTimeMillis() - days * 86_400_000L
|
||||
Payload.received(app).listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
private fun setProgress(text: String?) {
|
||||
progress = text
|
||||
changed()
|
||||
}
|
||||
|
||||
private fun progressCb(label: String): Progress {
|
||||
var last = 0L
|
||||
return { done, total ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - last > 300 || done == total) {
|
||||
last = now
|
||||
setProgress(if (total > 0) "$label … ${Payload.human(done)} / ${Payload.human(total)}" else "$label …")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toast(text: String) = Toast.makeText(app, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package de.netclip.app
|
||||
|
||||
import android.app.Application
|
||||
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.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class NetClipApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Repo.init(this)
|
||||
Notifier.createChannels(this)
|
||||
Repo.cleanupCache()
|
||||
}
|
||||
}
|
||||
|
||||
object Notifier {
|
||||
const val CH_SYNC = "sync"
|
||||
const val CH_CLIPS = "clips"
|
||||
const val ID_SERVICE = 1
|
||||
|
||||
fun createChannels(ctx: Context) {
|
||||
val nm = ctx.getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(NotificationChannel(CH_SYNC, "Verbindung", NotificationManager.IMPORTANCE_MIN)
|
||||
.apply { description = "Dauerhafte Anzeige, solange NetClip verbunden ist" })
|
||||
nm.createNotificationChannel(NotificationChannel(CH_CLIPS, "Empfangene Clips", NotificationManager.IMPORTANCE_DEFAULT))
|
||||
}
|
||||
|
||||
private fun openApp(ctx: Context, requestCode: Int = 0, extras: Intent.() -> Unit = {}): PendingIntent =
|
||||
PendingIntent.getActivity(ctx, requestCode, Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
extras()
|
||||
}, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
|
||||
fun service(ctx: Context): Notification {
|
||||
val text = when {
|
||||
Repo.connected -> if (Repo.devices.isEmpty()) "Verbunden" else "Verbunden mit ${Repo.devices.joinToString(", ")}"
|
||||
else -> "Getrennt" + if (Repo.lastError.isNotEmpty()) ": ${Repo.lastError}" else ""
|
||||
}
|
||||
val send = PendingIntent.getActivity(ctx, 1, Intent(ctx, SendClipboardActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), PendingIntent.FLAG_IMMUTABLE)
|
||||
return NotificationCompat.Builder(ctx, CH_SYNC)
|
||||
.setSmallIcon(R.drawable.ic_stat)
|
||||
.setContentTitle("NetClip")
|
||||
.setContentText(text)
|
||||
.setContentIntent(openApp(ctx))
|
||||
.addAction(0, "Zwischenablage senden", send)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun clip(ctx: Context, e: Entry, text: String) {
|
||||
if (!Repo.cfg.notifications) return
|
||||
val b = NotificationCompat.Builder(ctx, CH_CLIPS)
|
||||
.setSmallIcon(R.drawable.ic_stat)
|
||||
.setContentTitle("Von ${e.device}")
|
||||
.setContentText(text)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openApp(ctx, e.id.hashCode()))
|
||||
if (e.kind != "text") {
|
||||
val save = PendingIntent.getBroadcast(ctx, e.id.hashCode() + 1,
|
||||
Intent(ctx, ActionReceiver::class.java).putExtra("id", e.id).putExtra("action", Action.SAVE.name),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
b.addAction(0, "Speichern", save)
|
||||
b.addAction(0, "Teilen", openApp(ctx, e.id.hashCode() + 2) {
|
||||
putExtra("id", e.id); putExtra("action", Action.SHARE.name)
|
||||
})
|
||||
}
|
||||
ctx.getSystemService(NotificationManager::class.java).notify(e.id.hashCode(), b.build())
|
||||
}
|
||||
}
|
||||
|
||||
/** Haelt die WebSocket-Verbindung im Hintergrund offen. */
|
||||
class SyncService : Service() {
|
||||
private val listener: () -> Unit = {
|
||||
getSystemService(NotificationManager::class.java).notify(Notifier.ID_SERVICE, Notifier.service(this))
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0
|
||||
ServiceCompat.startForeground(this, Notifier.ID_SERVICE, Notifier.service(this), type)
|
||||
Repo.addListener(listener)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (!Repo.connected) Repo.connect()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Repo.removeListener(listener)
|
||||
Repo.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "stop"
|
||||
fun start(ctx: Context) = ContextCompat.startForegroundService(ctx, Intent(ctx, SyncService::class.java))
|
||||
fun restart(ctx: Context) {
|
||||
Repo.connect()
|
||||
start(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED && Repo.cfg.configured) SyncService.start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/** Benachrichtigungs-Aktion "Speichern" (ohne die App zu oeffnen). */
|
||||
class ActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val e = Repo.history.firstOrNull { it.id == intent.getStringExtra("id") } ?: return
|
||||
Repo.fetch(e, Action.valueOf(intent.getStringExtra("action") ?: return))
|
||||
}
|
||||
}
|
||||
|
||||
/** Schnelleinstellungs-Kachel: aktuelle Zwischenablage senden. */
|
||||
class ClipTileService : TileService() {
|
||||
override fun onClick() {
|
||||
val intent = Intent(this, SendClipboardActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@android.annotation.SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<group android:translateX="30" android:translateY="30">
|
||||
<path android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M38,4h-8.36C28.8,1.68 26.6,0 24,0S19.2,1.68 18.36,4H10C7.8,4 6,5.8 6,8v32c0,2.2 1.8,4 4,4h28c2.2,0 4,-1.8 4,-4V8C42,5.8 40.2,4 38,4zM24,4c1.1,0 2,0.9 2,2s-0.9,2 -2,2 -2,-0.9 -2,-2 0.9,-2 2,-2zM38,40H10V8h4v6h20V8h4v32z" />
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M15,20h18v3H15zM15,26h18v3H15zM15,32h12v3H15z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M19,2h-4.18C14.4,0.84 13.3,0 12,0S9.6,0.84 9.18,2H5C3.9,2 3,2.9 3,4v16c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V4C21,2.9 20.1,2 19,2zM12,2c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM19,20H5V4h2v3h10V4h2v16z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8l-6,-6zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/progressText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/send"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Zwischenablage senden" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="Noch nichts im Verlauf.\n\nAuf dem PC etwas kopieren oder hier über „Teilen → NetClip“ senden." />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/scan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="QR-Code vom PC scannen" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/importFile"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=".netclip-Datei importieren" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="Server-URL (https://clip.example.de)">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/server"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Token"
|
||||
app:endIconMode="password_toggle"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/token"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Server-Schlüssel (optional)"
|
||||
app:endIconMode="password_toggle"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/serverKey"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/verifyTls"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TLS-Zertifikat prüfen (aus = selbstsigniert)" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Gerätename">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/deviceName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/autoCopy"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Empfangenes automatisch in die Zwischenablage" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Automatisch laden bis (MB)">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/maxAuto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/notifications"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Benachrichtigungen bei neuen Clips" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/save"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Speichern und verbinden" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:tint="?android:attr/textColorSecondary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:id="@+id/settings" android:title="Einstellungen" />
|
||||
<item android:id="@+id/clear" android:title="Verlauf leeren" />
|
||||
<item android:id="@+id/quit" android:title="Beenden (Verbindung trennen)" />
|
||||
</menu>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/launcher_bg" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="launcher_bg">#3B6EA8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">NetClip</string>
|
||||
<string name="send_label">An NetClip senden</string>
|
||||
<string name="tile_label">Zwischenablage senden</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<resources>
|
||||
<style name="Theme.NetClip" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<item name="colorPrimary">#3B6EA8</item>
|
||||
<item name="colorPrimaryVariant">#2C5584</item>
|
||||
<item name="colorSecondary">#2FA84F</item>
|
||||
</style>
|
||||
<style name="Theme.NetClip.Invisible" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
<item name="android:windowAnimationStyle">@null</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="received" path="received/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.netclip.app
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Prueft die Kompatibilitaet mit dem Python-Client. Testdaten erzeugt
|
||||
* tools/interop.py gen <dir>; danach: ./gradlew test -Pinterop=<dir>; tools/interop.py check <dir>
|
||||
*/
|
||||
class CryptoInteropTest {
|
||||
private val dir = System.getProperty("interop")?.let { File(it) }
|
||||
|
||||
private fun sha(f: File) = MessageDigest.getInstance("SHA-256").digest(f.readBytes()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Test
|
||||
fun roundtrip() {
|
||||
val keys = Keys("roundtrip")
|
||||
val src = File.createTempFile("plain", ".bin").apply { writeBytes(ByteArray(3 * Keys.CHUNK + 5) { it.toByte() }) }
|
||||
val enc = File.createTempFile("enc", ".bin")
|
||||
val out = File.createTempFile("out", ".bin")
|
||||
src.inputStream().use { i -> enc.outputStream().use { keys.encryptStream(i, it) } }
|
||||
enc.inputStream().use { i -> out.outputStream().use { keys.decryptStream(i, it) } }
|
||||
assertEquals(sha(src), sha(out))
|
||||
assertNull(Keys("falsch").decryptMeta(keys.encryptMeta(JSONObject().put("a", 1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pythonInterop() {
|
||||
assumeTrue(dir != null)
|
||||
val vectors = JSONObject(File(dir, "vectors.json").readText())
|
||||
val keys = Keys(vectors.getString("token"))
|
||||
assertEquals(vectors.getString("room_id"), keys.roomId)
|
||||
assertEquals("Grüße aus Python", keys.decryptMeta(vectors.getString("meta"))!!.getString("preview"))
|
||||
// Blob von Python entschluesseln
|
||||
val plain = File(dir, "from_python.plain")
|
||||
File(dir, "from_python.enc").inputStream().use { i -> plain.outputStream().use { keys.decryptStream(i, it) } }
|
||||
assertEquals(vectors.getString("sha256"), sha(plain))
|
||||
// Zip von Python entpacken
|
||||
val target = File(dir, "unzipped")
|
||||
target.deleteRecursively()
|
||||
Payload.unzip(File(dir, "from_python.zip"), target)
|
||||
assertEquals("inhalt", File(target, "Ordner/sub/datei.txt").readText())
|
||||
// Fuer Python verschluesseln
|
||||
File(dir, "from_kotlin.meta").writeText(keys.encryptMeta(JSONObject().put("preview", "Hallo von Kotlin äöü")))
|
||||
File(dir, "from_python.plain").inputStream().use { i ->
|
||||
File(dir, "from_kotlin.enc").outputStream().use { keys.encryptStream(i, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.7.3' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "NetClip"
|
||||
include ':app'
|
||||
Reference in New Issue
Block a user