2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-31 22:25:08 +00:00

Compare commits

..

69 Commits

Author SHA1 Message Date
Albert Vaca Cintora
eb6784b626 Release 1.32.1 2024-08-19 01:08:16 +02:00
Albert Vaca Cintora
1beb8d4581 Bump deps 2024-08-18 23:59:04 +02:00
Albert Vaca Cintora
16067d7523 Fix crash 2024-08-18 23:59:04 +02:00
TPJ Schikhof
dea0bb4e1f Migrate trusted network helper to Kotlin 2024-08-18 21:58:07 +00:00
TPJ Schikhof
c9fb81363d Migrate video urls helper to Kotlin
- Migrated code to Kotlin
- Fixed crash
- Added unit tests
2024-08-18 21:54:42 +00:00
l10n daemon script
35e8ea0c4c GIT_SILENT made messages (after extraction) 2024-08-17 01:44:44 +00:00
l10n daemon script
7c5c7933c9 GIT_SILENT made messages (after extraction) 2024-08-16 01:47:25 +00:00
l10n daemon script
0dfa44aeac GIT_SILENT made messages (after extraction) 2024-08-15 01:51:43 +00:00
l10n daemon script
dd527f661c GIT_SILENT made messages (after extraction) 2024-08-14 01:47:34 +00:00
Qaz Cetelic
ed89fb43ed Migrate safe text checker to Kotlin
Functionality is identical and it passes all unit tests
2024-08-12 12:23:12 +00:00
Albert Vaca Cintora
aed2b64416 Release 1.32.0 2024-08-10 14:07:49 +02:00
Albert Vaca Cintora
4bdbb8f74a Bump deps 2024-08-10 14:05:00 +02:00
l10n daemon script
155ebf4fb2 GIT_SILENT made messages (after extraction) 2024-08-08 01:49:26 +00:00
Albert Vaca Cintora
46ad0c62ba Fix typo that caused certs to last 9 years instead of 10 2024-08-06 01:20:02 +02:00
ShellWen Chen
df0f2d651c feat(sftp): notify MediaStore on file changes
This commit adds functionality to notify the MediaStore when files are modified via SFTP. This ensures that
 changes made through SFTP are reflected in the Android media library.

Specifically, the MediaStore is notified after file creation, deletion, copying, renaming, and link creation. Additionally, it is notified after closing a file that was opened for writing. This ensures that the MediaStore is kept up-to-date
 with any changes made to files through SFTP.
2024-08-05 18:44:49 +08:00
l10n daemon script
167e2c7176 GIT_SILENT made messages (after extraction) 2024-08-04 01:50:29 +00:00
Albert Vaca Cintora
906326f837 Fix crashes 2024-08-04 00:16:48 +02:00
l10n daemon script
3c9c49fa87 GIT_SILENT made messages (after extraction) 2024-08-03 01:51:09 +00:00
l10n daemon script
24a6beb600 GIT_SILENT made messages (after extraction) 2024-08-02 01:52:00 +00:00
Albert Vaca Cintora
0775a45316 Release 1.32.0 Beta
Only meant for the Play Store (because of the automatic crash reports),
so no fastlane update and no tag.
2024-07-31 12:43:45 +02:00
ShellWen Chen
90dbdee282 chore: disable SSHD logging due to performance degradation and being very noisy even in development 2024-07-31 10:40:00 +00:00
ShellWen Chen
7d28c52c35 fix: now call stop() of sftp server will do nothing when sshd hasn't been initialized 2024-07-31 10:40:00 +00:00
ShellWen Chen
7686e012c3 feat: add custom author description for ShellWen Chen 2024-07-31 10:40:00 +00:00
ShellWen Chen
2cb9666678 fix: remove "set last modified" codes because it will always failed as SAF doesn't allow it. 2024-07-31 10:40:00 +00:00
ShellWen Chen
0ab4e0d1d2 revert: restore codes that have been changed by mistake 2024-07-31 10:40:00 +00:00
ShellWen Chen
30cc95713f chore: add some comments about FixPosixFilePermissionClassVisitorFactory 2024-07-31 10:40:00 +00:00
ShellWen Chen
ae49aa6456 fix: fix logics in newFileChannel() 2024-07-31 10:40:00 +00:00
ShellWen Chen
89454fcba9 fix: add calls to MediaStoreHelper.indexFile() 2024-07-31 10:40:00 +00:00
ShellWen Chen
5a6453729c refactor: rewrite newFileChannel logics 2024-07-31 10:40:00 +00:00
ShellWen Chen
0706ec1a0b refactor: clean up codes 2024-07-31 10:40:00 +00:00
ShellWen Chen
405e828683 refactor: make changes to SimpleSftpServer 2024-07-31 10:40:00 +00:00
ShellWen Chen
34a78e635e pref: reduce reflection calls 2024-07-31 10:40:00 +00:00
ShellWen Chen
c327c15825 chore: remove outdated comment 2024-07-31 10:40:00 +00:00
ShellWen Chen
6d027ae810 chore: add comments to explain why call PathUtils.setUserHomeFolderResolver() 2024-07-31 10:40:00 +00:00
ShellWen Chen
104013c916 fix: make SafFileSystem not closable 2024-07-31 10:40:00 +00:00
ShellWen Chen
8df1f04141 fix: make IDEA happy 2024-07-31 10:40:00 +00:00
ShellWen Chen
beab3599bf chore: remove dead codes 2024-07-31 10:40:00 +00:00
ShellWen Chen
819d3ea158 chore: fix a mistake in comment 2024-07-31 10:40:00 +00:00
ShellWen Chen
83fd2440ce chore: remove outdated comment 2024-07-31 10:40:00 +00:00
ShellWen Chen
e13451061f pref: Use Java NIO2 impl on Android >= 8.0 2024-07-31 10:40:00 +00:00
ShellWen Chen
e82c0fea84 fix: fix SAF issues before Android 7.0 (SDK Level < 24) 2024-07-31 10:40:00 +00:00
ShellWen Chen
e391750e0e chore: bump sshd core to 2.13.1 2024-07-31 10:40:00 +00:00
ShellWen Chen
6513bb1320 chore: use default signing and key exchange algorithms 2024-07-31 10:40:00 +00:00
ShellWen Chen
0fb6e25682 feat: add SAF support 2024-07-31 10:40:00 +00:00
ShellWen Chen
7fbfc9df90 fix: remove sshd instance when start second time due to sshd can't be start after stop 2024-07-31 10:40:00 +00:00
ShellWen Chen
cd8237d773 fix: use Apache MINA as IO Service of SSHD Core to fix issues when Android SDK < 26 (Android 8.0)
NIO2 is the default IO Service of SSHD Core. But when Android SDK < 26, NIO2 doesn't exists. So we have to use Apache MINA as IO Service to fix this issue.
2024-07-31 10:40:00 +00:00
ShellWen Chen
aaa750bbc6 chore: replace calls to Collections with backport impls instead when Android SDK < 26 (Android 8.0) 2024-07-31 10:40:00 +00:00
ShellWen Chen
3d54da75cc chore: bump mina core to 2.2.3 2024-07-31 10:40:00 +00:00
ShellWen Chen
358584ba6f chore: bump sshd core to 2.12.1
chore: bump sshd core to `2.10.0`

chore: bump sshd core to `2.8.0`

chore: bump sshd core to `2.6.0`

chore: bump sshd core to `2.4.0`

chore: bump sshd core to `2.3.0`

chore: bump sshd core to `2.2.0`

chore: bump sshd core to `2.1.0`

chore: bump sshd core to `2.0.0`

chore: bump sshd core to `1.7.0`
2024-07-31 10:40:00 +00:00
ShellWen Chen
adfab5f0f3 fix: fix a class type cast error by modify bytecode 2024-07-31 10:40:00 +00:00
ShellWen Chen
e37a519e3a refactor: migrate SSHD Core to 1.0.0. SAF is unavailable now. 2024-07-31 10:40:00 +00:00
ShellWen Chen
6783f0a167 refactor: migrate classes to Kotlin
refactor: migrate `AndroidSshFile` to Kotlin
refactor: migrate `DHG14_256` to Kotlin
refactor: migrate `RootFile` to Kotlin
refactor: migrate `SftpPlugin` to Kotlin
refactor: migrate `SignatureRSASHA256` to Kotlin
refactor: migrate `SimpleSftpServer` to Kotlin
refactor: migrate `StoragePreference` to Kotlin
refactor: migrate `StoragePreferenceDialogFragment` to Kotlin
2024-07-31 10:40:00 +00:00
l10n daemon script
6d78fe749a GIT_SILENT Sync po/docbooks with svn 2024-07-30 02:23:25 +00:00
l10n daemon script
2120c7967e GIT_SILENT Sync po/docbooks with svn 2024-07-29 02:22:45 +00:00
l10n daemon script
de861ce781 GIT_SILENT Add new file (after extraction) 2024-07-29 01:50:25 +00:00
l10n daemon script
e222937736 GIT_SILENT made messages (after extraction) 2024-07-26 01:44:21 +00:00
Krut Patel
e289811097 mpris-receiver: Send album art
Implementation of sending album art from phone to PC.

Complementary MR for the PC-side: https://invent.kde.org/network/kdeconnect-kde/-/merge_requests/541

Fixes https://bugs.kde.org/show_bug.cgi?id=422136
2024-07-22 20:51:08 +00:00
l10n daemon script
067a000b2b GIT_SILENT made messages (after extraction) 2024-07-14 01:42:40 +00:00
l10n daemon script
f9d05824a7 GIT_SILENT Sync po/docbooks with svn 2024-07-11 02:17:19 +00:00
l10n daemon script
d753f1eea4 GIT_SILENT Add new file (after extraction) 2024-07-11 01:43:55 +00:00
l10n daemon script
3c81b527eb GIT_SILENT made messages (after extraction) 2024-07-08 01:44:35 +00:00
l10n daemon script
96147bf6df GIT_SILENT made messages (after extraction) 2024-07-07 01:45:40 +00:00
l10n daemon script
8b33ce64a4 GIT_SILENT made messages (after extraction) 2024-07-06 01:47:59 +00:00
l10n daemon script
73fdd4b47e GIT_SILENT Sync po/docbooks with svn 2024-06-27 02:22:33 +00:00
l10n daemon script
680e404d05 GIT_SILENT made messages (after extraction) 2024-06-22 01:53:10 +00:00
l10n daemon script
aae6f1a7e9 GIT_SILENT made messages (after extraction) 2024-06-20 01:49:27 +00:00
Albert Vaca Cintora
5cda1ceb0c Catch a NPE that I'm not sure how to avoid 2024-06-13 12:50:09 +02:00
Albert Vaca Cintora
7ed4efedc3 Fix SecurityException if the notifications permission was revoked 2024-06-13 12:07:06 +02:00
Midori Kochiya
96ecd620cf Add support for Direct Share targets
As described in https://developer.android.com/training/sharing/direct-share-targets.

This makes connected devices with `SharePlugin` enabled show
up in Android's Sharesheet and can be directly shared to.
2024-06-11 14:01:03 +00:00
89 changed files with 3916 additions and 2460 deletions

View File

@@ -8,8 +8,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:versionCode="13101"
android:versionName="1.31.1">
android:versionCode="13201"
android:versionName="1.32.1">
<uses-feature
android:name="android.hardware.telephony"
@@ -120,6 +120,9 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name="org.kde.kdeconnect.UserInterface.PluginSettingsActivity"

View File

@@ -1,6 +1,15 @@
import com.android.build.api.instrumentation.AsmClassVisitorFactory
import com.android.build.api.instrumentation.ClassContext
import com.android.build.api.instrumentation.ClassData
import com.android.build.api.instrumentation.InstrumentationParameters
import com.android.build.api.instrumentation.InstrumentationScope
import com.github.jk1.license.LicenseReportExtension
import com.github.jk1.license.render.ReportRenderer
import com.github.jk1.license.render.TextReportRenderer
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes.CHECKCAST
import org.objectweb.asm.Opcodes.INVOKESTATIC
buildscript {
dependencies {
@@ -131,8 +140,139 @@ android {
}
}
/**
* Fix PosixFilePermission class type check issue.
*
* It fixed the class cast exception when lib desugar enabled and minSdk < 26.
*/
abstract class FixPosixFilePermissionClassVisitorFactory :
AsmClassVisitorFactory<FixPosixFilePermissionClassVisitorFactory.Params> {
override fun createClassVisitor(
classContext: ClassContext,
nextClassVisitor: ClassVisitor
): ClassVisitor {
return object : ClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) {
override fun visitMethod(
access: Int,
name: String?,
descriptor: String?,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
if (name == "attributesToPermissions") { // org.apache.sshd.sftp.common.SftpHelper.attributesToPermissions
return object : MethodVisitor(
instrumentationContext.apiVersion.get(),
super.visitMethod(access, name, descriptor, signature, exceptions)
) {
override fun visitTypeInsn(opcode: Int, type: String?) {
// We need to prevent Android Desugar modifying the `PosixFilePermission` classname.
//
// Android Desugar will replace `CHECKCAST java/nio/file/attribute/PosixFilePermission`
// to `CHECKCAST j$/nio/file/attribute/PosixFilePermission`.
// We need to replace it with `CHECKCAST java/lang/Enum` to prevent Android Desugar from modifying it.
if (opcode == CHECKCAST && type == "java/nio/file/attribute/PosixFilePermission") {
println("Bypass PosixFilePermission type check success.")
// `Enum` is the superclass of `PosixFilePermission`.
// Due to `Object` is not the superclass of `Enum`, we need to use `Enum` instead of `Object`.
super.visitTypeInsn(opcode, "java/lang/Enum")
} else {
super.visitTypeInsn(opcode, type)
}
}
}
}
return super.visitMethod(access, name, descriptor, signature, exceptions)
}
}
}
override fun isInstrumentable(classData: ClassData): Boolean {
return (classData.className == "org.apache.sshd.sftp.common.SftpHelper").also {
if (it) println("SftpHelper Found! Instrumenting...")
}
}
interface Params : InstrumentationParameters
}
/**
* Collections.unmodifiableXXX is not exist when Android API level is lower than 26.
* So we replace the call to Collections.unmodifiableXXX with the original collection by removing the call.
*/
abstract class FixCollectionsClassVisitorFactory :
AsmClassVisitorFactory<FixCollectionsClassVisitorFactory.Params> {
override fun createClassVisitor(
classContext: ClassContext,
nextClassVisitor: ClassVisitor
): ClassVisitor {
return object : ClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) {
override fun visitMethod(
access: Int,
name: String?,
descriptor: String?,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
return object : MethodVisitor(
instrumentationContext.apiVersion.get(),
super.visitMethod(access, name, descriptor, signature, exceptions)
) {
override fun visitMethodInsn(
opcode: Int,
type: String?,
name: String?,
descriptor: String?,
isInterface: Boolean
) {
val backportClass = "org/kde/kdeconnect/Helpers/CollectionsBackport"
if (opcode == INVOKESTATIC && type == "java/util/Collections") {
val replaceRules = mapOf(
"unmodifiableNavigableSet" to "(Ljava/util/NavigableSet;)Ljava/util/NavigableSet;",
"unmodifiableSet" to "(Ljava/util/Set;)Ljava/util/Set;",
"unmodifiableNavigableMap" to "(Ljava/util/NavigableMap;)Ljava/util/NavigableMap;",
"emptyNavigableMap" to "()Ljava/util/NavigableMap;")
if (name in replaceRules && descriptor == replaceRules[name]) {
super.visitMethodInsn(opcode, backportClass, name, descriptor, isInterface)
val calleeClass = classContext.currentClassData.className
println("Replace Collections.$name call with CollectionsBackport.$name from $calleeClass success.")
return
}
}
super.visitMethodInsn(opcode, type, name, descriptor, isInterface)
}
}
}
}
}
override fun isInstrumentable(classData: ClassData): Boolean {
return classData.className.startsWith("org.apache.sshd") // We only need to fix the Apache SSHD library
}
interface Params : InstrumentationParameters
}
androidComponents {
onVariants { variant ->
variant.instrumentation.transformClassesWith(
FixPosixFilePermissionClassVisitorFactory::class.java,
InstrumentationScope.ALL
) { }
variant.instrumentation.transformClassesWith(
FixCollectionsClassVisitorFactory::class.java,
InstrumentationScope.ALL
) { }
}
}
dependencies {
coreLibraryDesugaring(libs.android.desugarJdkLibs)
// It has a bug that causes a crash when using PosixFilePermission and minSdk < 26.
// It has been used in SSHD Core.
// We have taken a workaround to fix it.
// See `FixPosixFilePermissionClassVisitorFactory` for more details.
coreLibraryDesugaring(libs.android.desugarJdkLibsNio)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
@@ -159,7 +299,10 @@ dependencies {
implementation(libs.slf4j.handroid)
implementation(libs.apache.sshd.core)
implementation(libs.apache.mina.core) //For some reason, makes sshd-core:0.14.0 work without NIO, which isn't available until Android 8 (api 26)
implementation(libs.apache.sshd.sftp)
implementation(libs.apache.sshd.scp)
implementation(libs.apache.sshd.mina)
implementation(libs.apache.mina.core)
//implementation("com.github.bright:slf4android:0.1.6") { transitive = true } // For org.apache.sshd debugging
implementation(libs.bcpkix.jdk15on) //For SSL certificate generation

View File

@@ -0,0 +1,21 @@
KDE Connect предоставя набор от функции за интегриране на вашия работен процес на различни устройства:
- Прехвърляйте файлове между вашите устройства.
- Осъществявайте достъп до файлове на телефона си от компютъра си, без кабели.
- Споделен клипборд: копирайте и поставяйте между вашите устройства.
- Получавайте известия за входящи обаждания и съобщения на вашия компютър.
- Виртуален тъчпад: Използвайте екрана на телефона си като тъчпад на компютъра.
- Синхронизиране на известия: Достъп до известията на телефона ви от вашия компютър и отговаряне на съобщения.
- Мултимедийно дистанционно управление: Използвайте телефона си като дистанционно за Linux медийни плейъри.
- WiFi връзка: не е необходим USB кабел или bluetooth.
- TLS криптиране от край до край: информацията ви е в безопасност.
Моля, имайте предвид, че ще трябва да инсталирате KDE Connect на вашия компютър, за да работи това приложение, и поддържайте версията за настолен компютър актуална с версията за Android, за да работят най-новите функции.
Поверителна информация за разрешения:
* Разрешение за достъпност: Изисква се за получаване на вход от друго устройство за управление на вашия телефон с Android, ако използвате функцията за отдалечено въвеждане.
* Разрешение за местоположение във фонов режим: Изисква се, за да знаете към коя WiFi мрежа сте свързани, ако използвате функцията Trusted Networks.
KDE Connect никога не изпраща никаква информация на KDE или на трета страна. KDE Connect изпраща данни от едно устройство на друго директно чрез локалната мрежа, никога през интернет, и чрез криптиране от край до край.
Това приложение е част от проект с отворен код и съществува благодарение на всички хора, които са допринесли за него. Посетете уебсайта, за да вземете изходния код.

View File

@@ -0,0 +1 @@
KDE Connect интегрира вашия смартфон и компютър

View File

@@ -0,0 +1 @@
KDE Connect

View File

@@ -0,0 +1,10 @@
1.32
* Rewrite the remote file browsing
* Add Direct Share targets
* Send album art from phone to PC
1.31
* Allow sharing URLs to disconnected devices, to be opened when they become available later
* Show a notification to continue playing media on this device after stopping it on another device
* Display a shortened version of the pairing verification key
* Tweaks to the app theme

View File

@@ -0,0 +1,13 @@
1.32.1
* Fixed a crash when opening the presentation remote
1.32
* Rewrite the remote file browsing
* Add Direct Share targets
* Send album art from phone to PC
1.31
* Allow sharing URLs to disconnected devices, to be opened when they become available later
* Show a notification to continue playing media on this device after stopping it on another device
* Display a shortened version of the pairing verification key
* Tweaks to the app theme

View File

@@ -0,0 +1,21 @@
KDE Connect tilbyr eit sett funksjonar som lèt deg enkelt arbeida på tvers av einingar:
Overfør filer mellom einingane
Få trådlaus tilgang til filer på telefonen frå datamaskina
Del utklippsbilete: kopier og lim inn mellom einingane
Vert varsla på datamaskina om innkommande samtalar og tekstmeldingar
Virtuell styreplate: bruk telefon­skjermen som styreplate for datamaskina
Synkronisering av varslingar: få tilgang til telefon­varslingar frå datamaskina og svar på meldingar
Fjernkontroll av medieavspeling: bruk telefonen til å styra Linux-baserte mediespelarar
Wi-Fi-tilkopling: du treng ikkje USB- eller Bluetooth-tilkopling
Ende-til-ende-kryptering: informasjonen din er trygg
Merk at du må installera KDE Connect på datamaskina for å kunna bruka appen. Hugs å halda PC-versjonen oppdatert med Android-versjonen for tilgang til dei nyaste funksjonane.
Informasjon om sensitive løyve:
Tilgjenge-løyve: Trengst for å kunna ta imot tastetrykk frå PC for å styra Android-eininga om du brukar funksjonen «Fjernstyring»
Bakgrunns­løyve til å sjå geografiske posisjon: Trengst for å veta kva Wi-Fi-nettverk du er tilkopla om du brukar funksjonen «Tiltrudde nettverk»
KDE Connect sender aldri informasjon til KDE eller nokon tredjepart. Programmet sender data direkte mellom dei to einingane via lokalnettet, aldri via Internett og alltid med ende-til-ende-kryptering.
Appen er ein del av eit fri programvare-prosjekt og er blitt til takka vera mange bidragsytarar. Gå til heimesida for å sjå kjeldekoden.

View File

@@ -0,0 +1 @@
KDE Connect koplar telefonen din saman med datamaskina

View File

@@ -0,0 +1 @@
KDE Connect

View File

@@ -1,44 +1,44 @@
[versions]
activityCompose = "1.9.0"
activityCompose = "1.9.1"
androidDesugarJdkLibs = "2.0.4"
androidGradlePlugin = "8.4.1"
androidGradlePlugin = "8.5.2"
androidSmsmms = "kdeconnect-1-21-0"
appcompat = "1.7.0"
bcpkixJdk15on = "1.70"
classindex = "3.13"
commonsCollections4 = "4.4"
commonsIo = "2.16.1"
commonsLang3 = "3.14.0"
commonsLang3 = "3.16.0"
constraintlayoutCompose = "1.0.1"
coreKtx = "1.13.1"
dependencyLicenseReport = "2.7"
disklrucache = "2.0.2"
documentfile = "1.0.1"
gridlayout = "1.0.0"
jsonassert = "1.5.1"
jsonassert = "1.5.3"
junit = "4.13.2"
kotlin = "2.0.0"
kotlin = "2.0.10"
kotlinxCoroutinesCore = "1.8.1"
lifecycleExtensions = "2.2.0"
lifecycleRuntimeKtx = "2.8.1"
lifecycleRuntimeKtx = "2.8.4"
logger = "1.0.3"
material = "1.12.0"
material3 = "1.2.1"
media = "1.7.0"
minaCore = "2.0.19"
minaCore = "2.2.3"
mockitoCore = "5.12.0"
preferenceKtx = "1.2.1"
reactiveStreams = "1.0.4"
recyclerview = "1.3.2"
rxjava = "2.2.21"
sl4j = "2.0.4"
sshdCore = "0.14.0"
sl4j = "2.0.13"
sshdCore = "2.13.2"
swiperefreshlayout = "1.1.0"
uiToolingPreview = "1.6.7"
uiToolingPreview = "1.6.8"
univocityParsers = "2.9.1"
[libraries]
android-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }
android-desugarJdkLibsNio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "androidDesugarJdkLibs" }
android-smsmms = { module = "org.kde.invent.sredman:android-smsmms", version.ref = "androidSmsmms" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -74,6 +74,9 @@ logger = { module = "com.klinkerapps:logger", version.ref = "logger" }
material = { module = "com.google.android.material:material", version.ref = "material" }
apache-mina-core = { module = "org.apache.mina:mina-core", version.ref = "minaCore" }
apache-sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshdCore" }
apache-sshd-sftp = { module = "org.apache.sshd:sshd-sftp", version.ref = "sshdCore" }
apache-sshd-scp = { module = "org.apache.sshd:sshd-scp", version.ref = "sshdCore" }
apache-sshd-mina = { module = "org.apache.sshd:sshd-mina", version.ref = "sshdCore" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" }
reactive-streams = { module = "org.reactivestreams:reactive-streams", version.ref = "reactiveStreams" }
rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" }

View File

@@ -1,6 +1,6 @@
#Sat Mar 02 00:26:28 CET 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: 2024 Mincho Kondarev <mkondarev@yahoo.de>
#. extracted from ./metadata/android/en-US/full_description.txt
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-11-05 12:31+0000\n"
"PO-Revision-Date: 2024-07-28 18:31+0200\n"
"Last-Translator: Mincho Kondarev <mkondarev@yahoo.de>\n"
"Language-Team: Bulgarian <kde-i18n-doc@kde.org>\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Lokalize 24.07.70\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
msgid ""
"KDE Connect provides a set of features to integrate your workflow across "
"devices:\n"
"\n"
"- Transfer files between your devices.\n"
"- Access files on your phone from your computer, without wires.\n"
"- Shared clipboard: copy and paste between your devices.\n"
"- Get notifications for incoming calls and messages on your computer.\n"
"- Virtual touchpad: Use your phone screen as your computer's touchpad.\n"
"- Notifications sync: Access your phone notifications from your computer and "
"reply to messages.\n"
"- Multimedia remote control: Use your phone as a remote for Linux media "
"players.\n"
"- WiFi connection: no USB wire or bluetooth needed.\n"
"- End-to-end TLS encryption: your information is safe.\n"
"\n"
"Please note you will need to install KDE Connect on your computer for this "
"app to work, and keep the desktop version up-to-date with the Android "
"version for the latest features to work.\n"
"\n"
"Sensitive permissions information:\n"
"* Accessibility permission: Required to receive input from another device to "
"control your Android phone, if you use the Remote Input feature.\n"
"* Background location permission: Required to know to which WiFi network you "
"are connected to, if you use the Trusted Networks feature.\n"
"\n"
"KDE Connect never sends any information to KDE nor to any third party. KDE "
"Connect sends data from one device to the other directly using the local "
"network, never through the internet, and using end to end encryption.\n"
"\n"
"This app is part of an open source project and it exists thanks to all the "
"people who contributed to it. Visit the website to grab the source code.\n"
msgstr ""
"KDE Connect предоставя набор от функции за интегриране на вашия работен "
"процес на различни устройства:\n"
"\n"
"- Прехвърляйте файлове между вашите устройства.\n"
"- Осъществявайте достъп до файлове на телефона си от компютъра си, без "
"кабели.\n"
"- Споделен клипборд: копирайте и поставяйте между вашите устройства.\n"
"- Получавайте известия за входящи обаждания и съобщения на вашия компютър.\n"
"- Виртуален тъчпад: Използвайте екрана на телефона си като тъчпад на "
"компютъра.\n"
"- Синхронизиране на известия: Достъп до известията на телефона ви от вашия "
"компютър и отговаряне на съобщения.\n"
"- Мултимедийно дистанционно управление: Използвайте телефона си като "
"дистанционно за Linux медийни плейъри.\n"
"- WiFi връзка: не е необходим USB кабел или bluetooth.\n"
"- TLS криптиране от край до край: информацията ви е в безопасност.\n"
"\n"
"Моля, имайте предвид, че ще трябва да инсталирате KDE Connect на вашия "
"компютър, за да работи това приложение, и поддържайте версията за настолен "
"компютър актуална с версията за Android, за да работят най-новите функции.\n"
"\n"
"Поверителна информация за разрешения:\n"
"* Разрешение за достъпност: Изисква се за получаване на вход от друго "
"устройство за управление на вашия телефон с Android, ако използвате "
"функцията за отдалечено въвеждане.\n"
"* Разрешение за местоположение във фонов режим: Изисква се, за да знаете към "
"коя WiFi мрежа сте свързани, ако използвате функцията Trusted Networks.\n"
"\n"
"KDE Connect никога не изпраща никаква информация на KDE или на трета страна. "
"KDE Connect изпраща данни от едно устройство на друго директно чрез "
"локалната мрежа, никога през интернет, и чрез криптиране от край до край.\n"
"\n"
"Това приложение е част от проект с отворен код и съществува благодарение на "
"всички хора, които са допринесли за него. Посетете уебсайта, за да вземете "
"изходния код.\n"

View File

@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: 2024 Mincho Kondarev <mkondarev@yahoo.de>
#. extracted from ./metadata/android/en-US/short_description.txt
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-11-05 12:31+0000\n"
"PO-Revision-Date: 2024-07-28 18:32+0200\n"
"Last-Translator: Mincho Kondarev <mkondarev@yahoo.de>\n"
"Language-Team: Bulgarian <kde-i18n-doc@kde.org>\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Lokalize 24.07.70\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
msgid "KDE Connect integrates your smartphone and computer"
msgstr "KDE Connect интегрира вашия смартфон и компютър"

View File

@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2024 Vit Pelcak <vpelcak@suse.cz>
# SPDX-FileCopyrightText: 2024 Vit Pelcak <vit@pelcak.org>
#. extracted from ./metadata/android/en-US/short_description.txt
msgid ""
msgstr ""

View File

@@ -0,0 +1,85 @@
# Translation of kdeconnect-android-store-full to Norwegian Nynorsk
#
#. extracted from ./metadata/android/en-US/full_description.txt
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-11-05 12:31+0000\n"
"PO-Revision-Date: 2024-07-10 20:18+0200\n"
"Last-Translator: Karl Ove Hufthammer <karl@huftis.org>\n"
"Language-Team: Norwegian Nynorsk <l10n-no@lister.huftis.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: nn\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Lokalize 24.05.1\n"
"X-Environment: kde\n"
"X-Accelerator-Marker: &\n"
"X-Text-Markup: kde4\n"
msgid ""
"KDE Connect provides a set of features to integrate your workflow across "
"devices:\n"
"\n"
"- Transfer files between your devices.\n"
"- Access files on your phone from your computer, without wires.\n"
"- Shared clipboard: copy and paste between your devices.\n"
"- Get notifications for incoming calls and messages on your computer.\n"
"- Virtual touchpad: Use your phone screen as your computer's touchpad.\n"
"- Notifications sync: Access your phone notifications from your computer and "
"reply to messages.\n"
"- Multimedia remote control: Use your phone as a remote for Linux media "
"players.\n"
"- WiFi connection: no USB wire or bluetooth needed.\n"
"- End-to-end TLS encryption: your information is safe.\n"
"\n"
"Please note you will need to install KDE Connect on your computer for this "
"app to work, and keep the desktop version up-to-date with the Android "
"version for the latest features to work.\n"
"\n"
"Sensitive permissions information:\n"
"* Accessibility permission: Required to receive input from another device to "
"control your Android phone, if you use the Remote Input feature.\n"
"* Background location permission: Required to know to which WiFi network you "
"are connected to, if you use the Trusted Networks feature.\n"
"\n"
"KDE Connect never sends any information to KDE nor to any third party. KDE "
"Connect sends data from one device to the other directly using the local "
"network, never through the internet, and using end to end encryption.\n"
"\n"
"This app is part of an open source project and it exists thanks to all the "
"people who contributed to it. Visit the website to grab the source code.\n"
msgstr ""
"KDE Connect tilbyr eit sett funksjonar som lèt deg enkelt arbeida på tvers "
"av einingar:\n"
"\n"
" Overfør filer mellom einingane\n"
" Få trådlaus tilgang til filer på telefonen frå datamaskina\n"
" Del utklippsbilete: kopier og lim inn mellom einingane\n"
" Vert varsla på datamaskina om innkommande samtalar og tekstmeldingar\n"
" Virtuell styreplate: bruk telefon­skjermen som styreplate for datamaskina\n"
" Synkronisering av varslingar: få tilgang til telefon­varslingar frå "
"datamaskina og svar på meldingar\n"
" Fjernkontroll av medieavspeling: bruk telefonen til å styra Linux-baserte "
"mediespelarar\n"
" Wi-Fi-tilkopling: du treng ikkje USB- eller Bluetooth-tilkopling\n"
" Ende-til-ende-kryptering: informasjonen din er trygg\n"
"\n"
"Merk at du må installera KDE Connect på datamaskina for å kunna bruka appen. "
"Hugs å halda PC-versjonen oppdatert med Android-versjonen for tilgang til "
"dei nyaste funksjonane.\n"
"\n"
"Informasjon om sensitive løyve:\n"
" Tilgjenge-løyve: Trengst for å kunna ta imot tastetrykk frå PC for å styra "
"Android-eininga om du brukar funksjonen «Fjernstyring»\n"
" Bakgrunns­løyve til å sjå geografiske posisjon: Trengst for å veta kva Wi-"
"Fi-nettverk du er tilkopla om du brukar funksjonen «Tiltrudde nettverk»\n"
"\n"
"KDE Connect sender aldri informasjon til KDE eller nokon tredjepart. "
"Programmet sender data direkte mellom dei to einingane via lokalnettet, "
"aldri via Internett og alltid med ende-til-ende-kryptering.\n"
"\n"
"Appen er ein del av eit fri programvare-prosjekt og er blitt til takka vera "
"mange bidragsytarar. Gå til heimesida for å sjå kjeldekoden.\n"

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group android:pivotX="12" android:pivotY="12" android:scaleX="0.66" android:scaleY="0.66">
<path
android:fillColor="@android:color/white"
android:pathData="M21,2L3,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h7v2L8,20v2h8v-2h-2v-2h7c1.1,0 2,-0.9 2,-2L23,4c0,-1.1 -0.9,-2 -2,-2zM21,16L3,16L3,4h18v12z" />
</group>
</vector>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotX="12"
android:pivotY="12"
android:scaleX="0.66"
android:scaleY="0.66">
<path
android:fillColor="@android:color/white"
android:pathData="M20,18c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2H4C2.9,4 2,4.9 2,6v10c0,1.1 0.9,2 2,2H0v2h24v-2H20zM4,6h16v10H4V6z" />
</group>
</vector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotX="12"
android:pivotY="12"
android:scaleX="0.66"
android:scaleY="0.66">
<path
android:fillColor="@android:color/white"
android:pathData="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" />
</group>
</vector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotX="12"
android:pivotY="12"
android:scaleX="0.66"
android:scaleY="0.66">
<path
android:fillColor="@android:color/white"
android:pathData="M21,4L3,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h18c1.1,0 1.99,-0.9 1.99,-2L23,6c0,-1.1 -0.9,-2 -2,-2zM19,18L5,18L5,6h14v12z" />
</group>
</vector>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotX="12"
android:pivotY="12"
android:scaleX="0.66"
android:scaleY="0.66">
<path
android:fillColor="@android:color/white"
android:pathData="M21,3L3,3c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h5v2h8v-2h5c1.1,0 1.99,-0.9 1.99,-2L23,5c0,-1.1 -0.9,-2 -2,-2zM21,17L3,17L3,5h18v12z" />
</group>
</vector>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Подобрения на потребителския интерфейс и тази страница за</string>
<string name="holger_kaelberer_task">Плъгин за отдалечена клавиатура и поправки на грешки</string>
<string name="saikrishna_arcot_task">Поддръжка за използване на клавиатура в плъгина за отдалечено въвеждане, поправки на грешки и общи подобрения</string>
<string name="shellwen_chen_task">Внедряване на SFTP, подобряване на възможностите за поддръжка на този проект, поправки на грешки и общи подобрения</string>
<string name="everyone_else">Всички останали, които са допринесли за KDE Connect през годините</string>
<string name="send_clipboard">Изпращане на клипборд</string>
<string name="tap_to_execute">Докоснете, за да се изпълни</string>

View File

@@ -366,7 +366,7 @@
<item>Clar</item>
<item>Fosc</item>
</string-array>
<string name="report_bug">Informa d\'un error</string>
<string name="report_bug">Informeu d\'un error</string>
<string name="donate">Donació de diners</string>
<string name="source_code">Codi font</string>
<string name="licenses">Llicències</string>
@@ -390,7 +390,7 @@
<string name="compose_send_title">Títol de l\'enviament</string>
<string name="open_compose_send">Redacta text</string>
<string name="about_kde_about">&lt;h1&gt;Quant al&lt;/h1&gt; &lt;p&gt;El KDE és una comunitat mundial d\'enginyers, artistes, escriptors, traductors i creadors de programari compromesos amb el desenvolupament de &lt;a href=https://www.gnu.org/philosophy/free-sw.html&gt;programari lliure&lt;/a&gt;. El KDE produeix l\'entorn d\'escriptori Plasma, centenars d\'aplicacions i moltes biblioteques de programari que els donen suport.&lt;/p&gt; &lt;p&gt;El KDE és una empresa en cooperativa: cap entitat controla la seva direcció o els productes. En el seu lloc, treballem junts per a aconseguir l\'objectiu comú de construir el millor programari lliure del món. Tothom hi és benvingut a &lt;a href=https://community.kde.org/Get_Involved&gt;unir-se i contribuir&lt;/a&gt; al KDE, inclosos vosaltres.&lt;/p&gt; Visiteu &lt;a href=https://www.kde.org/ca/&gt;https://www.kde.org/ca/&lt;/a&gt; per a obtenir més informació sobre la comunitat KDE i el programari que produïm.</string>
<string name="about_kde_report_bugs_or_wishes">&lt;h1&gt;Informeu dels errors o desitjos&lt;/h1&gt; &lt;p&gt;El programari sempre es pot millorar, i l\'equip del KDE està a punt per a fer-ho. No obstant això, l\'usuari, ha de dir-nos quan alguna cosa no funciona com s\'esperava o si podria fer-se millor.&lt;/p&gt; &lt;p&gt;El KDE té un sistema de seguiment d\'errors. Per a informar-ne d\'un, visiteu &lt;a href=https://bugs.kde.org/&gt;https://bugs.kde.org/&lt;/a&gt; o useu el botó \"Informa d\'un error\" des de la pantalla Quant al.&lt;/p&gt; Si teniu un suggeriment de millora, podeu usar el sistema de seguiment d\'errors per a enregistrar el vostre desig. Assegureu-vos d\'usar la severitat anomenada \"Llista de desitjos\" (Wishlist).</string>
<string name="about_kde_report_bugs_or_wishes">&lt;h1&gt;Informeu dels errors o desitjos&lt;/h1&gt; &lt;p&gt;El programari sempre es pot millorar, i l\'equip del KDE està a punt per a fer-ho. No obstant això, l\'usuari, ha de dir-nos quan alguna cosa no funciona com s\'esperava o si podria fer-se millor.&lt;/p&gt; &lt;p&gt;El KDE té un sistema de seguiment d\'errors. Per a informar-ne d\'un, visiteu &lt;a href=https://bugs.kde.org/&gt;https://bugs.kde.org/&lt;/a&gt; o useu el botó «Informeu d\'un error» des de la pantalla Quant al.&lt;/p&gt; Si teniu un suggeriment de millora, podeu usar el sistema de seguiment d\'errors per a enregistrar el vostre desig. Assegureu-vos d\'usar la severitat anomenada «Llista de desitjos» (Wishlist).</string>
<string name="about_kde_join_kde">&lt;h1&gt;Uniu-vos al KDE&lt;/h1&gt; &lt;p&gt;No cal ser un desenvolupador de programari per a ser membre de l\'equip KDE. Podeu unir-vos als equips d\'idiomes que tradueixen la interfície dels programes. Podeu proporcionar gràfics, temes, sons i documentació millorada. Vosaltres decidiu!&lt;/p&gt; &lt;p&gt;Visiteu &lt;a href=https://community.kde.org/Get_Involved&gt;https://community.kde.org/Get_Involved&lt;/a&gt; per a obtenir informació sobre alguns projectes en què podeu participar-hi.&lt;/p&gt; Si us cal més informació o documentació, una visita a &lt;a href=https://techbase.kde.org/&gt;https://techbase.kde.org/&lt;/a&gt; us proporcionarà el que necessiteu.</string>
<string name="about_kde_support_kde">&lt;h1&gt;Contribució al KDE&lt;/h1&gt; &lt;p&gt;El programari KDE està i sempre estarà disponible de forma gratuïta, però la creació no està lliure de càrrecs.&lt;/p&gt; &lt;p&gt;Per a donar suport al desenvolupament, la comunitat KDE ha format la KDE e.V., una organització sense ànim de lucre legalment fundada a Alemanya. La KDE e.V. representa a la comunitat KDE en els assumptes legals i financers. Per a obtenir informació sobre la KDE e.V., vegeu &lt;a href=https://ev.kde.org/&gt;https://ev.kde.org/&lt;/a&gt;.El KDE es beneficia de molts tipus de contribucions, inclosa la financera. Usem els fons per a reemborsar als membres i altra gent per les despeses que incorren col·laborant-hi. S\'usen més fons per al suport legal i l\'organització de les conferències i reunions.&lt;/p&gt; &lt;p&gt;Us animem a ajudar al KDE mitjançant donacions monetàries, usant un dels mitjans descrits a &lt;a href=https://kde.org/ca/community/donations/&gt;https://kde.org/ca/community/donations/&lt;/a&gt;.&lt;/p&gt;. Moltes gràcies per endavant per la vostra ajuda.</string>
<string name="maintainer_and_developer">Mantenidor i desenvolupador</string>
@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Millores en la IU i ha creat aquesta pàgina</string>
<string name="holger_kaelberer_task">Connector de teclat remot i esmenes d\'errors</string>
<string name="saikrishna_arcot_task">Suport per a usar el teclat en el connector d\'entrada remota, esmenes d\'errors i millores generals</string>
<string name="shellwen_chen_task">Millorar la seguretat d\'SFTP, millorar la mantenibilitat d\'aquest projecte, esmenes d\'errors i millores generals</string>
<string name="everyone_else">Tothom qui ha contribuït al KDE Connect al llarg dels anys</string>
<string name="send_clipboard">Envia el porta-retalls</string>
<string name="tap_to_execute">Toqueu per a executar</string>

View File

@@ -207,6 +207,7 @@
<string name="mpris_notification_settings_summary">Umožnit ovládání přehrávače médií bez otevření KDE Connect</string>
<string name="share_to">Sdílet s...</string>
<string name="unreachable_device">%s (nedostupná)</string>
<string name="unreachable_device_url_share_text">URL sdílená s nedostupným zařízením budou zaslána jakmile se stane dostupným.\n\n</string>
<string name="protocol_version_newer">Toto zařízení používá novější verzi protokolu</string>
<string name="plugin_settings_with_name">Nastavení %s</string>
<string name="invalid_device_name">Neplatný název zařízení</string>
@@ -414,6 +415,7 @@
<string name="maxim_leshchenko_task">Vylepšení prostředí a tato stránka o aplikaci</string>
<string name="holger_kaelberer_task">Vzdálené modul klávesnice a opravy chyb</string>
<string name="saikrishna_arcot_task">Podpora použití klávesnice na vzdáleném vstupním modulu, opravy chyb a obecná zlepšení</string>
<string name="shellwen_chen_task">Vylepšení zabezpečení SFTP, zlepšení udržovatelnosti projektu, opravy chyb a obecná vylepšení</string>
<string name="everyone_else">Každý kdo přispěl do KDE Connect během let</string>
<string name="send_clipboard">Poslat schránku</string>
<string name="tap_to_execute">Pro spuštění ťukněte sem</string>
@@ -424,5 +426,6 @@
<string name="no_notifications">Upozornění jsou zakázána. Neuvidíte upozornění na párování.</string>
<string name="mpris_keepwatching">Pokračovat v přehrávání</string>
<string name="mpris_keepwatching_settings_title">Pokračovat v přehrávání</string>
<string name="mpris_keepwatching_settings_summary">Zobrazit tiché upozornění pro pokračování přehrávání na tomto zařízení po zavření médií</string>
<string name="notification_channel_keepwatching">Pokračovat v přehrávání</string>
</resources>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">UI improvements and this about page</string>
<string name="holger_kaelberer_task">Remote keyboard plugin and bug fixes</string>
<string name="saikrishna_arcot_task">Support for using the keyboard in the remote input plugin, bug fixes and general improvements</string>
<string name="shellwen_chen_task">Improve the security of SFTP, improve the maintainability of this project, bug fixes and general improvements</string>
<string name="everyone_else">Everyone else who has contributed to KDE Connect over the years</string>
<string name="send_clipboard">Send clipboard</string>
<string name="tap_to_execute">Tap to execute</string>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Mejoras en la UI y la página «Acerca de»</string>
<string name="holger_kaelberer_task">Complemento del teclado remoto y arreglos</string>
<string name="saikrishna_arcot_task">Soporte para usar el teclado en el complemento de entrada remota, arreglos y mejoras generales</string>
<string name="shellwen_chen_task">Mejoras en la seguridad de SFTP, mejoras en la mantenibilidad del proyecto, arreglos de fallos y mejoras generales.</string>
<string name="everyone_else">Todos los demás que han contribuido a KDE Connect a lo largo de su historia</string>
<string name="send_clipboard">Enviar al portapapeles</string>
<string name="tap_to_execute">Pulse para ejecutar</string>

View File

@@ -18,6 +18,7 @@
<string name="pref_plugin_clipboard_sent">Arbelekoa bidali da</string>
<string name="pref_plugin_mousepad">Urrutiko sarrera</string>
<string name="pref_plugin_mousepad_desc">Erabili zure telefonoa edo tableta ukimen-sagu eta teklatu gisa</string>
<string name="pref_plugin_presenter">Aurkezpenetarako urruneko agintea</string>
<string name="pref_plugin_presenter_desc">Erabili zure gailua aurkezpen bateko diapositibak aldatzeko</string>
<string name="pref_plugin_remotekeyboard">Jaso urruneko tekla-sakatzeak</string>
<string name="pref_plugin_remotekeyboard_desc">Jaso tekla-sakatze gertaerak urruneko gailuetatik</string>
@@ -51,6 +52,7 @@
<string name="remotekeyboard_multiple_connections">Urruneko teklatuekin konexio bat baino gehiago dago, hautatu konfiguratu beharreko gailua</string>
<string name="open_mousepad">Urruneko sarrera</string>
<string name="mousepad_info">Mugitu hatz bat pantailan zehar saguaren erakuslea mugitzeko. Egin tak klik baterako, eta erabili bi/hiru hatz eskuin eta erdiko botoietarako. Erabili 2 hatz kiribiltzeko. Erabili sakatze luze bat arrastatu eta jaregiteko. Saguaren giroskopio funtzionalitatea pluginen hobespenetatik gaitu daiteke.</string>
<string name="mousepad_info_no_gestures">Mugitu hatz bat pantailan kurtsorea mugitzeko, egin tak klik egiteko.</string>
<string name="mousepad_keyboard_input_not_supported">Parekatutako gailuak ez du teklatuko sarreraren euskarririk</string>
<string name="mousepad_single_tap_settings_title">Ezarri hatz bakarrarekin tak egitearen ekintza</string>
<string name="mousepad_double_tap_settings_title">Ezarri bi hatzez tak egitearen ekintza</string>
@@ -119,6 +121,8 @@
<string name="my_device_fingerprint">Zure gailuaren ziurtagiriaren SHA256 hatz-marka:</string>
<string name="remote_device_fingerprint">Urruneko gailuaren ziurtagiriaren SHA256 hatz-marka hau da:</string>
<string name="pair_requested">Parekatzea eskatu da</string>
<string name="pair_succeeded">Parekatze arrakastatsua</string>
<string name="pairing_request_from">\'%1s\'(e)ren parekatzeko eskaria</string>
<plurals name="incoming_file_title">
<item quantity="one">%2$s(e)tik fitxategi %1$d jasotzen</item>
<item quantity="other">%2$s(e)tik %1$d fitxategi jasotzen</item>
@@ -155,6 +159,7 @@
<string name="received_file_text">Tak egin \'%1s\' irekitzeko</string>
<string name="cannot_create_file">Ezin da sortu %s fitxategia</string>
<string name="tap_to_answer">Tak egin erantzuteko</string>
<string name="left_click">Bidali ezkerreko klik</string>
<string name="right_click">Bidali eskumako klik</string>
<string name="middle_click">Bidali erdiko klik</string>
<string name="show_keyboard">Erakutsi teklatua</string>
@@ -184,6 +189,9 @@
<string name="mpris_notifications_explanation">Urruneko euskarria jakinarazpen tiraderan erakusteko jakinarazpen-baimena behar da</string>
<string name="mpris_notification_settings_title">Erakutsi euskarri kontrolaren jakinarazpena</string>
<string name="mpris_notification_settings_summary">Utzi zure euskarri-jotzaileak kontrolatzen KDE Connect ireki gabe</string>
<string name="share_to">Partekatu honekin...</string>
<string name="unreachable_device">%s (eskuraezin)</string>
<string name="unreachable_device_url_share_text">Gailu eskuraezinekin partekatutako URLak, hartara bidaliko dira eskuragarri dagoenean.\n\n</string>
<string name="protocol_version_newer">Gailu honek protokoloaren bertsio berriago bat erabiltzen du</string>
<string name="plugin_settings_with_name">%s ezarpenak</string>
<string name="invalid_device_name">Gailuaren izen baliogabea</string>
@@ -395,6 +403,7 @@
<string name="maxim_leshchenko_task">Erabiltzaile-interfazean hobekuntzak eta Honi buru orria hau</string>
<string name="holger_kaelberer_task">Urruneko teklatuaren plugina eta akatsen konponketa</string>
<string name="saikrishna_arcot_task">Urruneko sarrerako pluginean teklatua erabiltzeko euskarria, akatsen konponketa eta hobekuntza orokorrak</string>
<string name="shellwen_chen_task">SFTPren segurtasuna hobetu, proiektu honen mantentze-gaitasuna hobetu, akatsak konpondu eta hobekuntza orokorrak</string>
<string name="everyone_else">Urteetan KDE Connect-ekin lagundu duten gainerako guztiak</string>
<string name="send_clipboard">Bidali arbelekoa</string>
<string name="tap_to_execute">Tak egin exekutatzeko</string>
@@ -403,4 +412,8 @@
<string name="receive_notifications_permission_explanation">Jakinarazpenak baimendu behar dira beste gailuetatik haiek jasotzeko</string>
<string name="findmyphone_notifications_explanation">Aplikazioa atzeko planoan dagoenean telefonoak jo dezan jakinarazpen-baimena behar da</string>
<string name="no_notifications">Jakinarazpenak ezgaituta daude, ez duzu jasoko parekatzeko sarrerako jakinarazpenik.</string>
<string name="mpris_keepwatching">Jarraitu jotzen</string>
<string name="mpris_keepwatching_settings_title">Jarraitu jotzen</string>
<string name="mpris_keepwatching_settings_summary">Hedabidea itxi ondoren, gailu honetan jotzen jarraitzeko jakinarazpen ixil bat erakutsi.</string>
<string name="notification_channel_keepwatching">Jarraitu jotzen</string>
</resources>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Melloras na UI e nesta páxina de información</string>
<string name="holger_kaelberer_task">Complemento de teclado remoto e correccións de fallos</string>
<string name="saikrishna_arcot_task">Posibilidade de usar o teclado no complemento de entrada remota, correccións de fallos e melloras xerais</string>
<string name="shellwen_chen_task">Mellorar a seguridade de SFTP, facilitar o mantemento do proxecto, correccións de fallos e melloras xerais.</string>
<string name="everyone_else">O resto de xente que colaborou en KDE Connect ao longo dos anos</string>
<string name="send_clipboard">Enviar o portapapeis</string>
<string name="tap_to_execute">Toque para executar</string>

View File

@@ -201,4 +201,7 @@
<string name="receive_notifications_permission_explanation">Notificationes necessita esser permettite a reciper los ex altere dispositivos</string>
<string name="findmyphone_notifications_explanation">Le permission de notificationes es necessari assi que le telephono pote sonar quando le app es in le fundo</string>
<string name="no_notifications">Notificatione es dishabilitate, tu nonrecipera notificatioones de association in arrivata.</string>
<string name="mpris_keepwatching">"Continua a executar "</string>
<string name="mpris_keepwatching_settings_title">Continua a executar</string>
<string name="notification_channel_keepwatching">Continua a executar</string>
</resources>

View File

@@ -307,7 +307,7 @@
<string name="settings_rename">Nome dispositivo</string>
<string name="settings_dark_mode">Tema scuro</string>
<string name="settings_more_settings_title">Altre impostazioni</string>
<string name="settings_more_settings_text">Le impostazioni per dispositivo sono disponibili sotto «Impostazione estensioni» dall\'interno del dispositivo.</string>
<string name="settings_more_settings_text">Le impostazioni per dispositivo sono disponibili sotto «Impostazioni estensioni» dall\'interno del dispositivo.</string>
<string name="setting_persistent_notification">Mostra notifica persistente</string>
<string name="setting_persistent_notification_oreo">Notifica persistente</string>
<string name="setting_persistent_notification_description">Tocca per abilitare/disabilitare nelle impostazioni delle notifiche</string>
@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Miglioramenti all\'interfaccia utente e questa pagina informativa</string>
<string name="holger_kaelberer_task">Estensione della tastiera remota e correzioni di bug</string>
<string name="saikrishna_arcot_task">Supporto per l\'utilizzo della tastiera nell\'estensione di inserimento remoto, correzioni di bug e miglioramenti generali</string>
<string name="shellwen_chen_task">Migliora la sicurezza di SFTP, migliora la manutenibilità di questo progetto, correzioni di bug e miglioramenti generali</string>
<string name="everyone_else">Tutti gli altri che hanno contribuito a KDE Connect nel corso degli anni</string>
<string name="send_clipboard">Invia gli appunti</string>
<string name="tap_to_execute">Tocca per eseguire</string>

View File

@@ -419,6 +419,7 @@
<string name="maxim_leshchenko_task">שיפורי ממשק משתמש ועמוד על אודות הזה</string>
<string name="holger_kaelberer_task">תוסף מקלדת מרוחקת ותיקוני תקלות</string>
<string name="saikrishna_arcot_task">תמיכה בשימוש במקלדת בתוסף הקלט המרוחק, תיקוני תקלות ושיפורים כלליים</string>
<string name="shellwen_chen_task">שיפור האבטחה ב־SFTP, שיפור תחזוקתיות המיזם הזה, תיקוני תקלות ושיפורים כלליים</string>
<string name="everyone_else">כל מי שתרם ל־KDE Connect לאורך השנים</string>
<string name="send_clipboard">שליחת לוח גזירים</string>
<string name="tap_to_execute">נגיעה תפעיל</string>

View File

@@ -18,6 +18,7 @@
<string name="pref_plugin_clipboard_sent">ბუფერი გაგზავნილია</string>
<string name="pref_plugin_mousepad">დაშორებული შეყვანა</string>
<string name="pref_plugin_mousepad_desc">გამოიყენეთ თქვენი ტელეფონი ან ტაბლეტი როგორც თაჩპედი და კლავიატურა</string>
<string name="pref_plugin_presenter">დაშორებული პრეზენტაცია</string>
<string name="pref_plugin_presenter_desc">გამოიყენეთ თქვენი მოწყობილობა სლაიდშოუს სამართავად</string>
<string name="pref_plugin_remotekeyboard">დაშორებული ღილაკების მიღება</string>
<string name="pref_plugin_mpris">მულტიმედიის მართვა</string>
@@ -92,6 +93,7 @@
<string name="error_canceled_by_other_peer">გაუქმებულია პარტნიორის მიერ</string>
<string name="encryption_info_title">დაშიფვრის ინფორმაცია</string>
<string name="pair_requested">დაწყვილების მოთხოვნა</string>
<string name="pair_succeeded">დაწყვილება წარმატებულია</string>
<plurals name="incoming_files_text">
<item quantity="one">File: %1s</item>
<item quantity="other">(File %2$d of %3$d) : %1$s</item>
@@ -104,6 +106,7 @@
<string name="received_file_text">\'%1s\'-ის გასახსნელად დაატყაპუნეთ</string>
<string name="cannot_create_file">ფაილის (%s) შექმნის შეცდომა</string>
<string name="tap_to_answer">საპასუხოდ დაატყაპუნეთ</string>
<string name="left_click">მარჯვენა წკაპის გაგზავნა</string>
<string name="right_click">მარჯვენა წკაპის გაგზავნა</string>
<string name="middle_click">შუა წკაპის გაგზავნა</string>
<string name="show_keyboard">კლავიატურის ჩვენება</string>
@@ -129,6 +132,8 @@
<item>1 წუთი</item>
<item>2 წუთი</item>
</string-array>
<string name="share_to">გაზიარება…</string>
<string name="unreachable_device">%s (მიუწვდომელია)</string>
<string name="protocol_version_newer">მოწყობილობა პროტოკოლის უფრო ახალ ვერსიას იყენებს</string>
<string name="plugin_settings_with_name">%s-ის მორგება</string>
<string name="invalid_device_name">მოწყობილობის არასწორი სახელი</string>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Verbeteringen aan UI en deze info over pagina</string>
<string name="holger_kaelberer_task">"Plug-in voor toetsenbord op afstand en reparaties"</string>
<string name="saikrishna_arcot_task">Ondersteuning voor gebruik van toetsenbord in de plug-in voor invoer op afstand, bugreparaties en algemene verbeteringen</string>
<string name="shellwen_chen_task">Verbeter de beveiliging van SFTP, verbeter de onderhoudbaarheid van dit project, reparaties van bugs en algemene verbeteringen</string>
<string name="everyone_else">Ieder ander die over de jaren heeft bijgedragen aan KDE Connect</string>
<string name="send_clipboard">Klembord verzenden</string>
<string name="tap_to_execute">Tik om uit te voeren</string>

View File

@@ -122,6 +122,7 @@
<string name="remote_device_fingerprint">SHA-256-fingeravtrykket til fjerneiningssertifikatet er:</string>
<string name="pair_requested">Paringsførespurnad</string>
<string name="pair_succeeded">Paring fullført</string>
<string name="pairing_request_from">Paringsførespurnad frå «%1s»</string>
<plurals name="incoming_file_title">
<item quantity="one">Fekk %1$d fil frå %2$s</item>
<item quantity="other">Fekk %1$d filer frå %2$s</item>
@@ -402,6 +403,7 @@
<string name="maxim_leshchenko_task">Forbetringar av brukarflata og denne «om»-sida</string>
<string name="holger_kaelberer_task">Fjerntastatur-tillegget og feilrettingar</string>
<string name="saikrishna_arcot_task">Støtte for bruk av tastaturet i fjernstyrings­tillegget, feilrettingar og generelle forbetringar</string>
<string name="shellwen_chen_task">Forbetra tryggleiken til SFTP, gjort prosjektet lettare å vedlikehalda, feilrettingar og generelle forbetringar</string>
<string name="everyone_else">Alle andre som har hjelpt til med utviklinga av KDE Connect opp gjennom åra</string>
<string name="send_clipboard">Send utklippstavla</string>
<string name="tap_to_execute">Tapp for å utføra handlinga</string>

View File

@@ -419,6 +419,7 @@
<string name="maxim_leshchenko_task">Usprawnienia do interfejsu i strony o programie</string>
<string name="holger_kaelberer_task">Wtyczka zdalnej klawiatury i usuwanie błędów</string>
<string name="saikrishna_arcot_task">Wsparcie do obsługi klawiatury we wtyczce zdalnego wprowadzania, usuwanie błędów i ogólne usprawnienia</string>
<string name="shellwen_chen_task">Polepszenie bezpieczeństwa SFTP, polepszenie łatwości w utrzymaniu tego projektu, poprawki błędów oraz ogólne ulepszenia</string>
<string name="everyone_else">Inni którzy współtworzyli KDE Connect na przestrzeni lat</string>
<string name="send_clipboard">Wysyłanie schowka</string>
<string name="tap_to_execute">Stuknij, aby wykonać</string>

View File

@@ -419,6 +419,7 @@
<string name="maxim_leshchenko_task">Izboljšave uporabniškega vmesnika in ta stran o programu</string>
<string name="holger_kaelberer_task">Vtičnik za oddaljeno tipkovnico in popravki napak</string>
<string name="saikrishna_arcot_task">Podpora za uporabo tipkovnice vtičnika za oddaljen vnos, popravki napak in splošne izboljšave</string>
<string name="shellwen_chen_task">Izboljšanje varnosti SFTP, izboljšanje vzdržljivosti tega projekta, popravki napak in splošne izboljšave</string>
<string name="everyone_else">Vsi ostali, ki so prispevali za KDE Connect v letih razvoja</string>
<string name="send_clipboard">Pošlji odložišče</string>
<string name="tap_to_execute">Tapkajte za izvedbo</string>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Förbättringar av användargränssnitt och den här om-sidan</string>
<string name="holger_kaelberer_task">Insticksprogram för fjärrtangentbord och felrättningar</string>
<string name="saikrishna_arcot_task">Stöd för användning av tangentbordet i insticksprogrammet för fjärrinmatning, felrättningar och allmänna förbättringar</string>
<string name="shellwen_chen_task">Förbättra säkerheten för SFTP, förbättra projektets underhållbarhet, felrättningar och allmänna förbättringar</string>
<string name="everyone_else">Alla andra som har bidragit till KDE-anslut under alla år</string>
<string name="send_clipboard">Skicka klippbord</string>
<string name="tap_to_execute">Rör för att köra</string>

View File

@@ -403,6 +403,7 @@
<string name="maxim_leshchenko_task">Kullanıcı arabirimi iyileştirmeleri ve bu hakkında sayfası</string>
<string name="holger_kaelberer_task">Uzak klavye eklentisi ve hata düzeltmeleri</string>
<string name="saikrishna_arcot_task">Uzaktan girdi eklentisinde klavye kullanımı desteği, hata düzeltmeleri ve genel iyileştirmeler</string>
<string name="shellwen_chen_task">SFTP güvenliğini artır, projenin bakımını daha da kolaylaştır, hata düzeltmeleri ve genel iyileştirmeler</string>
<string name="everyone_else">KDE Bağlana yıllar boyunca katkıda bulunan herkes</string>
<string name="send_clipboard">Pano gönder</string>
<string name="tap_to_execute">Yürütmek için dokun</string>

View File

@@ -419,6 +419,7 @@
<string name="maxim_leshchenko_task">Удосконалення інтерфейсу та ця інформаційна сторінка</string>
<string name="holger_kaelberer_task">Додаток бездротової клавіатури та виправлення вад</string>
<string name="saikrishna_arcot_task">Підтримка використання клавіатури у додатку віддаленого введення, виправлення вад і загальні удосконалення</string>
<string name="shellwen_chen_task">Удосконалення захисту SFTP, удосконалення можливості супроводу проєкту, виправлення вад і загальні удосконалення</string>
<string name="everyone_else">Усім іншим, хто робив внесок до KDE Connect протягом років розробки</string>
<string name="send_clipboard">Надіслати вміст буфера</string>
<string name="tap_to_execute">Торкніться, щоб виконати</string>

View File

@@ -395,6 +395,7 @@
<string name="maxim_leshchenko_task">界面改进和此关于页面</string>
<string name="holger_kaelberer_task">远程键盘插件和程序缺陷修正</string>
<string name="saikrishna_arcot_task">支持在远程输入插件中使用键盘、程序缺陷修正和常规改进</string>
<string name="shellwen_chen_task">改进 SFTP 的安全性,改进此项目的可维护性,修复程序缺陷和常规改进</string>
<string name="everyone_else">以及多年来为 KDE Connect 作出过贡献的其他所有人</string>
<string name="send_clipboard">发送剪贴板</string>
<string name="tap_to_execute">轻触执行</string>

View File

@@ -555,6 +555,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="maxim_leshchenko_task">UI improvements and this about page</string>
<string name="holger_kaelberer_task">Remote keyboard plugin and bug fixes</string>
<string name="saikrishna_arcot_task">Support for using the keyboard in the remote input plugin, bug fixes and general improvements</string>
<string name="shellwen_chen_task">Improve the security of SFTP, improve the maintainability of this project, bug fixes and general improvements</string>
<string name="everyone_else">Everyone else who has contributed to KDE Connect over the years</string>
<string name="send_clipboard">Send clipboard</string>

7
res/xml/shortcuts.xml Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="org.kde.kdeconnect.Plugins.SharePlugin.ShareActivity">
<data android:mimeType="*/*" />
<category android:name="org.kde.kdeconnect.category.SHARE_TARGET" />
</share-target>
</shortcuts>

View File

@@ -20,4 +20,15 @@ dependencyResolutionManagement {
}
}
}
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("org.ow2.asm:asm-util:9.6")
}
}
rootProject.name = "kdeconnect-android"

View File

@@ -255,6 +255,8 @@ class BluetoothLinkProvider(private val context: Context) : BaseLinkProvider() {
context.unregisterReceiver(this)
} catch (se: SecurityException) {
Log.w("BluetoothLinkProvider", se)
} catch (ia: IllegalArgumentException) {
Log.w("BluetoothLinkProvider", ia) // Happens sometimes in unregisterReceiver
}
}

View File

@@ -202,7 +202,7 @@ class ConnectionMultiplexer(socket: BluetoothSocket) : Closeable {
socket!!.outputStream.write(data)
}
private fun handleException(@Suppress("UNUSED_PARAMETER") ignored: IOException) {
private fun handleException(@Suppress("UNUSED_PARAMETER") ignored: Exception) {
lock.withLock {
open = false
for (channel in channels.values) {
@@ -257,6 +257,8 @@ class ConnectionMultiplexer(socket: BluetoothSocket) : Closeable {
socket!!.outputStream.write(data)
} catch (e: IOException) {
handleException(e)
} catch (e: NullPointerException) {
handleException(e)
}
channel.lockCondition.signalAll()
}

View File

@@ -22,13 +22,13 @@ import java.security.cert.CertificateException
* DeviceInfo contains all the properties needed to instantiate a Device.
*/
class DeviceInfo(
@JvmField val id : String,
@JvmField val certificate : Certificate,
@JvmField var name : String,
@JvmField var type : DeviceType,
@JvmField var protocolVersion : Int = 0,
@JvmField var incomingCapabilities : Set<String>? = null,
@JvmField var outgoingCapabilities : Set<String>? = null,
@JvmField val id: String,
@JvmField val certificate: Certificate,
@JvmField var name: String,
@JvmField var type: DeviceType,
@JvmField var protocolVersion: Int = 0,
@JvmField var incomingCapabilities: Set<String>? = null,
@JvmField var outgoingCapabilities: Set<String>? = null,
) {
/**
@@ -40,7 +40,7 @@ class DeviceInfo(
try {
val encodedCertificate = Base64.encodeToString(certificate.encoded, 0)
with (settings.edit()) {
with(settings.edit()) {
putString("certificate", encodedCertificate)
putString("deviceName", name)
putString("deviceType", type.toString())
@@ -73,7 +73,7 @@ class DeviceInfo(
*/
@JvmStatic
@Throws(CertificateException::class)
fun loadFromSettings(context : Context, deviceId: String, settings: SharedPreferences) =
fun loadFromSettings(context: Context, deviceId: String, settings: SharedPreferences) =
with(settings) {
DeviceInfo(
id = deviceId,
@@ -104,8 +104,8 @@ class DeviceInfo(
@JvmStatic
fun isValidIdentityPacket(identityPacket: NetworkPacket): Boolean = with(identityPacket) {
type == NetworkPacket.PACKET_TYPE_IDENTITY &&
DeviceHelper.filterName(getString("deviceName", "")).isNotBlank() &&
getString("deviceId", "").isNotBlank()
DeviceHelper.filterName(getString("deviceName", "")).isNotBlank() &&
getString("deviceId", "").isNotBlank()
}
}
}
@@ -126,7 +126,7 @@ enum class DeviceType {
ContextCompat.getDrawable(context, toDrawableId())!!
@DrawableRes
private fun toDrawableId() =
fun toDrawableId() =
when (this) {
PHONE -> R.drawable.ic_device_phone_32dp
TABLET -> R.drawable.ic_device_tablet_32dp
@@ -135,6 +135,15 @@ enum class DeviceType {
else -> R.drawable.ic_device_desktop_32dp
}
fun toShortcutDrawableId() =
when (this) {
PHONE -> R.drawable.ic_device_phone_shortcut
TABLET -> R.drawable.ic_device_tablet_shortcut
TV -> R.drawable.ic_device_tv_shortcut
LAPTOP -> R.drawable.ic_device_laptop_shortcut
else -> R.drawable.ic_device_desktop_shortcut
}
companion object {
@JvmStatic
fun fromString(s: String) =

View File

@@ -0,0 +1,871 @@
/*
* SPDX-FileCopyrightText: 2014 The Android Open Source Project
* SPDX-FileCopyrightText: 1997, 2021, Oracle and/or its affiliates. All rights reserved
*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers;
import android.os.Build;
import android.os.Build.VERSION;
import androidx.annotation.RequiresApi;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.Map;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.Objects;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.Spliterator;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.IntFunction;
import java.util.function.Predicate;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
/** @noinspection unused*/
public final class CollectionsBackport {
public static <T> NavigableSet<T> unmodifiableNavigableSet(NavigableSet<T> s) {
if (VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return Collections.unmodifiableNavigableSet(s);
} else {
return new UnmodifiableNavigableSetBackport<>(s);
}
}
public static <T> Set<T> unmodifiableSet(Set<T> s) {
if (VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return Collections.unmodifiableSet(s);
} else {
return new UnmodifiableSetBackport<>(s);
}
}
public static <T> Collection<T> unmodifiableCollection(Collection<T> c) {
if (VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return Collections.unmodifiableCollection(c);
} else {
return new UnmodifiableCollectionBackport<>(c);
}
}
public static <K, V> NavigableMap<K, V> unmodifiableNavigableMap(NavigableMap<K, V> m) {
if (VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return Collections.unmodifiableNavigableMap(m);
} else {
return new UnmodifiableNavigableMapBackport<>(m);
}
}
public static <K, V> Map<K, V> unmodifiableMap(Map<K, V> m) {
if (VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return Collections.unmodifiableMap(m);
} else {
return new UnmodifiableMapBackport<>(m);
}
}
public static <T> NavigableSet<T> emptyNavigableSet() {
//noinspection unchecked
return (NavigableSet<T>) UnmodifiableNavigableSetBackport.EMPTY_NAVIGABLE_SET;
}
public static <K, V> NavigableMap<K, V> emptyNavigableMap() {
//noinspection unchecked
return (NavigableMap<K, V>) UnmodifiableNavigableMapBackport.EMPTY_NAVIGABLE_MAP;
}
static boolean eq(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
}
static class UnmodifiableNavigableSetBackport<E>
extends UnmodifiableSortedSetBackport<E>
implements NavigableSet<E>, Serializable {
/**
* A singleton empty unmodifiable navigable set used for
* {@link #emptyNavigableSet()}.
*
* @param <E> type of elements, if there were any, and bounds
*/
private static class EmptyNavigableSet<E> extends UnmodifiableNavigableSetBackport<E>
implements Serializable {
public EmptyNavigableSet() {
super(new TreeSet<>());
}
@java.io.Serial
private Object readResolve() {
return EMPTY_NAVIGABLE_SET;
}
}
@SuppressWarnings("rawtypes")
private static final NavigableSet<?> EMPTY_NAVIGABLE_SET =
new EmptyNavigableSet<>();
/**
* The instance we are protecting.
*/
@SuppressWarnings("serial") // Conditionally serializable
private final NavigableSet<E> ns;
UnmodifiableNavigableSetBackport(NavigableSet<E> s) {
super(s);
ns = s;
}
public E lower(E e) {
return ns.lower(e);
}
public E floor(E e) {
return ns.floor(e);
}
public E ceiling(E e) {
return ns.ceiling(e);
}
public E higher(E e) {
return ns.higher(e);
}
public E pollFirst() {
throw new UnsupportedOperationException();
}
public E pollLast() {
throw new UnsupportedOperationException();
}
public NavigableSet<E> descendingSet() {
return new UnmodifiableNavigableSetBackport<>(ns.descendingSet());
}
public Iterator<E> descendingIterator() {
return descendingSet().iterator();
}
public NavigableSet<E> subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive) {
return new UnmodifiableNavigableSetBackport<>(
ns.subSet(fromElement, fromInclusive, toElement, toInclusive));
}
public NavigableSet<E> headSet(E toElement, boolean inclusive) {
return new UnmodifiableNavigableSetBackport<>(
ns.headSet(toElement, inclusive));
}
public NavigableSet<E> tailSet(E fromElement, boolean inclusive) {
return new UnmodifiableNavigableSetBackport<>(
ns.tailSet(fromElement, inclusive));
}
}
static class UnmodifiableSortedSetBackport<E>
extends UnmodifiableSetBackport<E>
implements SortedSet<E>, Serializable {
@SuppressWarnings("serial") // Conditionally serializable
private final SortedSet<E> ss;
UnmodifiableSortedSetBackport(SortedSet<E> s) {
super(s);
ss = s;
}
public Comparator<? super E> comparator() {
return ss.comparator();
}
public SortedSet<E> subSet(E fromElement, E toElement) {
return new UnmodifiableSortedSetBackport<>(ss.subSet(fromElement, toElement));
}
public SortedSet<E> headSet(E toElement) {
return new UnmodifiableSortedSetBackport<>(ss.headSet(toElement));
}
public SortedSet<E> tailSet(E fromElement) {
return new UnmodifiableSortedSetBackport<>(ss.tailSet(fromElement));
}
public E first() {
return ss.first();
}
public E last() {
return ss.last();
}
}
static class UnmodifiableSetBackport<E> extends UnmodifiableCollectionBackport<E>
implements Set<E>, Serializable {
UnmodifiableSetBackport(Set<? extends E> s) {
super(s);
}
public boolean equals(Object o) {
return o == this || c.equals(o);
}
public int hashCode() {
return c.hashCode();
}
}
static class UnmodifiableCollectionBackport<E> implements Collection<E>, Serializable {
@SuppressWarnings("serial") // Conditionally serializable
final Collection<? extends E> c;
UnmodifiableCollectionBackport(Collection<? extends E> c) {
if (c == null)
throw new NullPointerException();
this.c = c;
}
public int size() {
return c.size();
}
public boolean isEmpty() {
return c.isEmpty();
}
public boolean contains(Object o) {
return c.contains(o);
}
public Object[] toArray() {
return c.toArray();
}
public <T> T[] toArray(T[] a) {
return c.toArray(a);
}
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
public <T> T[] toArray(IntFunction<T[]> f) {
return c.toArray(f);
}
public String toString() {
return c.toString();
}
public Iterator<E> iterator() {
return new Iterator<E>() {
private final Iterator<? extends E> i = c.iterator();
public boolean hasNext() {
return i.hasNext();
}
public E next() {
return i.next();
}
public void remove() {
throw new UnsupportedOperationException();
}
@Override
public void forEachRemaining(Consumer<? super E> action) {
// Use backing collection version
i.forEachRemaining(action);
}
};
}
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public boolean remove(Object o) {
throw new UnsupportedOperationException();
}
public boolean containsAll(Collection<?> coll) {
return c.containsAll(coll);
}
public boolean addAll(Collection<? extends E> coll) {
throw new UnsupportedOperationException();
}
public boolean removeAll(Collection<?> coll) {
throw new UnsupportedOperationException();
}
public boolean retainAll(Collection<?> coll) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// Override default methods in Collection
@Override
public void forEach(Consumer<? super E> action) {
c.forEach(action);
}
@Override
public boolean removeIf(Predicate<? super E> filter) {
throw new UnsupportedOperationException();
}
@SuppressWarnings("unchecked")
@Override
public Spliterator<E> spliterator() {
return (Spliterator<E>) c.spliterator();
}
@RequiresApi(api = Build.VERSION_CODES.N)
@SuppressWarnings("unchecked")
@Override
public Stream<E> stream() {
return (Stream<E>) c.stream();
}
@RequiresApi(api = Build.VERSION_CODES.N)
@SuppressWarnings("unchecked")
@Override
public Stream<E> parallelStream() {
return (Stream<E>) c.parallelStream();
}
}
static class UnmodifiableNavigableMapBackport<K, V> extends UnmodifiableSortedMapBackport<K, V> implements NavigableMap<K, V>, Serializable {
private static final long serialVersionUID = -4858195264774772197L;
private static final EmptyNavigableMapBackport<?, ?> EMPTY_NAVIGABLE_MAP = new EmptyNavigableMapBackport();
private final NavigableMap<K, ? extends V> nm;
UnmodifiableNavigableMapBackport(NavigableMap<K, ? extends V> m) {
super(m);
this.nm = m;
}
public K lowerKey(K key) {
return this.nm.lowerKey(key);
}
public K floorKey(K key) {
return this.nm.floorKey(key);
}
public K ceilingKey(K key) {
return this.nm.ceilingKey(key);
}
public K higherKey(K key) {
return this.nm.higherKey(key);
}
public Map.Entry<K, V> lowerEntry(K key) {
Map.Entry<K, V> lower = (Entry<K, V>) this.nm.lowerEntry(key);
return null != lower ? new UnmodifiableMapBackport.UnmodifiableEntrySetBackport.UnmodifiableEntry(lower) : null;
}
public Map.Entry<K, V> floorEntry(K key) {
Map.Entry<K, V> floor = (Entry<K, V>) this.nm.floorEntry(key);
return null != floor ? new UnmodifiableMapBackport.UnmodifiableEntrySetBackport.UnmodifiableEntry(floor) : null;
}
public Map.Entry<K, V> ceilingEntry(K key) {
Map.Entry<K, V> ceiling = (Entry<K, V>) this.nm.ceilingEntry(key);
return null != ceiling ? new UnmodifiableMapBackport.UnmodifiableEntrySetBackport.UnmodifiableEntry(ceiling) : null;
}
public Map.Entry<K, V> higherEntry(K key) {
Map.Entry<K, V> higher = (Entry<K, V>) this.nm.higherEntry(key);
return null != higher ? new UnmodifiableMapBackport.UnmodifiableEntrySetBackport.UnmodifiableEntry(higher) : null;
}
public Map.Entry<K, V> firstEntry() {
Map.Entry<K, V> first = (Entry<K, V>) this.nm.firstEntry();
return null != first ? new UnmodifiableMapBackport.UnmodifiableEntrySetBackport.UnmodifiableEntry(first) : null;
}
public Map.Entry<K, V> lastEntry() {
Map.Entry<K, V> last = (Entry<K, V>) this.nm.lastEntry();
return null != last ? new UnmodifiableMapBackport.UnmodifiableEntrySetBackport.UnmodifiableEntry(last) : null;
}
public Map.Entry<K, V> pollFirstEntry() {
throw new UnsupportedOperationException();
}
public Map.Entry<K, V> pollLastEntry() {
throw new UnsupportedOperationException();
}
public NavigableMap<K, V> descendingMap() {
return (NavigableMap<K, V>) CollectionsBackport.unmodifiableNavigableMap(this.nm.descendingMap());
}
public NavigableSet<K> navigableKeySet() {
return CollectionsBackport.unmodifiableNavigableSet(this.nm.navigableKeySet());
}
public NavigableSet<K> descendingKeySet() {
return CollectionsBackport.unmodifiableNavigableSet(this.nm.descendingKeySet());
}
public NavigableMap<K, V> subMap(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) {
return (NavigableMap<K, V>) CollectionsBackport.unmodifiableNavigableMap(this.nm.subMap(fromKey, fromInclusive, toKey, toInclusive));
}
public NavigableMap<K, V> headMap(K toKey, boolean inclusive) {
return (NavigableMap<K, V>) CollectionsBackport.unmodifiableNavigableMap(this.nm.headMap(toKey, inclusive));
}
public NavigableMap<K, V> tailMap(K fromKey, boolean inclusive) {
return (NavigableMap<K, V>) CollectionsBackport.unmodifiableNavigableMap(this.nm.tailMap(fromKey, inclusive));
}
private static class EmptyNavigableMapBackport<K, V> extends UnmodifiableNavigableMapBackport<K, V> implements Serializable {
private static final long serialVersionUID = -2239321462712562324L;
EmptyNavigableMapBackport() {
super(new TreeMap());
}
public NavigableSet<K> navigableKeySet() {
return CollectionsBackport.emptyNavigableSet();
}
private Object readResolve() {
return UnmodifiableNavigableMapBackport.EMPTY_NAVIGABLE_MAP;
}
}
}
static class UnmodifiableSortedMapBackport<K,V>
extends UnmodifiableMapBackport<K,V>
implements SortedMap<K,V>, Serializable {
@SuppressWarnings("serial") // Conditionally serializable
private final SortedMap<K, ? extends V> sm;
UnmodifiableSortedMapBackport(SortedMap<K, ? extends V> m) {super(m); sm = m; }
public Comparator<? super K> comparator() { return sm.comparator(); }
public SortedMap<K,V> subMap(K fromKey, K toKey)
{ return new UnmodifiableSortedMapBackport<>(sm.subMap(fromKey, toKey)); }
public SortedMap<K,V> headMap(K toKey)
{ return new UnmodifiableSortedMapBackport<>(sm.headMap(toKey)); }
public SortedMap<K,V> tailMap(K fromKey)
{ return new UnmodifiableSortedMapBackport<>(sm.tailMap(fromKey)); }
public K firstKey() { return sm.firstKey(); }
public K lastKey() { return sm.lastKey(); }
}
private static class UnmodifiableMapBackport<K, V> implements Map<K, V>, Serializable {
@java.io.Serial
private static final long serialVersionUID = -1034234728574286014L;
@SuppressWarnings("serial") // Conditionally serializable
private final Map<? extends K, ? extends V> m;
UnmodifiableMapBackport(Map<? extends K, ? extends V> m) {
if (m == null)
throw new NullPointerException();
this.m = m;
}
public int size() {
return m.size();
}
public boolean isEmpty() {
return m.isEmpty();
}
public boolean containsKey(Object key) {
return m.containsKey(key);
}
public boolean containsValue(Object val) {
return m.containsValue(val);
}
public V get(Object key) {
return m.get(key);
}
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
public V remove(Object key) {
throw new UnsupportedOperationException();
}
public void putAll(Map<? extends K, ? extends V> m) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
private transient Set<K> keySet;
private transient Set<Map.Entry<K, V>> entrySet;
private transient Collection<V> values;
public Set<K> keySet() {
if (keySet == null)
keySet = (Set<K>) unmodifiableSet(m.keySet());
return keySet;
}
public Set<Map.Entry<K, V>> entrySet() {
if (entrySet == null)
entrySet = new UnmodifiableEntrySetBackport<>(m.entrySet());
return entrySet;
}
public Collection<V> values() {
if (values == null)
values = (Collection<V>) unmodifiableCollection(m.values());
return values;
}
public boolean equals(Object o) {
return o == this || m.equals(o);
}
public int hashCode() {
return m.hashCode();
}
public String toString() {
return m.toString();
}
// Override default methods in Map
@Override
@SuppressWarnings("unchecked")
public V getOrDefault(Object k, V defaultValue) {
// Safe cast as we don't change the value
return ((Map<K, V>) m).getOrDefault(k, defaultValue);
}
@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
m.forEach(action);
}
@Override
public void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) {
throw new UnsupportedOperationException();
}
@Override
public V putIfAbsent(K key, V value) {
throw new UnsupportedOperationException();
}
@Override
public boolean remove(Object key, Object value) {
throw new UnsupportedOperationException();
}
@Override
public boolean replace(K key, V oldValue, V newValue) {
throw new UnsupportedOperationException();
}
@Override
public V replace(K key, V value) {
throw new UnsupportedOperationException();
}
@Override
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
throw new UnsupportedOperationException();
}
@Override
public V computeIfPresent(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
throw new UnsupportedOperationException();
}
@Override
public V compute(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
throw new UnsupportedOperationException();
}
@Override
public V merge(K key, V value,
BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
throw new UnsupportedOperationException();
}
/**
* We need this class in addition to UnmodifiableSet as
* Map.Entries themselves permit modification of the backing Map
* via their setValue operation. This class is subtle: there are
* many possible attacks that must be thwarted.
*
* @serial include
*/
static class UnmodifiableEntrySetBackport<K, V>
extends UnmodifiableSetBackport<Map.Entry<K, V>> {
@java.io.Serial
private static final long serialVersionUID = 7854390611657943733L;
@SuppressWarnings({"unchecked", "rawtypes"})
UnmodifiableEntrySetBackport(Set<? extends Map.Entry<? extends K, ? extends V>> s) {
// Need to cast to raw in order to work around a limitation in the type system
super((Set) s);
}
static <K, V> Consumer<Map.Entry<? extends K, ? extends V>> entryConsumer(
Consumer<? super Entry<K, V>> action) {
return e -> action.accept(new UnmodifiableEntry<>(e));
}
public void forEach(Consumer<? super Entry<K, V>> action) {
Objects.requireNonNull(action);
c.forEach(entryConsumer(action));
}
static final class UnmodifiableEntrySetSpliterator<K, V>
implements Spliterator<Entry<K, V>> {
final Spliterator<Map.Entry<K, V>> s;
UnmodifiableEntrySetSpliterator(Spliterator<Entry<K, V>> s) {
this.s = s;
}
@Override
public boolean tryAdvance(Consumer<? super Entry<K, V>> action) {
Objects.requireNonNull(action);
return s.tryAdvance(entryConsumer(action));
}
@Override
public void forEachRemaining(Consumer<? super Entry<K, V>> action) {
Objects.requireNonNull(action);
s.forEachRemaining(entryConsumer(action));
}
@Override
public Spliterator<Entry<K, V>> trySplit() {
Spliterator<Entry<K, V>> split = s.trySplit();
return split == null
? null
: new UnmodifiableEntrySetSpliterator<>(split);
}
@Override
public long estimateSize() {
return s.estimateSize();
}
@Override
public long getExactSizeIfKnown() {
return s.getExactSizeIfKnown();
}
@Override
public int characteristics() {
return s.characteristics();
}
@Override
public boolean hasCharacteristics(int characteristics) {
return s.hasCharacteristics(characteristics);
}
@Override
public Comparator<? super Entry<K, V>> getComparator() {
return s.getComparator();
}
}
@SuppressWarnings("unchecked")
public Spliterator<Entry<K, V>> spliterator() {
return new UnmodifiableEntrySetSpliterator<>(
(Spliterator<Map.Entry<K, V>>) c.spliterator());
}
@Override
public Stream<Entry<K, V>> stream() {
return StreamSupport.stream(spliterator(), false);
}
@Override
public Stream<Entry<K, V>> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}
public Iterator<Map.Entry<K, V>> iterator() {
return new Iterator<Map.Entry<K, V>>() {
private final Iterator<? extends Map.Entry<? extends K, ? extends V>> i = c.iterator();
public boolean hasNext() {
return i.hasNext();
}
public Map.Entry<K, V> next() {
return new UnmodifiableEntry<>(i.next());
}
public void remove() {
throw new UnsupportedOperationException();
}
// Seems like an oversight. http://b/110351017
public void forEachRemaining(Consumer<? super Map.Entry<K, V>> action) {
i.forEachRemaining(entryConsumer(action));
}
};
}
@SuppressWarnings("unchecked")
public Object[] toArray() {
Object[] a = c.toArray();
for (int i = 0; i < a.length; i++)
a[i] = new UnmodifiableEntry<>((Map.Entry<? extends K, ? extends V>) a[i]);
return a;
}
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) {
// We don't pass a to c.toArray, to avoid window of
// vulnerability wherein an unscrupulous multithreaded client
// could get his hands on raw (unwrapped) Entries from c.
Object[] arr = c.toArray(a.length == 0 ? a : Arrays.copyOf(a, 0));
for (int i = 0; i < arr.length; i++)
arr[i] = new UnmodifiableEntry<>((Map.Entry<? extends K, ? extends V>) arr[i]);
if (arr.length > a.length)
return (T[]) arr;
System.arraycopy(arr, 0, a, 0, arr.length);
if (a.length > arr.length)
a[arr.length] = null;
return a;
}
/**
* This method is overridden to protect the backing set against
* an object with a nefarious equals function that senses
* that the equality-candidate is Map.Entry and calls its
* setValue method.
*/
public boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
return c.contains(
new UnmodifiableEntry<>((Map.Entry<?, ?>) o));
}
/**
* The next two methods are overridden to protect against
* an unscrupulous List whose contains(Object o) method senses
* when o is a Map.Entry, and calls o.setValue.
*/
public boolean containsAll(Collection<?> coll) {
for (Object e : coll) {
if (!contains(e)) // Invokes safe contains() above
return false;
}
return true;
}
public boolean equals(Object o) {
if (o == this)
return true;
// Android-changed: (b/247094511) instanceof pattern variable is not yet supported.
/*
return o instanceof Set<?> s
&& s.size() == c.size()
&& containsAll(s); // Invokes safe containsAll() above
*/
if (!(o instanceof Set))
return false;
Set<?> s = (Set<?>) o;
if (s.size() != c.size())
return false;
return containsAll(s); // Invokes safe containsAll() above
}
/**
* This "wrapper class" serves two purposes: it prevents
* the client from modifying the backing Map, by short-circuiting
* the setValue method, and it protects the backing Map against
* an ill-behaved Map.Entry that attempts to modify another
* Map Entry when asked to perform an equality check.
*/
private static class UnmodifiableEntry<K, V> implements Map.Entry<K, V> {
private Map.Entry<? extends K, ? extends V> e;
UnmodifiableEntry(Map.Entry<? extends K, ? extends V> e) {
this.e = Objects.requireNonNull(e);
}
public K getKey() {
return e.getKey();
}
public V getValue() {
return e.getValue();
}
public V setValue(V value) {
throw new UnsupportedOperationException();
}
public int hashCode() {
return e.hashCode();
}
public boolean equals(Object o) {
if (this == o)
return true;
// Android-changed: (b/247094511) instanceof pattern variable is not yet
// supported.
/*
return o instanceof Map.Entry<?, ?> t
&& eq(e.getKey(), t.getKey())
&& eq(e.getValue(), t.getValue());
*/
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?, ?> t = (Map.Entry<?, ?>) o;
return eq(e.getKey(), t.getKey()) &&
eq(e.getValue(), t.getValue());
}
public String toString() {
return e.toString();
}
}
}
}
}

View File

@@ -1,42 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 Daniel Weigl <DanielWeigl@gmx.at>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers;
public class SafeTextChecker {
private final String safeChars;
private final Integer maxLength;
public SafeTextChecker(String safeChars, Integer maxLength) {
this.safeChars = safeChars;
this.maxLength = maxLength;
}
// is used by the SendKeystrokes functionality to evaluate if a to-be-send text is safe for
// sending without user confirmation
// only allow sending text that can not harm any connected desktop (like "format c:\n" / "rm -rf\n",...)
public boolean isSafe(String content) {
if (content == null) {
return false;
}
if (content.length() > maxLength) {
return false;
}
for (int i = 0; i < content.length(); i++) {
String charAtPos = content.substring(i, i + 1);
if (!safeChars.contains(charAtPos)) {
return false;
}
}
// we are happy with the string
return true;
}
}

View File

@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2021 Daniel Weigl <DanielWeigl@gmx.at>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers
class SafeTextChecker {
private val safeChars: String
private val maxLength: Int
constructor(safeChars: String, maxLength: Int) {
this.safeChars = safeChars
this.maxLength = maxLength
}
// is used by the SendKeystrokes functionality to evaluate if a to-be-send text is safe for
// sending without user confirmation
// only allow sending text that can not harm any connected desktop (like "format c:\n" / "rm -rf\n",...)
fun isSafe(content: String?): Boolean {
return content != null &&
content.length <= maxLength &&
content.toCharArray().all { c -> c in safeChars }
}
}

View File

@@ -137,10 +137,9 @@ public class SslHelper {
nameBuilder.addRDN(BCStyle.CN, deviceId);
nameBuilder.addRDN(BCStyle.OU, "KDE Connect");
nameBuilder.addRDN(BCStyle.O, "KDE");
final LocalDate localDate = LocalDate.now().minusYears(1);
final Instant notBefore = localDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
final Instant notAfter = localDate.plusYears(10).atStartOfDay(ZoneId.systemDefault())
.toInstant();
final LocalDate localDate = LocalDate.now();
final Instant notBefore = localDate.minusYears(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
final Instant notAfter = localDate.plusYears(10).atStartOfDay(ZoneId.systemDefault()).toInstant();
X509v3CertificateBuilder certificateBuilder = new JcaX509v3CertificateBuilder(
nameBuilder.build(),
BigInteger.ONE,

View File

@@ -1,98 +0,0 @@
/*
* SPDX-FileCopyrightText: 2019 Juan David Vega <jdvr.93@hotmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.net.wifi.SupplicantState;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log;
import androidx.core.content.ContextCompat;
import org.apache.commons.lang3.ArrayUtils;
import java.util.List;
public class TrustedNetworkHelper {
private static final String KEY_CUSTOM_TRUSTED_NETWORKS = "trusted_network_preference";
private static final String KEY_CUSTOM_TRUST_ALL_NETWORKS = "trust_all_network_preference";
private static final String NETWORK_SSID_DELIMITER = "#_#";
private static final String NOT_AVAILABLE_SSID_RESULT = "<unknown ssid>";
private final Context context;
public TrustedNetworkHelper(Context context) {
this.context = context;
}
public String[] read() {
String serializeTrustedNetwork = PreferenceManager.getDefaultSharedPreferences(context).getString(
KEY_CUSTOM_TRUSTED_NETWORKS, "");
if (serializeTrustedNetwork.isEmpty())
return ArrayUtils.EMPTY_STRING_ARRAY;
return serializeTrustedNetwork.split(NETWORK_SSID_DELIMITER);
}
public void update(List<String> trustedNetworks) {
String serialized = TextUtils.join(NETWORK_SSID_DELIMITER, trustedNetworks);
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(
KEY_CUSTOM_TRUSTED_NETWORKS, serialized).apply();
}
public boolean allAllowed() {
if (!hasPermissions()) {
return true;
}
return PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean(KEY_CUSTOM_TRUST_ALL_NETWORKS, Boolean.TRUE);
}
public void allAllowed(boolean isChecked) {
PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putBoolean(KEY_CUSTOM_TRUST_ALL_NETWORKS, isChecked)
.apply();
}
public boolean hasPermissions() {
int result = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION);
return (result == PackageManager.PERMISSION_GRANTED);
}
public String currentSSID() {
WifiManager wifiManager = ContextCompat.getSystemService(context.getApplicationContext(),
WifiManager.class);
if (wifiManager == null) return "";
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
if (wifiInfo.getSupplicantState() != SupplicantState.COMPLETED) {
return "";
}
String ssid = wifiInfo.getSSID();
if (ssid.equalsIgnoreCase(NOT_AVAILABLE_SSID_RESULT)){
Log.d("TrustedNetworkHelper", "Current SSID is unknown");
return "";
}
return ssid;
}
public static boolean isTrustedNetwork(Context context) {
TrustedNetworkHelper trustedNetworkHelper = new TrustedNetworkHelper(context);
if (trustedNetworkHelper.allAllowed()){
return true;
}
return ArrayUtils.contains(trustedNetworkHelper.read(), trustedNetworkHelper.currentSSID());
}
}

View File

@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2024 TPJ Schikhof <kde@schikhof.eu>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.wifi.SupplicantState
import android.net.wifi.WifiManager
import android.preference.PreferenceManager
import android.util.Log
import androidx.core.content.ContextCompat
class TrustedNetworkHelper(private val context: Context) {
var trustedNetworks: List<String>
get() {
val serializedNetworks = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_CUSTOM_TRUSTED_NETWORKS, "") ?: ""
return NETWORK_SSID_DELIMITER.split(serializedNetworks).filter { it.isNotEmpty() }
}
set(value) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putString(KEY_CUSTOM_TRUSTED_NETWORKS, value.joinToString(NETWORK_SSID_DELIMITER))
.apply()
}
var allNetworksAllowed: Boolean
get() = !hasPermissions || PreferenceManager.getDefaultSharedPreferences(context).getBoolean(KEY_CUSTOM_TRUST_ALL_NETWORKS, true)
set(value) = PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putBoolean(KEY_CUSTOM_TRUST_ALL_NETWORKS, value)
.apply()
val hasPermissions: Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
/** @return The current SSID or null if it's not available for any reason */
val currentSSID: String?
get() {
val wifiManager = ContextCompat.getSystemService(context.applicationContext, WifiManager::class.java) ?: return null
val wifiInfo = wifiManager.connectionInfo
if (wifiInfo.supplicantState != SupplicantState.COMPLETED) return null
val ssid = wifiInfo.ssid
return when {
ssid.equals(NOT_AVAILABLE_SSID_RESULT, ignoreCase = true) -> {
Log.d("TrustedNetworkHelper", "Current SSID is unknown")
null
}
ssid.isBlank() -> null
else -> ssid
}
}
val isTrustedNetwork: Boolean
get() = this.allNetworksAllowed || this.currentSSID in this.trustedNetworks
companion object {
private const val KEY_CUSTOM_TRUSTED_NETWORKS = "trusted_network_preference"
private const val KEY_CUSTOM_TRUST_ALL_NETWORKS = "trust_all_network_preference"
private const val NETWORK_SSID_DELIMITER = "#_#"
private const val NOT_AVAILABLE_SSID_RESULT = "<unknown ssid>"
@JvmStatic
fun isTrustedNetwork(context: Context): Boolean = TrustedNetworkHelper(context).isTrustedNetwork
}
}

View File

@@ -1,151 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Soul Trace <S-trace@list.ru>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers;
import org.apache.commons.lang3.StringUtils;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Locale;
public class VideoUrlsHelper {
private static final int SECONDS_IN_MINUTE = 60;
private static final int MINUTES_IN_HOUR = 60;
private static final int SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR;
public static URL formatUriWithSeek(String address, long position)
throws MalformedURLException {
URL url = new URL(address);
position /= 1000; // Convert ms to seconds
if (position <= 0) {
return url; // nothing to do
}
String host = url.getHost().toLowerCase();
// Most common settings as defaults:
String parameter = "t="; // Characters before timestamp
String timestamp = Long.toString(position); // Timestamp itself
String trailer = ""; // Characters after timestamp
// true - search/add to query URL part (between ? and # signs),
// false - search/add timestamp to ref (anchor) URL part (after # sign),
boolean inQuery = true;
// true - We know how to format URL with seek timestamp, false - not
boolean seekUrl = false;
// Override defaults if necessary
if (StringUtils.containsAny(host, "youtube.com", "youtu.be", "pornhub.com")) {
seekUrl = true;
url = stripTimestampS(url, parameter, trailer, inQuery);
} else if (host.contains("vimeo.com")) {
seekUrl = true;
trailer = "s";
url = stripTimestampS(url, parameter, trailer, inQuery);
} else if (host.contains("dailymotion.com")) {
seekUrl = true;
parameter = "start=";
url = stripTimestampS(url, parameter, trailer, inQuery);
} else if (host.contains("twitch.tv")) {
seekUrl = true;
timestamp = formatTimestampHMS(position, true);
url = stripTimestampHMS(url, parameter, trailer, inQuery);
}
if (seekUrl) {
url = formatUrlWithSeek(url, timestamp, parameter, trailer, inQuery);
}
return url;
}
// Returns timestamp in 1h2m34s or 01h02m34s (according to padWithZeroes)
private static String formatTimestampHMS(long seconds, boolean padWithZeroes) {
if (seconds == 0) {
return "0s";
}
int sec = (int) (seconds % SECONDS_IN_MINUTE);
int min = (int) ((seconds / SECONDS_IN_MINUTE) % MINUTES_IN_HOUR);
int hour = (int) (seconds / SECONDS_IN_HOUR);
String hours = hour > 0 ? hour + "h" : "";
String mins = min > 0 || hour > 0 ? min + "m" : "";
String secs = sec + "s";
String value;
if (padWithZeroes) {
String hoursPad = hour > 9 ? "" : "0";
String minsPad = min > 9 ? "" : "0";
String secsPad = sec > 9 ? "" : "0";
value = hoursPad + hours + minsPad + mins + secsPad + secs;
} else {
value = hours + mins + secs;
}
return value;
}
// Remove timestamp in 01h02m34s or 1h2m34s or 02m34s or 2m34s or 01s or 1s format.
// Can also nandle rimestamps in 1234s format if called with 's' trailer
private static URL stripTimestampHMS(URL url, String parameter, String trailer, boolean inQuery)
throws MalformedURLException {
String regex = parameter + "([\\d]+[hH])?([\\d]+[mM])?[\\d]+[sS]" + trailer + "&?";
return stripTimestampCommon(url, inQuery, regex);
}
// Remove timestamp in 1234 format
private static URL stripTimestampS(URL url, String parameter, String trailer, boolean inQuery)
throws MalformedURLException {
String regex = parameter + "[\\d]+" + trailer + "&?";
return stripTimestampCommon(url, inQuery, regex);
}
private static URL stripTimestampCommon(URL url, boolean inQuery, String regex)
throws MalformedURLException {
String value;
if (inQuery) {
value = url.getQuery();
} else {
value = url.getRef();
}
if (value == null) {
return url;
}
String newValue = value.replaceAll(regex, "");
String replaced = url.toString().replaceFirst(value, newValue);
if (inQuery && replaced.endsWith("&")) {
replaced = replaced.substring(0, replaced.length() - 1);
}
return new URL(replaced);
}
private static URL formatUrlWithSeek(URL url, String position, String parameter, String trailer,
boolean inQuery) throws MalformedURLException {
String value;
String separator;
String newValue;
if (inQuery) {
value = url.getQuery();
separator = "?";
} else {
value = url.getRef();
separator = "#";
}
if (value == null) {
newValue = String.format(Locale.getDefault(), "%s%s%s%s%s",
url, separator, parameter, position, trailer);
return new URL(newValue);
}
if (inQuery) {
newValue = String.format(Locale.getDefault(), "%s&%s%s%s",
value, parameter, position, trailer);
} else {
newValue = String.format(Locale.getDefault(), "%s%s%s",
parameter, position, trailer);
}
return new URL(url.toString().replaceFirst(value, newValue));
}
}

View File

@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2024 TPJ Schikhof <kde@schikhof.eu>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers
import java.net.MalformedURLException
import java.net.URL
object VideoUrlsHelper {
private const val SECONDS_IN_MINUTE = 60
private const val MINUTES_IN_HOUR = 60
private const val SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR
@Throws(MalformedURLException::class)
fun formatUriWithSeek(address: String, position: Long): URL {
val positionSeconds = position / 1000 // milliseconds to seconds
val url = URL(address)
if (positionSeconds <= 0) {
return url // nothing to do
}
val host = url.host.lowercase()
return when {
listOf("youtube.com", "youtu.be", "pornhub.com").any { site -> site in host } -> {
url.editParameter("t", Regex("\\d+")) { "$positionSeconds" }
}
host.contains("vimeo.com") -> {
url.editParameter("t", Regex("\\d+s")) { "${positionSeconds}s" }
}
host.contains("dailymotion.com") -> {
url.editParameter("start", Regex("\\d+")) { "$positionSeconds" }
}
host.contains("twitch.tv") -> {
url.editParameter("t", Regex("(\\d+[hH])?(\\d+[mM])?\\d+[sS]")) { positionSeconds.formatTimestampHMS() }
}
else -> url
}
}
private fun URL.editParameter(parameter: CharSequence, valuePattern: Regex?, parameterValueModifier: (String) -> String): URL {
// "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13" -> ["https://www.youtube.com/watch", "v=ovX5G0O5ZvA&t=13"]
val urlSplit = this.toString().split("?")
if (urlSplit.size != 2) {
return this
}
val (urlBase, urlQuery) = urlSplit
val modifiedUrlQuery = urlQuery
.split("&") // "v=ovX5G0O5ZvA&t=13" -> ["v=ovX5G0O5ZvA", "t=13"]
.map { it.split("=", limit = 2) } // […, "t=13"] -> […, ["t", "13"]]
.map { Pair(it.first(), it.lastOrNull() ?: return this) }
.map { paramAndValue ->
// Modify matching parameter and optionally matches the old value with the provided pattern
if (paramAndValue.first == parameter && valuePattern?.matches(paramAndValue.second) != false) {
Pair(paramAndValue.first, parameterValueModifier(paramAndValue.second)) // ["t", "13"] -> ["t", result]
} else {
paramAndValue
}
}
.joinToString("&") { "${it.first}=${it.second}" } // [["v", "ovX5G0O5ZvA"], ["t", "14"]] -> "v=ovX5G0O5ZvA&t=14"
return URL("${urlBase}?${modifiedUrlQuery}") // -> "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=14"
}
/** Formats timestamp to e.g. 01h02m34s */
private fun Long.formatTimestampHMS(): String {
if (this == 0L) return "0s"
val seconds: Long = this % SECONDS_IN_MINUTE
val minutes: Long = (this / SECONDS_IN_MINUTE) % MINUTES_IN_HOUR
val hours: Long = this / SECONDS_IN_HOUR
fun pad(s: String) = s.padStart(3, '0')
val hoursText = if (hours > 0) pad("${hours}h") else ""
val minutesText = if (minutes > 0 || hours > 0) pad("${minutes}m") else ""
val secondsText = pad("${seconds}s")
return "${hoursText}${minutesText}${secondsText}"
}
}

View File

@@ -9,6 +9,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.ConnectivityManager
import android.net.Uri
import android.util.Log
import androidx.collection.LruCache
import androidx.core.content.getSystemService
@@ -24,7 +25,6 @@ import java.io.File
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL
import java.net.URLDecoder
import java.security.MessageDigest
@@ -53,12 +53,12 @@ internal object AlbumArtCache {
/**
* A list of urls yet to be fetched.
*/
private val fetchUrlList = ArrayList<URL>()
private val fetchUrlList = ArrayList<Uri>()
/**
* A list of urls currently being fetched
*/
private val isFetchingList = ArrayList<URL>()
private val isFetchingList = ArrayList<Uri>()
/**
* A integer indicating how many fetches are in progress.
@@ -70,6 +70,15 @@ internal object AlbumArtCache {
*/
private val registeredPlugins = CopyOnWriteArrayList<MprisPlugin>()
@JvmStatic
val ALLOWED_SCHEMES = listOf("http", "https", "file", "kdeconnect")
/**
* A list of art url schemes that require a fetch from remote side.
*/
@JvmStatic
private val REMOTE_FETCH_SCHEMES = listOf("file", "kdeconnect")
/**
* Initializes the disk cache. Needs to be called at least once before trying to use the cache
*
@@ -121,16 +130,10 @@ internal object AlbumArtCache {
if (albumUrl.isNullOrEmpty()) {
return null
}
val url = try {
URL(albumUrl)
} catch (e: MalformedURLException) {
//Invalid url, so just return "no album art"
//Shouldn't happen (checked on receival of the url), but just to be sure
return null
}
val url = Uri.parse(albumUrl)
//We currently only support http(s) and file urls
if (url.protocol !in arrayOf("http", "https", "file")) {
//We currently only support http(s), file, and kdeconnect urls
if (url.scheme !in ALLOWED_SCHEMES) {
return null
}
@@ -175,8 +178,8 @@ internal object AlbumArtCache {
/* If not found, we have not tried fetching it (recently), or a fetch is in-progress.
Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */
if ("file" == url.protocol) {
//Special-case file, since we need to fetch it from the remote
if (url.scheme in REMOTE_FETCH_SCHEMES) {
//Special-case file or kdeconnect, since we need to fetch it from the remote
if (url in isFetchingList) return null
if (!plugin.askTransferAlbumArt(albumUrl, player)) {
//It doesn't support transferring the art, so mark it as failed in the memory cache
@@ -193,7 +196,7 @@ internal object AlbumArtCache {
*
* @param url The url
*/
private fun fetchUrl(url: URL) {
private fun fetchUrl(url: Uri) {
//We need the disk cache for this
if (!this::diskCache.isInitialized) {
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
@@ -218,7 +221,7 @@ internal object AlbumArtCache {
* Does the actual fetching and makes sure only not too many fetches are running at the same time
*/
private fun initiateFetch() {
var url : URL;
var url : Uri;
synchronized(fetchUrlList) {
if (numFetching >= 2 || fetchUrlList.isEmpty()) return
//Fetch the last-requested url first, it will probably be needed first
@@ -226,8 +229,8 @@ internal object AlbumArtCache {
//Remove the url from the to-fetch list
fetchUrlList.remove(url)
}
if ("file" == url.protocol) {
throw AssertionError("Not file urls should be possible here!")
if (url.scheme in REMOTE_FETCH_SCHEMES) {
throw AssertionError("Only http(s) urls should be possible here!")
}
//Download the album art ourselves
@@ -266,7 +269,7 @@ internal object AlbumArtCache {
/**
* Transfer an asked-for album art payload to the disk cache.
*
* @param albumUrl The url of the album art (should be a file:// url)
* @param albumUrl The url of the album art (must be one of the [REMOTE_FETCH_SCHEMES])
* @param payload The payload input stream
*/
@JvmStatic
@@ -280,15 +283,10 @@ internal object AlbumArtCache {
payload.close()
return
}
val url = try {
URL(albumUrl)
} catch (e: MalformedURLException) {
val url = Uri.parse(albumUrl)
if (url.scheme !in REMOTE_FETCH_SCHEMES) {
//Shouldn't happen (checked on receival of the url), but just to be sure
payload.close()
return
}
if ("file" != url.protocol) {
//Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure
Log.e("KDE/Mpris/AlbumArtCache", "Got invalid art url with payload: $albumUrl")
payload.close()
return
}
@@ -341,7 +339,7 @@ internal object AlbumArtCache {
* @param payload A NetworkPacket Payload (if from the connected device). null if fetched from http(s)
* @param cacheItem The disk cache item to edit
*/
private suspend fun fetchURL(url: URL, payload: Payload?, cacheItem: DiskLruCache.Editor) {
private suspend fun fetchURL(url: Uri, payload: Payload?, cacheItem: DiskLruCache.Editor) {
var success = withContext(Dispatchers.IO) {
//See if we need to open a http(s) connection here, or if we use a payload input stream
val output = cacheItem.newOutputStream(0)
@@ -405,12 +403,13 @@ internal object AlbumArtCache {
*
* @return True if succeeded
*/
private fun openHttp(url: URL): InputStream? {
private fun openHttp(url: Uri): InputStream? {
//Default android behaviour does not follow https -> http urls, so do this manually
if (url.protocol !in arrayOf("http", "https")) {
if (url.scheme !in arrayOf("http", "https")) {
throw AssertionError("Invalid url: not http(s) in background album art fetch")
}
var currentUrl = url
// TODO: Should use contentResolver from android instead of opening our own connection
var currentUrl = URL(url.toString())
var connection: HttpURLConnection
loop@ for (i in 0..4) {
connection = currentUrl.openConnection() as HttpURLConnection

View File

@@ -32,7 +32,6 @@ import org.kde.kdeconnect.Plugins.PluginFactory.LoadablePlugin
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment
import org.kde.kdeconnect_tp.R
import java.net.MalformedURLException
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
@LoadablePlugin
@@ -278,11 +277,10 @@ class MprisPlugin : Plugin() {
playerStatus.isGoPreviousAllowed = np.getBoolean("canGoPrevious", playerStatus.isGoPreviousAllowed)
playerStatus.seekAllowed = np.getBoolean("canSeek", playerStatus.seekAllowed)
val newAlbumArtUrlString = np.getString("albumArtUrl", playerStatus.albumArtUrl)
try {
// Turn the url into canonical form (and check its validity)
val newAlbumArtUrl = URL(newAlbumArtUrlString)
val newAlbumArtUrl = Uri.parse(newAlbumArtUrlString)
if (newAlbumArtUrl.scheme in AlbumArtCache.ALLOWED_SCHEMES) {
playerStatus.albumArtUrl = newAlbumArtUrl.toString()
} catch (ignored: MalformedURLException) {
} else {
Log.w("MprisControl", "Invalid album art URL: $newAlbumArtUrlString")
playerStatus.albumArtUrl = ""
}

View File

@@ -6,26 +6,135 @@
package org.kde.kdeconnect.Plugins.MprisReceiverPlugin;
import android.graphics.Bitmap;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.PlaybackState;
import android.net.Uri;
import android.os.Build;
import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.Objects;
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1)
class MprisReceiverCallback extends MediaController.Callback {
private static final String TAG = "MprisReceiver";
private final MprisReceiverPlayer player;
private final MprisReceiverPlugin plugin;
private Long artHash = null;
private Bitmap displayArt = null;
private String artUrl = null;
private String album = null;
private String artist = null;
private static final String[] PREFERRED_BITMAP_ORDER = {
MediaMetadata.METADATA_KEY_DISPLAY_ICON,
MediaMetadata.METADATA_KEY_ART,
MediaMetadata.METADATA_KEY_ALBUM_ART
};
private static final String[] PREFERRED_URI_ORDER = {
MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI,
MediaMetadata.METADATA_KEY_ART_URI,
MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
// Fall back to album name if none of the above is set
MediaMetadata.METADATA_KEY_ALBUM,
// Youtube doesn't normally provide album info
MediaMetadata.METADATA_KEY_TITLE,
// Last option, use artist
MediaMetadata.METADATA_KEY_ALBUM_ARTIST,
MediaMetadata.METADATA_KEY_ARTIST,
};
static String encodeAsUri(String kind, String data) {
// there's probably a better way to do this, but meh
// TODO: do we want to include the player name?
return new Uri.Builder()
.scheme("kdeconnect")
.path("/artUri")
.appendQueryParameter(kind, data)
.build().toString();
}
/**
* Extract the art bitmap and corresponding uri from the media metadata.
*
* @return Pair of art,artUrl. May be null if either was not found.
*/
static Pair<Bitmap, String> getArtAndUri(MediaMetadata metadata) {
if (metadata == null) return null;
String uri = null;
Bitmap art = null;
for (String s : PREFERRED_BITMAP_ORDER) {
Bitmap next = metadata.getBitmap(s);
if (next != null) {
art = next;
break;
}
}
for (String s : PREFERRED_URI_ORDER) {
String next = metadata.getString(s);
if (next != null && !next.isEmpty()) {
String kind;
switch (s) {
case MediaMetadata.METADATA_KEY_ALBUM:
kind = "album";
break;
case MediaMetadata.METADATA_KEY_TITLE:
kind = "title";
break;
case MediaMetadata.METADATA_KEY_ARTIST:
case MediaMetadata.METADATA_KEY_ALBUM_ARTIST:
kind = "artist";
break;
default:
kind = "orig";
break;
}
uri = encodeAsUri(kind, next);
break;
}
}
if (art == null || uri == null) return null;
return new Pair<>(art, uri);
}
private static long hashBitmap(Bitmap bitmap) {
int[] buffer = new int[bitmap.getWidth() * bitmap.getHeight()];
bitmap.getPixels(buffer, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
return Arrays.hashCode(buffer);
}
private String makeArtUrl(long artHash, String artUrl) {
// we include the hash in the URL to handle the case when the player changes the bitmap
// without changing the url- the PC side won't know the art was modified if we don't do this
// also useful when the input url contains only the artist name (eg: Youtube)
return Uri.parse(artUrl)
.buildUpon()
.appendQueryParameter("kdeArtHash", String.valueOf(artHash))
.build()
.toString();
}
MprisReceiverCallback(MprisReceiverPlugin plugin, MprisReceiverPlayer player) {
this.player = player;
this.plugin = plugin;
// fetch the initial art, when player is already running and we start kdeconnect
Pair<Bitmap, String> artAndUri = getArtAndUri(player.getMetadata());
if (artAndUri != null) {
Bitmap bitmap = artAndUri.first;
artHash = hashBitmap(bitmap);
artUrl = makeArtUrl(artHash, artAndUri.second);
displayArt = bitmap;
album = player.getAlbum();
artist = player.getArtist();
}
}
@Override
@@ -35,6 +144,43 @@ class MprisReceiverCallback extends MediaController.Callback {
@Override
public void onMetadataChanged(@Nullable MediaMetadata metadata) {
if (metadata == null) {
artHash = null;
displayArt = null;
artUrl = null;
artist = null;
album = null;
} else {
// We could check hasRequestedAlbumArt to avoid hashing art for clients that don't support it
// But upon running the profiler, looks like hashBitmap is a minuscule (<1%) part so no
// need to optimize prematurely.
Pair<Bitmap, String> artAndUri = getArtAndUri(metadata);
String newAlbum = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM);
String newArtist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST);
if (artAndUri == null) {
// check if the album+artist is still the same- some players don't send art every time
if (!Objects.equals(newAlbum, album) || !Objects.equals(newArtist, artist)) {
// there really is no new art
artHash = null;
displayArt = null;
artUrl = null;
album = null;
artist = null;
}
} else {
Long newHash = hashBitmap(artAndUri.first);
// In case the hashes are equal, we do a full comparison to protect against collisions
if ((!newHash.equals(artHash) || !artAndUri.first.sameAs(displayArt))) {
artHash = newHash;
displayArt = artAndUri.first;
artUrl = makeArtUrl(artHash, artAndUri.second);
artist = newArtist;
album = newAlbum;
}
}
}
plugin.sendMetadata(player);
}
@@ -43,4 +189,22 @@ class MprisReceiverCallback extends MediaController.Callback {
//Note: not called by all media players
plugin.sendMetadata(player);
}
public String getArtUrl() {
return artUrl;
}
/**
* Get the JPG art of the current track as a bytearray.
*
* @return null if no art is available, otherwise a PNG image serialized into a bytearray
*/
public byte[] getArtAsArray() {
if (displayArt == null) {
return null;
}
ByteArrayOutputStream stream = new ByteArrayOutputStream();
displayArt.compress(Bitmap.CompressFormat.JPEG, 90, stream);
return stream.toByteArray();
}
}

View File

@@ -163,4 +163,8 @@ class MprisReceiverPlayer {
return metadata.getLong(MediaMetadata.METADATA_KEY_DURATION);
}
MediaMetadata getMetadata() {
return controller.getMetadata();
}
}

View File

@@ -23,6 +23,7 @@ import androidx.fragment.app.DialogFragment;
import org.apache.commons.lang3.StringUtils;
import org.kde.kdeconnect.Helpers.AppsHelper;
import org.kde.kdeconnect.Helpers.ThreadHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.NotificationsPlugin.NotificationReceiver;
import org.kde.kdeconnect.Plugins.Plugin;
@@ -49,12 +50,15 @@ public class MprisReceiverPlugin extends Plugin {
private HashMap<String, MprisReceiverCallback> playerCbs;
private MediaSessionChangeListener mediaSessionChangeListener;
public @NonNull String getDeviceId() {
return device.getDeviceId();
}
@Override
public boolean onCreate() {
if (!hasPermission())
return false;
players = new HashMap<>();
playerCbs = new HashMap<>();
try {
@@ -103,7 +107,6 @@ public class MprisReceiverPlugin extends Plugin {
@Override
public boolean onPacketReceived(@NonNull NetworkPacket np) {
if (np.getBoolean("requestPlayerList")) {
sendPlayerList();
return true;
@@ -117,6 +120,18 @@ public class MprisReceiverPlugin extends Plugin {
if (null == player) {
return false;
}
String artUrl = np.getString("albumArtUrl", "");
if (!artUrl.isEmpty()) {
String playerName = player.getName();
MprisReceiverCallback cb = playerCbs.get(playerName);
if (cb == null) {
Log.e(TAG, "no callback for " + playerName + " (player likely stopped)");
return false;
}
// run it on a different thread to avoid blocking
ThreadHelper.execute(() -> sendAlbumArt(playerName, cb, artUrl));
return true;
}
if (np.getBoolean("requestNowPlaying", false)) {
sendMetadata(player);
@@ -181,7 +196,7 @@ public class MprisReceiverPlugin extends Plugin {
return;
}
for (MprisReceiverPlayer p : players.values()) {
p.getController().unregisterCallback(playerCbs.get(p.getName()));
p.getController().unregisterCallback(Objects.requireNonNull(playerCbs.get(p.getName())));
}
playerCbs.clear();
players.clear();
@@ -206,6 +221,7 @@ public class MprisReceiverPlugin extends Plugin {
private void sendPlayerList() {
NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS);
np.set("playerList", players.keySet());
np.set("supportAlbumArtPayload", true);
getDevice().sendPacket(np);
}
@@ -214,6 +230,37 @@ public class MprisReceiverPlugin extends Plugin {
return Build.VERSION_CODES.LOLLIPOP_MR1;
}
void sendAlbumArt(String playerName, @NonNull MprisReceiverCallback cb, @Nullable String requestedUrl) {
// NOTE: It is possible that the player gets killed in the middle of this method.
// The proper thing to do this case would be to abort the send - but that gets into the
// territory of async cancellation or putting a lock.
// For now, we just continue to send the art- cb stores the bitmap, so it will be valid.
// cb will get GC'd after this method completes.
String localArtUrl = cb.getArtUrl();
if (localArtUrl == null) {
Log.w(TAG, "art not found!");
return;
}
String artUrl = requestedUrl == null ? localArtUrl : requestedUrl;
if (requestedUrl != null && !requestedUrl.contentEquals(localArtUrl)) {
Log.w(TAG, "sendAlbumArt: Doesn't match current url");
Log.d(TAG, "current: " + localArtUrl);
Log.d(TAG, "requested: " + requestedUrl);
return;
}
byte[] p = cb.getArtAsArray();
if (p == null) {
Log.w(TAG, "sendAlbumArt: Failed to get art stream");
return;
}
NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS);
np.setPayload(new NetworkPacket.Payload(p));
np.set("player", playerName);
np.set("transferringAlbumArt", true);
np.set("albumArtUrl", artUrl);
getDevice().sendPacket(np);
}
void sendMetadata(MprisReceiverPlayer player) {
NetworkPacket np = new NetworkPacket(MprisReceiverPlugin.PACKET_TYPE_MPRIS);
np.set("player", player.getName());
@@ -232,6 +279,15 @@ public class MprisReceiverPlugin extends Plugin {
np.set("canGoNext", player.canGoNext());
np.set("canSeek", player.canSeek());
np.set("volume", player.getVolume());
MprisReceiverCallback cb = playerCbs.get(player.getName());
assert cb != null;
String artUrl = cb.getArtUrl();
if (artUrl != null) {
np.set("albumArtUrl", artUrl);
Log.v(TAG, "Sending metadata with url " + artUrl);
} else {
Log.v(TAG, "Sending metadata without url ");
}
getDevice().sendPacket(np);
}

View File

@@ -106,6 +106,10 @@ public class NotificationsPlugin extends Plugin implements NotificationReceiver.
@Override
public boolean checkRequiredPermissions() {
return hasNotificationsPermission();
}
private boolean hasNotificationsPermission() {
//Notifications use a different kind of permission, because it was added before the current runtime permissions model
String notificationListenerList = Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners");
return StringUtils.contains(notificationListenerList, context.getPackageName());
@@ -506,11 +510,20 @@ public class NotificationsPlugin extends Plugin implements NotificationReceiver.
}
private void sendCurrentNotifications(NotificationReceiver service) {
StatusBarNotification[] notifications = service.getActiveNotifications();
if (notifications != null) { //Can happen only on API 23 and lower
for (StatusBarNotification notification : notifications) {
sendNotification(notification, true);
}
if (!hasNotificationsPermission()) {
return;
}
StatusBarNotification[] notifications;
try {
notifications = service.getActiveNotifications();
} catch (SecurityException e) {
return;
}
if (notifications == null) {
return; //Can happen only on API 23 and lower
}
for (StatusBarNotification notification : notifications) {
sendNotification(notification, true);
}
}

View File

@@ -49,12 +49,11 @@ class PresenterActivity : AppCompatActivity(), SensorEventListener {
if (offScreenControlsSupported) MediaSessionCompat(this, "kdeconnect") else null
}
private val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager }
private val plugin: PresenterPlugin by lazy {
KdeConnect.getInstance().getDevicePlugin(intent.getStringExtra("deviceId"), PresenterPlugin::class.java)!!
}
private lateinit var plugin : PresenterPlugin
//TODO: make configurable
private val sensitivity = 0.03f
override fun onSensorChanged(event: SensorEvent?) {
if (event?.sensor?.type == Sensor.TYPE_GYROSCOPE) {
val xPos = -event.values[2] * sensitivity
@@ -70,6 +69,7 @@ class PresenterActivity : AppCompatActivity(), SensorEventListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
plugin = KdeConnect.getInstance().getDevicePlugin(intent.getStringExtra("deviceId"), PresenterPlugin::class.java)!!
setContent { PresenterScreen() }
createMediaSession()
}

View File

@@ -1,44 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import org.apache.sshd.common.Session;
import org.apache.sshd.common.file.FileSystemFactory;
import org.apache.sshd.common.file.FileSystemView;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class AndroidFileSystemFactory implements FileSystemFactory {
final private Context context;
final Map<String, String> roots;
AndroidFileSystemFactory(Context context) {
this.context = context;
this.roots = new HashMap<>();
}
void initRoots(List<SftpPlugin.StorageInfo> storageInfoList) {
for (SftpPlugin.StorageInfo curStorageInfo : storageInfoList) {
if (curStorageInfo.isFileUri()) {
if (curStorageInfo.uri.getPath() != null){
roots.put(curStorageInfo.displayName, curStorageInfo.uri.getPath());
}
} else if (curStorageInfo.isContentUri()){
roots.put(curStorageInfo.displayName, curStorageInfo.uri.toString());
}
}
}
@Override
public FileSystemView createFileSystemView(final Session username) {
return new AndroidSafFileSystemView(roots, username.getUsername(), context);
}
}

View File

@@ -1,101 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.SshFile;
import org.apache.sshd.common.file.nativefs.NativeFileSystemView;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
class AndroidFileSystemView extends NativeFileSystemView {
final private String userName;
final private Context context;
private final Map<String, String> roots;
private final RootFile rootFile;
AndroidFileSystemView(Map<String, String> roots, String currentRoot, final String userName, Context context) {
super(userName, roots, currentRoot, File.separatorChar, true);
this.roots = roots;
this.userName = userName;
this.context = context;
this.rootFile = new RootFile( createFileList(), userName, true);
}
private List<SshFile> createFileList() {
List<SshFile> list = new ArrayList<>();
for (Map.Entry<String, String> entry : roots.entrySet()) {
String displayName = entry.getKey();
String path = entry.getValue();
list.add(createNativeSshFile(displayName, new File(path), userName));
}
return list;
}
@Override
public SshFile getFile(String file) {
return getFile("/", file);
}
@Override
public SshFile getFile(SshFile baseDir, String file) {
return getFile(baseDir.getAbsolutePath(), file);
}
@Override
protected SshFile getFile(String dir, String file) {
if (!dir.endsWith("/")) {
dir = dir + "/";
}
if (!file.startsWith("/")) {
file = dir + file;
}
String filename = NativeSshFile.getPhysicalName("/", "/", file, false);
if (filename.equals("/")) {
return rootFile;
}
for (String root : roots.keySet()) {
if (filename.indexOf(root) == 1) {
String nameWithoutRoot = filename.substring(root.length() + 1);
String path = roots.get(root);
if (nameWithoutRoot.isEmpty()) {
return createNativeSshFile(filename, new File(path), userName);
} else {
return createNativeSshFile(filename, new File(path, nameWithoutRoot), userName);
}
}
}
//It's a file under / but not one covered by any Tree
return new RootFile(new ArrayList<>(0), userName, false);
}
// NativeFileSystemView.getFile(), NativeSshFile.getParentFile() and NativeSshFile.listSshFiles() call
// createNativeSshFile to create new NativeSshFiles so override that instead of getFile() to always create an AndroidSshFile
@Override
public AndroidSshFile createNativeSshFile(String name, File file, String username) {
return new AndroidSshFile(this, name, file, username, context);
}
@Override
public FileSystemView getNormalizedView() {
return this;
}
}

View File

@@ -1,124 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.net.Uri;
import android.provider.DocumentsContract;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.SshFile;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class AndroidSafFileSystemView implements FileSystemView {
final String userName;
final Context context;
private final Map<String, String> roots;
private final RootFile rootFile;
AndroidSafFileSystemView(Map<String, String> roots, String userName, Context context) {
this.roots = roots;
this.userName = userName;
this.context = context;
this.rootFile = new RootFile( createFileList(), userName, true);
}
private List<SshFile> createFileList() {
List<SshFile> list = new ArrayList<>();
for (Map.Entry<String, String> entry : roots.entrySet()) {
String displayName = entry.getKey();
String uri = entry.getValue();
Uri treeUri = Uri.parse(uri);
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
list.add(createAndroidSafSshFile(null, documentUri, File.separatorChar + displayName));
}
return list;
}
@Override
public SshFile getFile(String file) {
return getFile("/", file);
}
@Override
public SshFile getFile(SshFile baseDir, String file) {
return getFile(baseDir.getAbsolutePath(), file);
}
protected SshFile getFile(String dir, String file) {
if (!dir.endsWith("/")) {
dir = dir + "/";
}
if (!file.startsWith("/")) {
file = dir + file;
}
String filename = NativeSshFile.getPhysicalName("/", "/", file, false);
if (filename.equals("/")) {
return rootFile;
}
for (String root : roots.keySet()) {
if (filename.indexOf(root) == 1) {
String nameWithoutRoot = filename.substring(root.length() + 1);
String pathOrUri = roots.get(root);
Uri treeUri = Uri.parse(pathOrUri);
if (nameWithoutRoot.isEmpty()) {
//TreeDocument
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
return createAndroidSafSshFile(documentUri, documentUri, filename);
} else {
/*
When sharing a root document tree like "Internal Storage" documentUri looks like:
content://com.android.externalstorage.documents/tree/primary:/document/primary:
For a file or folder beneath that the uri looks like:
content://com.android.externalstorage.documents/tree/primary:/document/primary:Folder/file.txt
Sharing a non root document tree the documentUri looks like:
content://com.android.externalstorage.documents/tree/primary:Download/document/primary:Download
For a file or folder beneath that the uri looks like:
content://com.android.externalstorage.documents/tree/primary:Download/document/primary:Download/Folder/file.txt
*/
String treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri);
File nameWithoutRootFile = new File(nameWithoutRoot);
String parentSuffix = nameWithoutRootFile.getParent();
String parentDocumentId = treeDocumentId + ("/".equals(parentSuffix) ? "" : parentSuffix);
Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocumentId);
String documentId = treeDocumentId + (treeDocumentId.endsWith(":") ? nameWithoutRoot.substring(1) : nameWithoutRoot);
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId);
return createAndroidSafSshFile(parentUri, documentUri, filename);
}
}
}
//It's a file under / but not one covered by any Tree
return new RootFile(new ArrayList<>(0), userName, false);
}
public AndroidSafSshFile createAndroidSafSshFile(Uri parentUri, Uri documentUri, String virtualFilename) {
return new AndroidSafSshFile(this, parentUri, documentUri, virtualFilename);
}
@Override
public FileSystemView getNormalizedView() {
return this;
}
}

View File

@@ -1,480 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import org.apache.sshd.common.file.SshFile;
import org.kde.kdeconnect.Helpers.FilesHelper;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class AndroidSafSshFile implements SshFile {
private static final String TAG = AndroidSafSshFile.class.getSimpleName();
private final String virtualFileName;
private DocumentInfo documentInfo;
private Uri parentUri;
private final AndroidSafFileSystemView fileSystemView;
AndroidSafSshFile(final AndroidSafFileSystemView fileSystemView, Uri parentUri, Uri uri, String virtualFileName) {
this.fileSystemView = fileSystemView;
this.parentUri = parentUri;
this.documentInfo = new DocumentInfo(fileSystemView.context, uri);
this.virtualFileName = virtualFileName;
}
@Override
public String getAbsolutePath() {
return virtualFileName;
}
@Override
public String getName() {
/* From NativeSshFile, looks a lot like new File(virtualFileName).getName() to me */
// strip the last '/'
String shortName = virtualFileName;
int filelen = virtualFileName.length();
if (shortName.charAt(filelen - 1) == File.separatorChar) {
shortName = shortName.substring(0, filelen - 1);
}
// return from the last '/'
int slashIndex = shortName.lastIndexOf(File.separatorChar);
if (slashIndex != -1) {
shortName = shortName.substring(slashIndex + 1);
}
return shortName;
}
@Override
public String getOwner() {
return fileSystemView.userName;
}
@Override
public boolean isDirectory() {
return documentInfo.isDirectory;
}
@Override
public boolean isFile() {
return documentInfo.isFile;
}
@Override
public boolean doesExist() {
return documentInfo.exists;
}
@Override
public long getSize() {
return documentInfo.length;
}
@Override
public long getLastModified() {
return documentInfo.lastModified;
}
@Override
public boolean setLastModified(long time) {
//TODO
/* Throws UnsupportedOperationException on API 26
try {
ContentValues updateValues = new ContentValues();
updateValues.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time);
result = fileSystemView.context.getContentResolver().update(documentInfo.uri, updateValues, null, null) != 0;
documentInfo.lastModified = time;
} catch (NullPointerException ignored) {}
*/
return true;
}
@Override
public boolean isReadable() {
return documentInfo.canRead;
}
@Override
public boolean isWritable() {
return documentInfo.canWrite;
}
@Override
public boolean isExecutable() {
return documentInfo.isDirectory;
}
@Override
public boolean isRemovable() {
Log.d(TAG, "isRemovable() - is this ever called?");
return false;
}
public SshFile getParentFile() {
Log.d(TAG,"getParentFile() - is this ever called");
return null;
}
@Override
public boolean delete() {
boolean ret;
try {
ret = DocumentsContract.deleteDocument(fileSystemView.context.getContentResolver(), documentInfo.uri);
} catch (FileNotFoundException e) {
ret = false;
}
return ret;
}
@Override
public boolean create() {
return create(parentUri, FilesHelper.getMimeTypeFromFile(virtualFileName), getName());
}
private boolean create(Uri parentUri, String mimeType, String name) {
Uri uri = null;
try {
uri = DocumentsContract.createDocument(fileSystemView.context.getContentResolver(), parentUri, mimeType, name);
if (uri != null) {
documentInfo = new DocumentInfo(fileSystemView.context, uri);
if (!name.equals(documentInfo.displayName)) {
delete();
return false;
}
}
} catch (FileNotFoundException ignored) {}
return uri != null;
}
@Override
public void truncate() {
if (documentInfo.length > 0) {
delete();
create();
}
}
@Override
public boolean move(final SshFile dest) {
boolean success = false;
Uri destParentUri = ((AndroidSafSshFile)dest).parentUri;
if (destParentUri.equals(parentUri)) {
//Rename
try {
Uri newUri = DocumentsContract.renameDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, dest.getName());
if (newUri != null) {
success = true;
documentInfo.uri = newUri;
}
} catch (FileNotFoundException ignored) {}
} else {
// Move:
String sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri);
String destTreeDocumentId = DocumentsContract.getTreeDocumentId(((AndroidSafSshFile) dest).parentUri);
if (sourceTreeDocumentId.equals(destTreeDocumentId) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
Uri newUri = DocumentsContract.moveDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, parentUri, destParentUri);
if (newUri != null) {
success = true;
parentUri = destParentUri;
documentInfo.uri = newUri;
}
} catch (Exception e) {
Log.e(TAG,"DocumentsContract.moveDocument() threw an exception", e);
}
} else {
try {
if (dest.create()) {
try (InputStream in = createInputStream(0); OutputStream out = dest.createOutputStream(0)) {
byte[] buffer = new byte[10 * 1024];
int read;
while ((read = in.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
out.flush();
delete();
success = true;
} catch (IOException e) {
if (dest.doesExist()) {
dest.delete();
}
}
}
} catch (IOException ignored) {}
}
}
return success;
}
@Override
public boolean mkdir() {
return create(parentUri, DocumentsContract.Document.MIME_TYPE_DIR, getName());
}
@Override
public List<SshFile> listSshFiles() {
if (!documentInfo.isDirectory) {
return null;
}
final ContentResolver resolver = fileSystemView.context.getContentResolver();
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(documentInfo.uri, DocumentsContract.getDocumentId(documentInfo.uri));
final ArrayList<AndroidSafSshFile> results = new ArrayList<>();
Cursor c = resolver.query(childrenUri, new String[]
{ DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME }, null, null, null);
while (c != null && c.moveToNext()) {
final String documentId = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
final String displayName = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(documentInfo.uri, documentId);
results.add(new AndroidSafSshFile(fileSystemView, parentUri, documentUri, virtualFileName + File.separatorChar + displayName));
}
if (c != null) {
c.close();
}
return Collections.unmodifiableList(results);
}
@Override
public OutputStream createOutputStream(final long offset) throws IOException {
if (offset != 0) {
throw new IOException("Seeking is not supported.");
}
return fileSystemView.context.getContentResolver().openOutputStream(documentInfo.uri);
}
@Override
public InputStream createInputStream(final long offset) throws IOException {
InputStream s = fileSystemView.context.getContentResolver().openInputStream(documentInfo.uri);
final long sought = s.skip(offset);
if (sought != offset) {
throw new IOException(String.format("Unable to seek %d bytes, sought %d bytes.", offset, sought));
}
return s;
}
@Override
public void handleClose() {
// Nop
}
@Override
public Map<Attribute, Object> getAttributes(boolean followLinks) {
Map<SshFile.Attribute, Object> attributes = new HashMap<>();
for (SshFile.Attribute attr : SshFile.Attribute.values()) {
switch (attr) {
case Uid:
case Gid:
case NLink:
continue;
}
attributes.put(attr, getAttribute(attr, followLinks));
}
return attributes;
}
@Override
public Object getAttribute(Attribute attribute, boolean followLinks) {
Object ret;
switch (attribute) {
case Size:
ret = documentInfo.length;
break;
case Uid:
case Gid:
ret = 1;
break;
case Owner:
case Group:
ret = getOwner();
break;
case IsDirectory:
ret = documentInfo.isDirectory;
break;
case IsRegularFile:
ret = documentInfo.isFile;
break;
case IsSymbolicLink:
ret = false;
break;
case Permissions:
Set<Permission> tmp = new HashSet<>();
if (documentInfo.canRead) {
tmp.add(SshFile.Permission.UserRead);
tmp.add(SshFile.Permission.GroupRead);
tmp.add(SshFile.Permission.OthersRead);
}
if (documentInfo.canWrite) {
tmp.add(SshFile.Permission.UserWrite);
tmp.add(SshFile.Permission.GroupWrite);
tmp.add(SshFile.Permission.OthersWrite);
}
if (isExecutable()) {
tmp.add(SshFile.Permission.UserExecute);
tmp.add(SshFile.Permission.GroupExecute);
tmp.add(SshFile.Permission.OthersExecute);
}
ret = tmp.isEmpty()
? EnumSet.noneOf(SshFile.Permission.class)
: EnumSet.copyOf(tmp);
break;
case CreationTime:
case LastModifiedTime:
case LastAccessTime:
ret = documentInfo.lastModified;
break;
case NLink:
ret = 0;
break;
default:
ret = null;
break;
}
return ret;
}
@Override
public void setAttributes(Map<Attribute, Object> attributes) {
//TODO: Using Java 7 NIO it should be possible to implement setting a number of attributes but does SaF allow that?
}
@Override
public void setAttribute(Attribute attribute, Object value) {}
@Override
public String readSymbolicLink() throws IOException {
throw new IOException("Not Implemented");
}
@Override
public void createSymbolicLink(SshFile destination) throws IOException {
throw new IOException("Not Implemented");
}
/**
* Retrieve all file info using 1 query to speed things up
* The only fields guaranteed to be initialized are uri and exists
*/
private static class DocumentInfo {
private Uri uri;
private boolean exists;
private boolean canRead;
private boolean canWrite;
private boolean isDirectory;
private boolean isFile;
private long lastModified;
private long length;
@Nullable
private String displayName;
private static final String[] columns;
static {
columns = new String[]{
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
//DocumentsContract.Document.COLUMN_ICON,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
};
}
/*
Based on https://github.com/rcketscientist/DocumentActivity
Extracted from android.support.v4.provider.DocumentsContractAPI19 and android.support.v4.provider.DocumentsContractAPI21
*/
private DocumentInfo(Context c, Uri uri)
{
this.uri = uri;
try (Cursor cursor = c.getContentResolver().query(uri, columns, null, null, null)) {
exists = cursor != null && cursor.getCount() > 0;
if (!exists)
return;
cursor.moveToFirst();
//String documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
final boolean readPerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
== PackageManager.PERMISSION_GRANTED;
final boolean writePerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
== PackageManager.PERMISSION_GRANTED;
final int flags = cursor.getInt(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS));
final boolean supportsDelete = (flags & DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0;
final boolean supportsCreate = (flags & DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE) != 0;
final boolean supportsWrite = (flags & DocumentsContract.Document.FLAG_SUPPORTS_WRITE) != 0;
String mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
final boolean hasMime = !TextUtils.isEmpty(mimeType);
isDirectory = DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType);
isFile = !isDirectory && hasMime;
canRead = readPerm && hasMime;
canWrite = writePerm && (supportsDelete || (isDirectory && supportsCreate) || (hasMime && supportsWrite));
displayName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
lastModified = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED));
length = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE));
} catch (IllegalArgumentException e) {
//File does not exist, it's probably going to be created
exists = false;
canWrite = true;
}
}
}
}

View File

@@ -1,98 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import org.apache.commons.io.FileUtils;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import org.kde.kdeconnect.Helpers.MediaStoreHelper;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
class AndroidSshFile extends NativeSshFile {
private static final String TAG = AndroidSshFile.class.getSimpleName();
final private Context context;
final private File file;
AndroidSshFile(final AndroidFileSystemView view, String name, final File file, final String userName, Context context) {
super(view, name, file, userName);
this.context = context;
this.file = file;
}
@Override
public OutputStream createOutputStream(long offset) throws IOException {
if (!isWritable()) {
throw new IOException("No write permission : " + file.getName());
}
final RandomAccessFile raf = new RandomAccessFile(file, "rw");
try {
if (offset < raf.length()) {
throw new IOException("Your SSHFS is bugged"); //SSHFS 3.0 and 3.2 cause data corruption, abort the transfer if this happens
}
raf.setLength(offset);
raf.seek(offset);
return new FileOutputStream(raf.getFD()) {
public void close() throws IOException {
super.close();
raf.close();
}
};
} catch (IOException e) {
raf.close();
throw e;
}
}
@Override
public boolean delete() {
boolean ret = super.delete();
if (ret) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file));
}
return ret;
}
@Override
public boolean create() throws IOException {
boolean ret = super.create();
if (ret) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file));
}
return ret;
}
// Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java
@Override
public boolean doesExist() {
boolean exists = file.exists();
if (!exists) {
// file.exists() returns false when we don't have read permission
// try to figure out if it really does not exist
try {
exists = FileUtils.directoryContains(file.getParentFile(), file);
} catch (IOException | IllegalArgumentException e) {
// An IllegalArgumentException is thrown if the parent is null or not a directory.
Log.d(TAG, "Exception: ", e);
}
}
return exists;
}
}

View File

@@ -1,39 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.KeyExchange;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.digest.SHA256;
import org.apache.sshd.common.kex.AbstractDH;
import org.apache.sshd.common.kex.DH;
import org.apache.sshd.common.kex.DHGroupData;
import org.apache.sshd.server.kex.AbstractDHGServer;
public class DHG14_256 extends AbstractDHGServer {
public static class Factory implements NamedFactory<KeyExchange> {
public String getName() {
return "diffie-hellman-group14-sha256";
}
public KeyExchange create() {
return new DHG14_256();
}
}
@Override
protected AbstractDH getDH() throws Exception {
DH dh = new DH(new SHA256.Factory());
dh.setG(DHGroupData.getG());
dh.setP(DHGroupData.getP14());
return dh;
}
}

View File

@@ -1,163 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.file.SshFile;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Calendar;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
//TODO: ls .. and ls / only show .. and / respectively I would expect a listing
//TODO: cd .. to / does not work and prints "Can't change directory: Can't check target"
class RootFile implements SshFile {
private final boolean exists;
private final String userName;
private final List<SshFile> files;
RootFile(List<SshFile> files, String userName, boolean exits) {
this.files = files;
this.userName = userName;
this.exists = exits;
}
public String getAbsolutePath() {
return "/";
}
public String getName() {
return "/";
}
public Map<Attribute, Object> getAttributes(boolean followLinks) {
Map<Attribute, Object> attrs = new HashMap<>();
attrs.put(Attribute.Size, 0);
attrs.put(Attribute.Owner, userName);
attrs.put(Attribute.Group, userName);
EnumSet<Permission> p = EnumSet.noneOf(Permission.class);
p.add(Permission.UserExecute);
p.add(Permission.GroupExecute);
p.add(Permission.OthersExecute);
attrs.put(Attribute.Permissions, p);
long now = Calendar.getInstance().getTimeInMillis();
attrs.put(Attribute.LastAccessTime, now);
attrs.put(Attribute.LastModifiedTime, now);
attrs.put(Attribute.IsSymbolicLink, false);
attrs.put(Attribute.IsDirectory, true);
attrs.put(Attribute.IsRegularFile, false);
return attrs;
}
public void setAttributes(Map<Attribute, Object> attributes) {
}
public Object getAttribute(Attribute attribute, boolean followLinks) {
return null;
}
public void setAttribute(Attribute attribute, Object value) {
}
public String readSymbolicLink() {
return "";
}
public void createSymbolicLink(SshFile destination) {
}
public String getOwner() {
return null;
}
public boolean isDirectory() {
return true;
}
public boolean isFile() {
return false;
}
public boolean doesExist() {
return exists;
}
public boolean isReadable() {
return true;
}
public boolean isWritable() {
return false;
}
public boolean isExecutable() {
return true;
}
public boolean isRemovable() {
return false;
}
public SshFile getParentFile() {
return this;
}
public long getLastModified() {
return 0;
}
public boolean setLastModified(long time) {
return false;
}
public long getSize() {
return 0;
}
public boolean mkdir() {
return false;
}
public boolean delete() {
return false;
}
public boolean create() {
return false;
}
public void truncate() {
}
public boolean move(SshFile destination) {
return false;
}
public List<SshFile> listSshFiles() {
return Collections.unmodifiableList(files);
}
public OutputStream createOutputStream(long offset) {
return null;
}
public InputStream createInputStream(long offset) {
return null;
}
public void handleClose() {
}
}

View File

@@ -1,326 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.Settings;
import androidx.annotation.NonNull;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.Helpers.NetworkHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.UserInterface.AlertDialogFragment;
import org.kde.kdeconnect.UserInterface.DeviceSettingsAlertDialogFragment;
import org.kde.kdeconnect.UserInterface.MainActivity;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment;
import org.kde.kdeconnect_tp.BuildConfig;
import org.kde.kdeconnect_tp.R;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
@PluginFactory.LoadablePlugin
public class SftpPlugin extends Plugin implements SharedPreferences.OnSharedPreferenceChangeListener {
private final static String PACKET_TYPE_SFTP = "kdeconnect.sftp";
private final static String PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request";
static final int PREFERENCE_KEY_STORAGE_INFO_LIST = R.string.sftp_preference_key_storage_info_list;
private static final SimpleSftpServer server = new SimpleSftpServer();
@Override
public @NonNull String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_sftp);
}
@Override
public @NonNull String getDescription() {
return context.getResources().getString(R.string.pref_plugin_sftp_desc);
}
@Override
public boolean onCreate() {
return true;
}
@Override
public boolean checkRequiredPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
} else {
return SftpSettingsFragment.getStorageInfoList(context, this).size() != 0;
}
}
@Override
public @NonNull AlertDialogFragment getPermissionExplanationDialog() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return new StartActivityAlertDialogFragment.Builder()
.setTitle(getDisplayName())
.setMessage(R.string.sftp_manage_storage_permission_explanation)
.setPositiveButton(R.string.open_settings)
.setNegativeButton(R.string.cancel)
.setIntentAction(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
.setIntentUrl("package:" + BuildConfig.APPLICATION_ID)
.setStartForResult(true)
.setRequestCode(MainActivity.RESULT_NEEDS_RELOAD)
.create();
} else {
return new DeviceSettingsAlertDialogFragment.Builder()
.setTitle(getDisplayName())
.setMessage(R.string.sftp_saf_permission_explanation)
.setPositiveButton(R.string.ok)
.setNegativeButton(R.string.cancel)
.setDeviceId(getDevice().getDeviceId())
.setPluginKey(getPluginKey())
.create();
}
}
@Override
public void onDestroy() {
server.stop();
if (getPreferences() != null) {
getPreferences().unregisterOnSharedPreferenceChangeListener(this);
}
}
@Override
public boolean onPacketReceived(@NonNull NetworkPacket np) {
if (np.getBoolean("startBrowsing")) {
if (!server.isInitialized()) {
try {
server.initialize(context, getDevice());
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}
ArrayList<String> paths = new ArrayList<>();
ArrayList<String> pathNames = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
List<StorageVolume> volumes = context.getSystemService(StorageManager.class).getStorageVolumes();
for (StorageVolume sv : volumes) {
pathNames.add(sv.getDescription(context));
paths.add(sv.getDirectory().getPath());
}
} else {
List<StorageInfo> storageInfoList = SftpSettingsFragment.getStorageInfoList(context, this);
Collections.sort(storageInfoList, Comparator.comparing(StorageInfo::getUri));
if (storageInfoList.size() > 0) {
getPathsAndNamesForStorageInfoList(paths, pathNames, storageInfoList);
} else {
NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP);
np2.set("errorMessage", context.getString(R.string.sftp_no_storage_locations_configured));
getDevice().sendPacket(np2);
return true;
}
removeChildren(storageInfoList);
server.setSafRoots(storageInfoList);
}
if (server.start()) {
if (getPreferences() != null) {
getPreferences().registerOnSharedPreferenceChangeListener(this);
}
NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP);
np2.set("ip", NetworkHelper.getLocalIpAddress().getHostAddress());
np2.set("port", server.getPort());
np2.set("user", SimpleSftpServer.USER);
np2.set("password", server.regeneratePassword());
//Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it
if (paths.size() == 1) {
np2.set("path", paths.get(0));
} else {
np2.set("path", "/");
}
if (paths.size() > 0) {
np2.set("multiPaths", paths);
np2.set("pathNames", pathNames);
}
getDevice().sendPacket(np2);
return true;
}
}
return false;
}
private void getPathsAndNamesForStorageInfoList(List<String> paths, List<String> pathNames, List<StorageInfo> storageInfoList) {
StorageInfo prevInfo = null;
StringBuilder pathBuilder = new StringBuilder();
for (StorageInfo curInfo : storageInfoList) {
pathBuilder.setLength(0);
pathBuilder.append("/");
if (prevInfo != null && curInfo.uri.toString().startsWith(prevInfo.uri.toString())) {
pathBuilder.append(prevInfo.displayName);
pathBuilder.append("/");
if (curInfo.uri.getPath() != null && prevInfo.uri.getPath() != null) {
pathBuilder.append(curInfo.uri.getPath().substring(prevInfo.uri.getPath().length()));
} else {
throw new RuntimeException("curInfo.uri.getPath() or parentInfo.uri.getPath() returned null");
}
} else {
pathBuilder.append(curInfo.displayName);
if (prevInfo == null || !curInfo.uri.toString().startsWith(prevInfo.uri.toString())) {
prevInfo = curInfo;
}
}
paths.add(pathBuilder.toString());
pathNames.add(curInfo.displayName);
}
}
private void removeChildren(List<StorageInfo> storageInfoList) {
StorageInfo prevInfo = null;
Iterator<StorageInfo> it = storageInfoList.iterator();
while (it.hasNext()) {
StorageInfo curInfo = it.next();
if (prevInfo != null && curInfo.uri.toString().startsWith(prevInfo.uri.toString())) {
it.remove();
} else {
if (prevInfo == null || !curInfo.uri.toString().startsWith(prevInfo.uri.toString())) {
prevInfo = curInfo;
}
}
}
}
@Override
public @NonNull String[] getSupportedPacketTypes() {
return new String[]{PACKET_TYPE_SFTP_REQUEST};
}
@Override
public @NonNull String[] getOutgoingPacketTypes() {
return new String[]{PACKET_TYPE_SFTP};
}
@Override
public boolean hasSettings() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R;
}
@Override
public boolean supportsDeviceSpecificSettings() { return true; }
@Override
public PluginSettingsFragment getSettingsFragment(Activity activity) {
return SftpSettingsFragment.newInstance(getPluginKey(), R.xml.sftpplugin_preferences);
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (key.equals(context.getString(PREFERENCE_KEY_STORAGE_INFO_LIST))) {
if (server.isStarted()) {
server.stop();
NetworkPacket np = new NetworkPacket(PACKET_TYPE_SFTP_REQUEST);
np.set("startBrowsing", true);
onPacketReceived(np);
}
}
}
static class StorageInfo {
private static final String KEY_DISPLAY_NAME = "DisplayName";
private static final String KEY_URI = "Uri";
@NonNull
String displayName;
@NonNull
final Uri uri;
StorageInfo(@NonNull String displayName, @NonNull Uri uri) {
this.displayName = displayName;
this.uri = uri;
}
@NonNull
Uri getUri() {
return uri;
}
static StorageInfo copy(StorageInfo from) {
//Both String and Uri are immutable
return new StorageInfo(from.displayName, from.uri);
}
boolean isFileUri() {
return uri.getScheme().equals(ContentResolver.SCHEME_FILE);
}
boolean isContentUri() {
return uri.getScheme().equals(ContentResolver.SCHEME_CONTENT);
}
public JSONObject toJSON() throws JSONException {
JSONObject jsonObject = new JSONObject();
jsonObject.put(KEY_DISPLAY_NAME, displayName);
jsonObject.put(KEY_URI, uri.toString());
return jsonObject;
}
@NonNull
static StorageInfo fromJSON(@NonNull JSONObject jsonObject) throws JSONException {
String displayName = jsonObject.getString(KEY_DISPLAY_NAME);
Uri uri = Uri.parse(jsonObject.getString(KEY_URI));
return new StorageInfo(displayName, uri);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StorageInfo that = (StorageInfo) o;
if (!displayName.equals(that.displayName)) return false;
return uri.equals(that.uri);
}
@Override
public int hashCode() {
int result = displayName.hashCode();
result = 31 * result + uri.hashCode();
return result;
}
}
}

View File

@@ -0,0 +1,259 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.app.Activity
import android.content.ContentResolver
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.Settings
import org.json.JSONException
import org.json.JSONObject
import org.kde.kdeconnect.Helpers.NetworkHelper.localIpAddress
import org.kde.kdeconnect.NetworkPacket
import org.kde.kdeconnect.Plugins.Plugin
import org.kde.kdeconnect.Plugins.PluginFactory.LoadablePlugin
import org.kde.kdeconnect.UserInterface.AlertDialogFragment
import org.kde.kdeconnect.UserInterface.DeviceSettingsAlertDialogFragment
import org.kde.kdeconnect.UserInterface.MainActivity
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment
import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment
import org.kde.kdeconnect_tp.BuildConfig
import org.kde.kdeconnect_tp.R
import java.security.GeneralSecurityException
@LoadablePlugin
class SftpPlugin : Plugin(), OnSharedPreferenceChangeListener {
override val displayName: String
get() = context.resources.getString(R.string.pref_plugin_sftp)
override val description: String
get() = context.resources.getString(R.string.pref_plugin_sftp_desc)
override fun onCreate(): Boolean = true
override fun checkRequiredPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
SftpSettingsFragment.getStorageInfoList(context, this).size != 0
}
}
override val permissionExplanationDialog: AlertDialogFragment
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
StartActivityAlertDialogFragment.Builder()
.setTitle(displayName)
.setMessage(R.string.sftp_manage_storage_permission_explanation)
.setPositiveButton(R.string.open_settings)
.setNegativeButton(R.string.cancel)
.setIntentAction(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
.setIntentUrl("package:" + BuildConfig.APPLICATION_ID)
.setStartForResult(true)
.setRequestCode(MainActivity.RESULT_NEEDS_RELOAD)
.create()
} else {
DeviceSettingsAlertDialogFragment.Builder()
.setTitle(displayName)
.setMessage(R.string.sftp_saf_permission_explanation)
.setPositiveButton(R.string.ok)
.setNegativeButton(R.string.cancel)
.setDeviceId(device.deviceId)
.setPluginKey(pluginKey)
.create()
}
override fun onDestroy() {
server.stop()
preferences?.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onPacketReceived(np: NetworkPacket): Boolean {
if (!np.getBoolean("startBrowsing")) return false
if (!server.isInitialized || server.isClosed) {
try {
server.initialize(context, device)
} catch (e: GeneralSecurityException) {
throw RuntimeException(e)
}
}
val paths = mutableListOf<String>()
val pathNames = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val volumes = context.getSystemService(
StorageManager::class.java
).storageVolumes
for (sv in volumes) {
pathNames.add(sv.getDescription(context))
paths.add(sv.directory!!.path)
}
} else {
val storageInfoList = SftpSettingsFragment.getStorageInfoList(context, this)
storageInfoList.sortBy { it.uri }
if (storageInfoList.size <= 0) {
device.sendPacket(NetworkPacket(PACKET_TYPE_SFTP).apply {
this["errorMessage"] = context.getString(R.string.sftp_no_storage_locations_configured)
})
return true
}
getPathsAndNamesForStorageInfoList(paths, pathNames, storageInfoList)
storageInfoList.removeChildren()
server.setSafRoots(storageInfoList)
}
if (!server.start()) {
return false
}
if (preferences != null) {
preferences!!.registerOnSharedPreferenceChangeListener(this)
}
device.sendPacket(NetworkPacket(PACKET_TYPE_SFTP).apply {
this["ip"] = localIpAddress!!.hostAddress
this["port"] = server.port
this["user"] = SimpleSftpServer.USER
this["password"] = server.regeneratePassword()
// Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it
this["path"] = if (paths.size == 1) paths[0] else "/"
if (paths.size > 0) {
this["multiPaths"] = paths
this["pathNames"] = pathNames
}
})
return true
}
private fun getPathsAndNamesForStorageInfoList(
paths: MutableList<String>,
pathNames: MutableList<String>,
storageInfoList: List<StorageInfo>
) {
var prevInfo: StorageInfo? = null
val pathBuilder = StringBuilder()
for (curInfo in storageInfoList) {
pathBuilder.setLength(0)
pathBuilder.append("/")
if (prevInfo != null && curInfo.uri.toString().startsWith(prevInfo.uri.toString())) {
pathBuilder.append(prevInfo.displayName)
pathBuilder.append("/")
if (curInfo.uri.path != null && prevInfo.uri.path != null) {
pathBuilder.append(curInfo.uri.path!!.substring(prevInfo.uri.path!!.length))
} else {
throw RuntimeException("curInfo.uri.getPath() or parentInfo.uri.getPath() returned null")
}
} else {
pathBuilder.append(curInfo.displayName)
if (prevInfo == null || !curInfo.uri.toString()
.startsWith(prevInfo.uri.toString())
) {
prevInfo = curInfo
}
}
paths.add(pathBuilder.toString())
pathNames.add(curInfo.displayName)
}
}
private fun MutableList<StorageInfo>.removeChildren() {
fun StorageInfo.isParentOf(other: StorageInfo): Boolean =
other.uri.toString().startsWith(this.uri.toString())
var currentParent: StorageInfo? = null
retainAll { curInfo ->
when {
currentParent == null -> {
currentParent = curInfo
true
}
currentParent!!.isParentOf(curInfo) -> {
false
}
else -> {
currentParent = curInfo
true
}
}
}
}
override val supportedPacketTypes: Array<String> = arrayOf(PACKET_TYPE_SFTP_REQUEST)
override val outgoingPacketTypes: Array<String> = arrayOf(PACKET_TYPE_SFTP)
override fun hasSettings(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
override fun supportsDeviceSpecificSettings(): Boolean = true
override fun getSettingsFragment(activity: Activity): PluginSettingsFragment {
return SftpSettingsFragment.newInstance(pluginKey, R.xml.sftpplugin_preferences)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
if (key != context.getString(PREFERENCE_KEY_STORAGE_INFO_LIST)) return
if (!server.isStarted) return
server.stop()
val np = NetworkPacket(PACKET_TYPE_SFTP_REQUEST).apply {
this["startBrowsing"] = true
}
onPacketReceived(np)
}
data class StorageInfo(@JvmField var displayName: String, @JvmField val uri: Uri) {
val isFileUri: Boolean = uri.scheme == ContentResolver.SCHEME_FILE
val isContentUri: Boolean = uri.scheme == ContentResolver.SCHEME_CONTENT
@Throws(JSONException::class)
fun toJSON(): JSONObject {
return JSONObject().apply {
put(KEY_DISPLAY_NAME, displayName)
put(KEY_URI, uri.toString())
}
}
companion object {
private const val KEY_DISPLAY_NAME = "DisplayName"
private const val KEY_URI = "Uri"
@JvmStatic
@Throws(JSONException::class)
fun fromJSON(jsonObject: JSONObject): StorageInfo { // TODO: Use Result after migrate callee to Kotlin
val displayName = jsonObject.getString(KEY_DISPLAY_NAME)
val uri = Uri.parse(jsonObject.getString(KEY_URI))
return StorageInfo(displayName, uri)
}
}
}
companion object {
private const val PACKET_TYPE_SFTP = "kdeconnect.sftp"
private const val PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request"
@JvmField
val PREFERENCE_KEY_STORAGE_INFO_LIST: Int = R.string.sftp_preference_key_storage_info_list
private val server = SimpleSftpServer()
}
}

View File

@@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.Signature;
import org.apache.sshd.common.signature.AbstractSignature;
public class SignatureRSASHA256 extends AbstractSignature {
public static class Factory implements NamedFactory<Signature> {
public String getName() {
return "rsa-sha2-256";
}
public Signature create() {
return new SignatureRSASHA256();
}
}
public SignatureRSASHA256() {
super("SHA256withRSA");
}
public byte[] sign() throws Exception {
return signature.sign();
}
public boolean verify(byte[] sig) throws Exception {
sig = extractSig(sig);
return signature.verify(sig);
}
}

View File

@@ -1,206 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import org.apache.sshd.SshServer;
import org.apache.sshd.common.file.nativefs.NativeFileSystemFactory;
import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider;
import org.apache.sshd.common.signature.SignatureDSA;
import org.apache.sshd.common.signature.SignatureECDSA;
import org.apache.sshd.common.signature.SignatureRSA;
import org.apache.sshd.common.util.SecurityUtils;
import org.apache.sshd.server.PasswordAuthenticator;
import org.apache.sshd.server.PublickeyAuthenticator;
import org.apache.sshd.server.command.ScpCommandFactory;
import org.apache.sshd.server.kex.DHG14;
import org.apache.sshd.server.kex.ECDHP256;
import org.apache.sshd.server.kex.ECDHP384;
import org.apache.sshd.server.kex.ECDHP521;
import org.apache.sshd.server.session.ServerSession;
import org.apache.sshd.server.sftp.SftpSubsystem;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.RandomHelper;
import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.kde.kdeconnect.Helpers.SecurityHelpers.ConstantTimeCompareKt.constantTimeCompare;
class SimpleSftpServer {
private static final int STARTPORT = 1739;
private static final int ENDPORT = 1764;
static final String USER = "kdeconnect";
private int port = -1;
private boolean started = false;
private final SimplePasswordAuthenticator passwordAuth = new SimplePasswordAuthenticator();
private final SimplePublicKeyAuthenticator keyAuth = new SimplePublicKeyAuthenticator();
static {
SecurityUtils.setRegisterBouncyCastle(false);
}
boolean initialized = false;
private final SshServer sshd = SshServer.setUpDefaultServer();
private AndroidFileSystemFactory safFileSystemFactory;
public void setSafRoots(List<SftpPlugin.StorageInfo> storageInfoList) {
safFileSystemFactory.initRoots(storageInfoList);
}
void initialize(Context context, Device device) throws GeneralSecurityException {
sshd.setSignatureFactories(Arrays.asList(
new SignatureECDSA.NISTP256Factory(),
new SignatureECDSA.NISTP384Factory(),
new SignatureECDSA.NISTP521Factory(),
new SignatureDSA.Factory(),
new SignatureRSASHA256.Factory(),
new SignatureRSA.Factory() // Insecure SHA1, left for backwards compatibility
));
sshd.setKeyExchangeFactories(Arrays.asList(
new ECDHP256.Factory(), // ecdh-sha2-nistp256
new ECDHP384.Factory(), // ecdh-sha2-nistp384
new ECDHP521.Factory(), // ecdh-sha2-nistp521
new DHG14_256.Factory(), // diffie-hellman-group14-sha256
new DHG14.Factory() // Insecure diffie-hellman-group14-sha1, left for backwards-compatibility.
));
//Reuse this device keys for the ssh connection as well
final KeyPair keyPair;
PrivateKey privateKey = RsaHelper.getPrivateKey(context);
PublicKey publicKey = RsaHelper.getPublicKey(context);
keyPair = new KeyPair(publicKey, privateKey);
sshd.setKeyPairProvider(new AbstractKeyPairProvider() {
@Override
public Iterable<KeyPair> loadKeys() {
return Collections.singletonList(keyPair);
}
});
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
sshd.setFileSystemFactory(new NativeFileSystemFactory());
} else {
safFileSystemFactory = new AndroidFileSystemFactory(context);
sshd.setFileSystemFactory(safFileSystemFactory);
}
sshd.setCommandFactory(new ScpCommandFactory());
sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory()));
keyAuth.deviceKey = device.getCertificate().getPublicKey();
sshd.setPublickeyAuthenticator(keyAuth);
sshd.setPasswordAuthenticator(passwordAuth);
initialized = true;
}
public boolean start() {
if (!started) {
regeneratePassword();
port = STARTPORT;
while (!started) {
try {
sshd.setPort(port);
sshd.start();
started = true;
} catch (IOException e) {
port++;
if (port >= ENDPORT) {
port = -1;
Log.e("SftpServer", "No more ports available");
return false;
}
}
}
}
return true;
}
public void stop() {
try {
started = false;
sshd.stop(true);
} catch (Exception e) {
Log.e("SFTP", "Exception while stopping the server", e);
}
}
public boolean isStarted() {
return started;
}
String regeneratePassword() {
String password = RandomHelper.randomString(28);
passwordAuth.setPassword(password);
return password;
}
int getPort() {
return port;
}
public boolean isInitialized() {
return initialized;
}
static class SimplePasswordAuthenticator implements PasswordAuthenticator {
MessageDigest sha;
{
try {
sha = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
public void setPassword(String password) {
sha.digest(password.getBytes(StandardCharsets.UTF_8));
}
byte[] passwordHash;
@Override
public boolean authenticate(String user, String password, ServerSession session) {
byte[] receivedPasswordHash = sha.digest(password.getBytes(StandardCharsets.UTF_8));
return user.equals(SimpleSftpServer.USER) && constantTimeCompare(passwordHash, receivedPasswordHash);
}
}
static class SimplePublicKeyAuthenticator implements PublickeyAuthenticator {
PublicKey deviceKey;
@Override
public boolean authenticate(String user, PublicKey key, ServerSession session) {
return deviceKey.equals(key);
}
}
}

View File

@@ -0,0 +1,291 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import org.apache.sshd.common.file.nativefs.NativeFileSystemFactory
import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider
import org.apache.sshd.common.session.SessionContext
import org.apache.sshd.common.util.io.PathUtils
import org.apache.sshd.common.util.security.SecurityUtils.SECURITY_PROVIDER_REGISTRARS
import org.apache.sshd.scp.server.ScpCommandFactory
import org.apache.sshd.server.ServerBuilder
import org.apache.sshd.server.SshServer
import org.apache.sshd.server.auth.password.PasswordAuthenticator
import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator
import org.apache.sshd.server.session.ServerSession
import org.apache.sshd.server.subsystem.SubsystemFactory
import org.apache.sshd.sftp.server.FileHandle
import org.apache.sshd.sftp.server.SftpFileSystemAccessor
import org.apache.sshd.sftp.server.SftpSubsystemFactory
import org.apache.sshd.sftp.server.SftpSubsystemProxy
import org.kde.kdeconnect.Device
import org.kde.kdeconnect.Helpers.MediaStoreHelper
import org.kde.kdeconnect.Helpers.RandomHelper
import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper
import org.kde.kdeconnect.Helpers.SecurityHelpers.constantTimeCompare
import org.kde.kdeconnect.Plugins.SftpPlugin.saf.SafFileSystemFactory
import org.kde.kdeconnect.Plugins.SftpPlugin.saf.SafPath
import org.slf4j.impl.HandroidLoggerAdapter
import java.io.IOException
import java.nio.channels.Channel
import java.nio.channels.SeekableByteChannel
import java.nio.charset.StandardCharsets
import java.nio.file.CopyOption
import java.nio.file.OpenOption
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.FileAttribute
import java.security.GeneralSecurityException
import java.security.KeyPair
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.PublicKey
internal class SimpleSftpServer {
private lateinit var sshd: SshServer
val port: Int
get() {
if (!::sshd.isInitialized) return -1
return sshd.port
}
val isStarted: Boolean
get() {
if (!::sshd.isInitialized) return false
return sshd.isStarted
}
val isClosed: Boolean
get() {
if (!::sshd.isInitialized) return false
return sshd.isClosed
}
private val passwordAuth = SimplePasswordAuthenticator()
private val keyAuth = SimplePublicKeyAuthenticator()
val isInitialized: Boolean
get() = ::sshd.isInitialized
private var safFileSystemFactory: SafFileSystemFactory? = null
fun setSafRoots(storageInfoList: List<SftpPlugin.StorageInfo>) {
safFileSystemFactory!!.initRoots(storageInfoList)
}
@Throws(GeneralSecurityException::class)
fun initialize(context: Context, device: Device) {
val sshd = ServerBuilder.builder().apply {
fileSystemFactory(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
NativeFileSystemFactory()
} else {
safFileSystemFactory = SafFileSystemFactory(context)
safFileSystemFactory
}
)
}.build()
// Reuse this device keys for the ssh connection as well
val keyPair = KeyPair(
RsaHelper.getPublicKey(context),
RsaHelper.getPrivateKey(context)
)
sshd.keyPairProvider = object : AbstractKeyPairProvider() {
override fun loadKeys(session: SessionContext): Iterable<KeyPair> = listOf(keyPair)
}
sshd.commandFactory = ScpCommandFactory()
sshd.subsystemFactories =
listOf<SubsystemFactory>(SftpSubsystemFactory.Builder().apply {
withFileSystemAccessor(object : SftpFileSystemAccessor {
fun notifyMediaStore(path: Path) {
kotlin.runCatching {
val uri = Uri.parse(path.toUri().toString())
MediaStoreHelper.indexFile(context, uri)
uri
}.fold(
onSuccess = { Log.i(TAG, "Notified media store: $path, $it") },
onFailure = { Log.w(TAG, "Failed to notify media store: $path", it) }
)
}
override fun openFile(
subsystem: SftpSubsystemProxy?,
fileHandle: FileHandle?,
file: Path?,
handle: String?,
options: MutableSet<out OpenOption>?,
vararg attrs: FileAttribute<*>?
): SeekableByteChannel {
if (file is SafPath) {
return file.fileSystem.provider().newByteChannel(file, options, *attrs)
}
return super.openFile(subsystem, fileHandle, file, handle, options, *attrs)
}
override fun removeFile(
subsystem: SftpSubsystemProxy?,
path: Path?,
isDirectory: Boolean
) {
super.removeFile(subsystem, path, isDirectory)
path?.let { notifyMediaStore(it) }
}
override fun copyFile(
subsystem: SftpSubsystemProxy?,
src: Path?,
dst: Path?,
opts: MutableCollection<CopyOption>?
) {
super.copyFile(subsystem, src, dst, opts)
dst?.let { notifyMediaStore(it) }
}
override fun renameFile(
subsystem: SftpSubsystemProxy?,
oldPath: Path?,
newPath: Path?,
opts: MutableCollection<CopyOption>?
) {
super.renameFile(subsystem, oldPath, newPath, opts)
oldPath?.let { notifyMediaStore(it) }
newPath?.let { notifyMediaStore(it) }
}
override fun createLink(
subsystem: SftpSubsystemProxy?,
link: Path?,
existing: Path?,
symLink: Boolean
) {
super.createLink(subsystem, link, existing, symLink)
link?.let { notifyMediaStore(it) }
existing?.let { notifyMediaStore(it) }
}
override fun closeFile(
subsystem: SftpSubsystemProxy?,
fileHandle: FileHandle?,
file: Path?,
handle: String?,
channel: Channel?,
options: MutableSet<out OpenOption>?
) {
super.closeFile(subsystem, fileHandle, file, handle, channel, options)
if (options?.contains(StandardOpenOption.WRITE) == true) {
file?.let { notifyMediaStore(it) }
}
}
})
}.build())
keyAuth.deviceKey = device.certificate.publicKey
sshd.publickeyAuthenticator = keyAuth
sshd.passwordAuthenticator = passwordAuth
this.sshd = sshd
}
fun start(): Boolean {
if (isStarted) return true
regeneratePassword()
PORT_RANGE.forEach { port ->
try {
sshd.port = port
sshd.start()
return true
} catch (e: IOException) {
Log.w("SftpServer", "Failed to start server on port $port, trying next port", e)
}
}
Log.e("SftpServer", "No more ports available")
return false
}
fun stop() {
if (!::sshd.isInitialized) return
try {
sshd.stop(true)
} catch (e: Exception) {
Log.e("SFTP", "Exception while stopping the server", e)
}
}
fun regeneratePassword(): String {
return RandomHelper.randomString(28).also {
passwordAuth.setPassword(it)
}
}
internal class SimplePasswordAuthenticator : PasswordAuthenticator {
private val sha: MessageDigest = try {
MessageDigest.getInstance("SHA-256")
} catch (e: NoSuchAlgorithmException) {
throw RuntimeException(e)
}
private var passwordHash: ByteArray = byteArrayOf()
fun setPassword(password: String) {
passwordHash = sha.digest(password.toByteArray(StandardCharsets.UTF_8))
}
override fun authenticate(user: String, password: String, session: ServerSession): Boolean {
val receivedPasswordHash = sha.digest(password.toByteArray(StandardCharsets.UTF_8))
return user == USER && constantTimeCompare(passwordHash, receivedPasswordHash)
}
}
internal class SimplePublicKeyAuthenticator : PublickeyAuthenticator {
var deviceKey: PublicKey? = null
override fun authenticate(user: String, key: PublicKey, session: ServerSession): Boolean =
user == USER && deviceKey == key
}
companion object {
private const val TAG = "SimpleSftpServer"
private val PORT_RANGE = 1739..1764
const val USER: String = "kdeconnect"
init {
System.setProperty(SECURITY_PROVIDER_REGISTRARS, "") // disable BouncyCastle
System.setProperty(
"org.apache.sshd.common.io.IoServiceFactoryFactory",
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// Use MINA instead NIO2 due to compatibility issues
// Android 7.1 (API 25) and below have issues with NIO2
// When we require API 26, we can remove this and the Mina dependency.
"org.apache.sshd.mina.MinaServiceFactoryFactory"
} else {
"org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory"
}
)
// Remove it when SSHD Core is fixed.
// Android has no user home folder, so we need to set it to something.
// `System.getProperty("user.home")` is not available on Android,
// but it exists in SSHD Core's `org.apache.sshd.common.util.io.PathUtils.LazyDefaultUserHomeFolderHolder`.
PathUtils.setUserHomeFolderResolver { Path.of("/") }
// Disable SSHD logging due to performance degradation and being very noisy even in development
HandroidLoggerAdapter.DEBUG = false
}
}
}

View File

@@ -1,122 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.provider.DocumentsContract;
import android.util.AttributeSet;
import android.view.View;
import android.widget.CheckBox;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.DialogPreference;
import androidx.preference.PreferenceViewHolder;
import org.kde.kdeconnect_tp.R;
public class StoragePreference extends DialogPreference {
@Nullable
private SftpPlugin.StorageInfo storageInfo;
@Nullable
private OnLongClickListener onLongClickListener;
CheckBox checkbox;
public boolean inSelectionMode;
public void setInSelectionMode(boolean inSelectionMode) {
if (this.inSelectionMode != inSelectionMode) {
this.inSelectionMode = inSelectionMode;
notifyChanged();
}
}
public StoragePreference(Context context, AttributeSet attrs) {
super(context, attrs);
setDialogLayoutResource(R.layout.fragment_storage_preference_dialog);
setWidgetLayoutResource(R.layout.preference_checkbox);
setPersistent(false);
inSelectionMode = false;
}
public StoragePreference(Context context) {
this(context, null);
}
public void setOnLongClickListener(@Nullable OnLongClickListener listener) {
this.onLongClickListener = listener;
}
public void setStorageInfo(@NonNull SftpPlugin.StorageInfo storageInfo) {
if (this.storageInfo != null && this.storageInfo.equals(storageInfo)) {
return;
}
if (callChangeListener(storageInfo)) {
setStorageInfoInternal(storageInfo);
}
}
private void setStorageInfoInternal(@NonNull SftpPlugin.StorageInfo storageInfo) {
this.storageInfo = storageInfo;
setTitle(storageInfo.displayName);
setSummary(DocumentsContract.getTreeDocumentId(storageInfo.uri));
}
@Nullable
public SftpPlugin.StorageInfo getStorageInfo() {
return storageInfo;
}
@Override
public void setDefaultValue(Object defaultValue) {
if (defaultValue == null || defaultValue instanceof SftpPlugin.StorageInfo) {
super.setDefaultValue(defaultValue);
} else {
throw new RuntimeException("StoragePreference defaultValue must be null or an instance of StfpPlugin.StorageInfo");
}
}
@Override
protected void onSetInitialValue(@Nullable Object defaultValue) {
if (defaultValue != null) {
setStorageInfoInternal((SftpPlugin.StorageInfo) defaultValue);
}
}
@Override
public void onBindViewHolder(@NonNull PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
checkbox = (CheckBox) holder.itemView.findViewById(R.id.checkbox);
checkbox.setVisibility(inSelectionMode ? View.VISIBLE : View.INVISIBLE);
holder.itemView.setOnLongClickListener(v -> {
if (onLongClickListener != null) {
onLongClickListener.onLongClick(StoragePreference.this);
return true;
}
return false;
});
}
@Override
protected void onClick() {
if (inSelectionMode) {
checkbox.setChecked(!checkbox.isChecked());
} else {
super.onClick();
}
}
public interface OnLongClickListener {
void onLongClick(StoragePreference storagePreference);
}
}

View File

@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.content.Context
import android.provider.DocumentsContract
import android.util.AttributeSet
import android.view.View
import android.widget.CheckBox
import androidx.preference.DialogPreference
import androidx.preference.PreferenceViewHolder
import org.kde.kdeconnect_tp.R
class StoragePreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
DialogPreference(
context, attrs
) {
var storageInfo: SftpPlugin.StorageInfo? = null
private set
private var onLongClickListener: OnLongClickListener? = null
lateinit var checkbox: CheckBox
var inSelectionMode: Boolean = false
set(value) {
if (field != value) {
field = value
notifyChanged()
}
}
init {
dialogLayoutResource = R.layout.fragment_storage_preference_dialog
widgetLayoutResource = R.layout.preference_checkbox
isPersistent = false
}
fun setOnLongClickListener(listener: OnLongClickListener?) {
this.onLongClickListener = listener
}
fun setStorageInfo(storageInfo: SftpPlugin.StorageInfo) {
if (this.storageInfo != null && (this.storageInfo == storageInfo)) {
return
}
if (callChangeListener(storageInfo)) {
setStorageInfoInternal(storageInfo)
}
}
private fun setStorageInfoInternal(storageInfo: SftpPlugin.StorageInfo) {
this.storageInfo = storageInfo
title = storageInfo.displayName
summary = DocumentsContract.getTreeDocumentId(storageInfo.uri)
}
override fun setDefaultValue(defaultValue: Any?) {
require(defaultValue == null || defaultValue is SftpPlugin.StorageInfo) {
"StoragePreference defaultValue must be null or an instance of StfpPlugin.StorageInfo"
}
super.setDefaultValue(defaultValue)
}
override fun onSetInitialValue(defaultValue: Any?) {
if (defaultValue != null) {
setStorageInfoInternal(defaultValue as SftpPlugin.StorageInfo)
}
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
checkbox = holder.itemView.findViewById<View>(R.id.checkbox) as CheckBox
checkbox.visibility = if (inSelectionMode) View.VISIBLE else View.INVISIBLE
holder.itemView.setOnLongClickListener {
onLongClickListener?.let {
it.onLongClick(this@StoragePreference)
true
} ?: false
}
}
override fun onClick() {
if (inSelectionMode) {
checkbox.isChecked = !checkbox.isChecked
return
}
super.onClick()
}
interface OnLongClickListener {
fun onLongClick(storagePreference: StoragePreference)
}
}

View File

@@ -1,323 +0,0 @@
/*
* SPDX-FileCopyrightText: 2019 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.app.Activity;
import android.app.Dialog;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.provider.DocumentsContract;
import android.text.Editable;
import android.text.InputFilter;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.widget.TextViewCompat;
import androidx.preference.PreferenceDialogFragmentCompat;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.Helpers.StorageHelper;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.FragmentStoragePreferenceDialogBinding;
public class StoragePreferenceDialogFragment extends PreferenceDialogFragmentCompat implements TextWatcher {
private static final int REQUEST_CODE_DOCUMENT_TREE = 1001;
//When state is restored I cannot determine if an error is going to be displayed on one of the TextInputEditText's or not so I have to remember if the dialog's positive button was enabled or not
private static final String KEY_POSITIVE_BUTTON_ENABLED = "PositiveButtonEnabled";
private static final String KEY_STORAGE_INFO = "StorageInfo";
private static final String KEY_TAKE_FLAGS = "TakeFlags";
private FragmentStoragePreferenceDialogBinding binding;
private Callback callback;
private Drawable arrowDropDownDrawable;
private Button positiveButton;
private boolean stateRestored;
private boolean enablePositiveButton;
private SftpPlugin.StorageInfo storageInfo;
private int takeFlags;
public static StoragePreferenceDialogFragment newInstance(String key) {
StoragePreferenceDialogFragment fragment = new StoragePreferenceDialogFragment();
Bundle args = new Bundle();
args.putString(ARG_KEY, key);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
stateRestored = false;
enablePositiveButton = true;
if (savedInstanceState != null) {
stateRestored = true;
enablePositiveButton = savedInstanceState.getBoolean(KEY_POSITIVE_BUTTON_ENABLED);
takeFlags = savedInstanceState.getInt(KEY_TAKE_FLAGS, 0);
try {
JSONObject jsonObject = new JSONObject(savedInstanceState.getString(KEY_STORAGE_INFO, "{}"));
storageInfo = SftpPlugin.StorageInfo.fromJSON(jsonObject);
} catch (JSONException ignored) {}
}
Drawable drawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_drop_down_24px);
if (drawable != null) {
drawable = DrawableCompat.wrap(drawable);
DrawableCompat.setTint(drawable, ContextCompat.getColor(requireContext(),
android.R.color.darker_gray));
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
arrowDropDownDrawable = drawable;
}
}
void setCallback(Callback callback) {
this.callback = callback;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
AlertDialog dialog = (AlertDialog) super.onCreateDialog(savedInstanceState);
dialog.setOnShowListener(dialog1 -> {
AlertDialog alertDialog = (AlertDialog) dialog1;
positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(enablePositiveButton);
});
return dialog;
}
@Override
protected void onBindDialogView(@NonNull View view) {
super.onBindDialogView(view);
binding = FragmentStoragePreferenceDialogBinding.bind(view);
binding.storageLocation.setOnClickListener(v -> {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
//For API >= 26 we can also set Extra: DocumentsContract.EXTRA_INITIAL_URI
startActivityForResult(intent, REQUEST_CODE_DOCUMENT_TREE);
});
binding.storageDisplayName.setFilters(new InputFilter[]{new FileSeparatorCharFilter()});
binding.storageDisplayName.addTextChangedListener(this);
if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) {
if (!stateRestored) {
enablePositiveButton = false;
binding.storageLocation.setText(requireContext().getString(R.string.sftp_storage_preference_click_to_select));
}
boolean isClickToSelect = TextUtils.equals(binding.storageLocation.getText(),
getString(R.string.sftp_storage_preference_click_to_select));
TextViewCompat.setCompoundDrawablesRelative(binding.storageLocation, null, null,
isClickToSelect ? arrowDropDownDrawable : null, null);
binding.storageLocation.setEnabled(isClickToSelect);
binding.storageLocation.setFocusable(false);
binding.storageLocation.setFocusableInTouchMode(false);
binding.storageDisplayName.setEnabled(!isClickToSelect);
} else {
if (!stateRestored) {
StoragePreference preference = (StoragePreference) getPreference();
SftpPlugin.StorageInfo info = preference.getStorageInfo();
if (info == null) {
throw new RuntimeException("Cannot edit a StoragePreference that does not have its storageInfo set");
}
storageInfo = SftpPlugin.StorageInfo.copy(info);
binding.storageLocation.setText(DocumentsContract.getTreeDocumentId(storageInfo.uri));
binding.storageDisplayName.setText(storageInfo.displayName);
}
TextViewCompat.setCompoundDrawablesRelative(binding.storageLocation, null, null, null, null);
binding.storageLocation.setEnabled(false);
binding.storageLocation.setFocusable(false);
binding.storageLocation.setFocusableInTouchMode(false);
binding.storageDisplayName.setEnabled(true);
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != Activity.RESULT_OK) {
return;
}
switch (requestCode) {
case REQUEST_CODE_DOCUMENT_TREE:
Uri uri = data.getData();
takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
if (uri == null) {
return;
}
CallbackResult result = callback.isUriAllowed(uri);
if (result.isAllowed) {
String documentId = DocumentsContract.getTreeDocumentId(uri);
String displayName = StorageHelper.getDisplayName(requireContext(), uri);
storageInfo = new SftpPlugin.StorageInfo(displayName, uri);
binding.storageLocation.setText(documentId);
TextViewCompat.setCompoundDrawablesRelative(binding.storageLocation, null, null, null, null);
binding.storageLocation.setError(null);
binding.storageLocation.setEnabled(false);
// TODO: Show name as used in android's picker app but I don't think it's possible to get that, everything I tried throws PermissionDeniedException
binding.storageDisplayName.setText(displayName);
binding.storageDisplayName.setEnabled(true);
} else {
binding.storageLocation.setError(result.errorMessage);
setPositiveButtonEnabled(false);
}
break;
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(KEY_POSITIVE_BUTTON_ENABLED, positiveButton.isEnabled());
outState.putInt(KEY_TAKE_FLAGS, takeFlags);
if (storageInfo != null) {
try {
outState.putString(KEY_STORAGE_INFO, storageInfo.toJSON().toString());
} catch (JSONException ignored) {}
}
}
@Override
public void onDialogClosed(boolean positiveResult) {
if (positiveResult) {
storageInfo.displayName = binding.storageDisplayName.getText().toString();
if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) {
callback.addNewStoragePreference(storageInfo, takeFlags);
} else {
((StoragePreference)getPreference()).setStorageInfo(storageInfo);
}
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
//Don't care
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
//Don't care
}
@Override
public void afterTextChanged(Editable s) {
String displayName = s.toString();
StoragePreference storagePreference = (StoragePreference) getPreference();
SftpPlugin.StorageInfo storageInfo = storagePreference.getStorageInfo();
if (storageInfo == null || !storageInfo.displayName.equals(displayName)) {
CallbackResult result = callback.isDisplayNameAllowed(displayName);
if (result.isAllowed) {
setPositiveButtonEnabled(true);
} else {
setPositiveButtonEnabled(false);
binding.storageDisplayName.setError(result.errorMessage);
}
}
}
private void setPositiveButtonEnabled(boolean enabled) {
if (positiveButton != null) {
positiveButton.setEnabled(enabled);
} else {
enablePositiveButton = enabled;
}
}
private static class FileSeparatorCharFilter implements InputFilter {
//TODO: Add more chars to refuse?
//https://www.cyberciti.biz/faq/linuxunix-rules-for-naming-file-and-directory-names/
String notAllowed = "/\\><|:&?*";
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
boolean keepOriginal = true;
StringBuilder sb = new StringBuilder(end - start);
for (int i = start; i < end; i++) {
char c = source.charAt(i);
if (notAllowed.indexOf(c) < 0) {
sb.append(c);
} else {
keepOriginal = false;
sb.append("_");
}
}
if (keepOriginal) {
return null;
} else {
if (source instanceof Spanned) {
SpannableString sp = new SpannableString(sb);
TextUtils.copySpansFrom((Spanned) source, start, sb.length(), null, sp, 0);
return sp;
} else {
return sb;
}
}
}
}
static class CallbackResult {
boolean isAllowed;
String errorMessage;
}
interface Callback {
@NonNull CallbackResult isDisplayNameAllowed(@NonNull String displayName);
@NonNull CallbackResult isUriAllowed(@NonNull Uri uri);
void addNewStoragePreference(@NonNull SftpPlugin.StorageInfo storageInfo, int takeFlags);
}
}

View File

@@ -0,0 +1,338 @@
/*
* SPDX-FileCopyrightText: 2019 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.app.Activity
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.provider.DocumentsContract
import android.text.Editable
import android.text.InputFilter
import android.text.SpannableString
import android.text.Spanned
import android.text.TextUtils
import android.text.TextWatcher
import android.view.View
import android.widget.Button
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.widget.TextViewCompat
import androidx.preference.PreferenceDialogFragmentCompat
import org.json.JSONException
import org.json.JSONObject
import org.kde.kdeconnect.Helpers.StorageHelper
import org.kde.kdeconnect.Plugins.SftpPlugin.SftpPlugin.StorageInfo.Companion.fromJSON
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.FragmentStoragePreferenceDialogBinding
class StoragePreferenceDialogFragment : PreferenceDialogFragmentCompat(), TextWatcher {
private var binding: FragmentStoragePreferenceDialogBinding? = null
var callback: Callback? = null
private var arrowDropDownDrawable: Drawable? = null
private var positiveButton: Button? = null
private var stateRestored = false
private var enablePositiveButton = false
private var storageInfo: SftpPlugin.StorageInfo? = null
private var takeFlags = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
stateRestored = false
enablePositiveButton = true
if (savedInstanceState != null) {
stateRestored = true
enablePositiveButton = savedInstanceState.getBoolean(KEY_POSITIVE_BUTTON_ENABLED)
takeFlags = savedInstanceState.getInt(KEY_TAKE_FLAGS, 0)
try {
val jsonObject = JSONObject(savedInstanceState.getString(KEY_STORAGE_INFO, "{}"))
storageInfo = fromJSON(jsonObject)
} catch (ignored: JSONException) {
}
}
var drawable =
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_drop_down_24px)
if (drawable != null) {
drawable = DrawableCompat.wrap(drawable)
DrawableCompat.setTint(
drawable, ContextCompat.getColor(
requireContext(),
android.R.color.darker_gray
)
)
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
arrowDropDownDrawable = drawable
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as AlertDialog
dialog.setOnShowListener { alertDialog: DialogInterface ->
positiveButton = (alertDialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).apply {
isEnabled = enablePositiveButton
}
}
return dialog
}
override fun onBindDialogView(view: View) {
super.onBindDialogView(view)
val binding = FragmentStoragePreferenceDialogBinding.bind(view).also {
this.binding = it
}
binding.storageLocation.setOnClickListener { v: View? ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
// For API >= 26 we can also set Extra: DocumentsContract.EXTRA_INITIAL_URI
startActivityForResult(intent, REQUEST_CODE_DOCUMENT_TREE)
}
binding.storageDisplayName.filters = arrayOf<InputFilter>(FileSeparatorCharFilter())
binding.storageDisplayName.addTextChangedListener(this)
if (preference.key == getString(R.string.sftp_preference_key_add_storage)) {
if (!stateRestored) {
enablePositiveButton = false
binding.storageLocation.setText(requireContext().getString(R.string.sftp_storage_preference_click_to_select))
}
val isClickToSelect = TextUtils.equals(
binding.storageLocation.text,
getString(R.string.sftp_storage_preference_click_to_select)
)
TextViewCompat.setCompoundDrawablesRelative(
binding.storageLocation, null, null,
if (isClickToSelect) arrowDropDownDrawable else null, null
)
binding.storageLocation.isEnabled = isClickToSelect
binding.storageLocation.isFocusable = false
binding.storageLocation.isFocusableInTouchMode = false
binding.storageDisplayName.isEnabled = !isClickToSelect
} else {
if (!stateRestored) {
val preference = preference as StoragePreference
val info = preference.storageInfo
?: throw RuntimeException("Cannot edit a StoragePreference that does not have its storageInfo set")
storageInfo = info.copy()
binding.storageLocation.setText(DocumentsContract.getTreeDocumentId(storageInfo!!.uri))
binding.storageDisplayName.setText(storageInfo!!.displayName)
}
TextViewCompat.setCompoundDrawablesRelative(
binding.storageLocation,
null,
null,
null,
null
)
binding.storageLocation.isEnabled = false
binding.storageLocation.isFocusable = false
binding.storageLocation.isFocusableInTouchMode = false
binding.storageDisplayName.isEnabled = true
}
}
override fun onDestroyView() {
super.onDestroyView()
binding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) {
return
}
when (requestCode) {
REQUEST_CODE_DOCUMENT_TREE -> {
val uri = data!!.data
takeFlags =
data.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (uri == null) {
return
}
val result = callback!!.isUriAllowed(uri)
if (result.isAllowed) {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val displayName = StorageHelper.getDisplayName(requireContext(), uri)
storageInfo = SftpPlugin.StorageInfo(displayName, uri)
binding!!.storageLocation.setText(documentId)
TextViewCompat.setCompoundDrawablesRelative(
binding!!.storageLocation,
null,
null,
null,
null
)
binding!!.storageLocation.error = null
binding!!.storageLocation.isEnabled = false
// TODO: Show name as used in android's picker app but I don't think it's possible to get that, everything I tried throws PermissionDeniedException
binding!!.storageDisplayName.setText(displayName)
binding!!.storageDisplayName.isEnabled = true
} else {
binding!!.storageLocation.error = result.errorMessage
setPositiveButtonEnabled(false)
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_POSITIVE_BUTTON_ENABLED, positiveButton!!.isEnabled)
outState.putInt(KEY_TAKE_FLAGS, takeFlags)
if (storageInfo != null) {
try {
outState.putString(KEY_STORAGE_INFO, storageInfo!!.toJSON().toString())
} catch (ignored: JSONException) {
}
}
}
override fun onDialogClosed(positiveResult: Boolean) {
if (!positiveResult) return
storageInfo!!.displayName = binding!!.storageDisplayName.text.toString()
if (preference.key == getString(R.string.sftp_preference_key_add_storage)) {
callback!!.addNewStoragePreference(storageInfo!!, takeFlags)
} else {
(preference as StoragePreference).setStorageInfo(storageInfo!!)
}
}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
// Don't care
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
// Don't care
}
override fun afterTextChanged(s: Editable) {
val displayName = s.toString()
val storagePreference = preference as StoragePreference
val storageInfo = storagePreference.storageInfo
if (storageInfo != null && storageInfo.displayName == displayName) return
val result = callback!!.isDisplayNameAllowed(displayName)
if (result.isAllowed) {
setPositiveButtonEnabled(true)
} else {
setPositiveButtonEnabled(false)
binding!!.storageDisplayName.error = result.errorMessage
}
}
private fun setPositiveButtonEnabled(enabled: Boolean) {
if (positiveButton != null) {
positiveButton!!.isEnabled = enabled
} else {
enablePositiveButton = enabled
}
}
private class FileSeparatorCharFilter : InputFilter {
// TODO: Add more chars to refuse?
// https://www.cyberciti.biz/faq/linuxunix-rules-for-naming-file-and-directory-names/
var notAllowed: String = "/\\><|:&?*"
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned,
dstart: Int,
dend: Int
): CharSequence? {
var keepOriginal = true
val sb = StringBuilder(end - start)
for (i in start until end) {
val c = source[i]
if (notAllowed.indexOf(c) < 0) {
sb.append(c)
} else {
keepOriginal = false
sb.append("_")
}
}
if (keepOriginal) {
return null
} else {
if (source is Spanned) {
val sp = SpannableString(sb)
TextUtils.copySpansFrom(source, start, sb.length, null, sp, 0)
return sp
} else {
return sb
}
}
}
}
class CallbackResult {
@JvmField
var isAllowed: Boolean = false
@JvmField
var errorMessage: String? = null
}
interface Callback {
fun isDisplayNameAllowed(displayName: String): CallbackResult
fun isUriAllowed(uri: Uri): CallbackResult
fun addNewStoragePreference(storageInfo: SftpPlugin.StorageInfo, takeFlags: Int)
}
companion object {
private const val REQUEST_CODE_DOCUMENT_TREE = 1001
// When state is restored I cannot determine if an error is going to be displayed on one of the TextInputEditText's or not so I have to remember if the dialog's positive button was enabled or not
private const val KEY_POSITIVE_BUTTON_ENABLED = "PositiveButtonEnabled"
private const val KEY_STORAGE_INFO = "StorageInfo"
private const val KEY_TAKE_FLAGS = "TakeFlags"
@JvmStatic
fun newInstance(key: String): StoragePreferenceDialogFragment {
return StoragePreferenceDialogFragment().apply {
arguments = Bundle().apply {
putString(ARG_KEY, key)
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import java.io.IOException
import java.nio.file.attribute.BasicFileAttributeView
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
object RootBasicFileAttributeView : BasicFileAttributeView {
override fun name(): String = "basic"
override fun readAttributes(): BasicFileAttributes = RootFileAttributes
override fun setTimes(
lastModifiedTime: FileTime?,
lastAccessTime: FileTime?,
createTime: FileTime?
) {
throw IOException("Set times of root directory is not supported")
}
}

View File

@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import java.nio.file.attribute.FileTime
import java.nio.file.attribute.GroupPrincipal
import java.nio.file.attribute.PosixFileAttributes
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.UserPrincipal
object RootFileAttributes : PosixFileAttributes {
override fun lastModifiedTime(): FileTime = FileTime.fromMillis(0)
override fun lastAccessTime(): FileTime = FileTime.fromMillis(0)
override fun creationTime(): FileTime = FileTime.fromMillis(0)
override fun size(): Long = 0
override fun fileKey(): Any? = null
override fun isDirectory(): Boolean = true
override fun isRegularFile(): Boolean = false
override fun isSymbolicLink(): Boolean = false
override fun isOther(): Boolean = false
override fun owner(): UserPrincipal? = null
override fun group(): GroupPrincipal? = null
override fun permissions(): Set<PosixFilePermission> = // 660 for SAF
setOf(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
)
}

View File

@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import java.io.IOException
import java.nio.file.attribute.FileTime
import java.nio.file.attribute.GroupPrincipal
import java.nio.file.attribute.PosixFileAttributeView
import java.nio.file.attribute.PosixFileAttributes
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.UserPrincipal
object RootPosixFileAttributeView : PosixFileAttributeView {
override fun name(): String = "posix"
override fun readAttributes(): PosixFileAttributes = RootFileAttributes
override fun setTimes(
lastModifiedTime: FileTime?,
lastAccessTime: FileTime?,
createTime: FileTime?
) {
throw IOException("Set times of root directory is not supported")
}
override fun getOwner(): UserPrincipal? = null
override fun setOwner(owner: UserPrincipal?) {
throw IOException("Set owner of root directory is not supported")
}
override fun setPermissions(perms: MutableSet<PosixFilePermission>?) {
throw IOException("Set permissions of root directory is not supported")
}
override fun setGroup(group: GroupPrincipal?) {
throw IOException("Set group of root directory is not supported")
}
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.apache.sshd.common.file.util.BaseFileSystem
import java.nio.file.attribute.UserPrincipalLookupService
import java.nio.file.spi.FileSystemProvider
class SafFileSystem(
fileSystemProvider: FileSystemProvider,
private val roots: MutableMap<String, Uri>,
private val context: Context
) : BaseFileSystem<SafPath>(fileSystemProvider) {
override fun close() {
throw UnsupportedOperationException("SAF does not support closing")
}
override fun isOpen(): Boolean = true
override fun supportedFileAttributeViews(): Set<String> = setOf("basic", "posix")
override fun getUserPrincipalLookupService(): UserPrincipalLookupService {
throw UnsupportedOperationException("SAF does not support user principal lookup")
}
private tailrec fun getDocumentFileFromPath(
docFile: DocumentFile,
names: List<String>
): DocumentFile? {
if (names.isEmpty()) {
return docFile
}
val nextName = names.first()
val nextNames = names.drop(1)
val nextDocFile = docFile.findFile(nextName)
return if (nextDocFile != null) {
getDocumentFileFromPath(nextDocFile, nextNames)
} else {
null
}
}
override fun create(root: String?, names: List<String>): SafPath {
Log.v(TAG, "create: $root, $names")
if ((root == "/") && names.isEmpty()) {
return SafPath.newRootPath(this)
}
val dirName = names.getOrNull(0)
if (dirName != null) {
roots.forEach { (k, v) ->
if (k == dirName) {
if (names.size == 1) {
return SafPath(this, v, root, names)
} else {
val docFile = getDocumentFileFromPath(
DocumentFile.fromTreeUri(context, v)!!,
names.drop(1)
)
return SafPath(this, docFile?.uri, root, names)
}
}
}
}
return SafPath(this, null, root, names)
}
companion object {
private const val TAG = "SafFileSystem"
}
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.Context
import android.net.Uri
import android.util.Log
import org.apache.sshd.common.file.FileSystemFactory
import org.apache.sshd.common.session.SessionContext
import org.kde.kdeconnect.Plugins.SftpPlugin.SftpPlugin
import java.nio.file.FileSystem
import java.nio.file.Path
class SafFileSystemFactory(private val context: Context) : FileSystemFactory {
private val roots: MutableMap<String, Uri> = HashMap()
private val provider = SafFileSystemProvider(context, roots)
fun initRoots(storageInfoList: List<SftpPlugin.StorageInfo>) {
Log.i(TAG, "initRoots: $storageInfoList")
for (curStorageInfo in storageInfoList) {
when {
curStorageInfo.isFileUri -> {
TODO("File URI is not supported yet")
// if (curStorageInfo.uri.path != null) {
// roots[curStorageInfo.displayName] = curStorageInfo.uri.path
// }
}
curStorageInfo.isContentUri -> {
roots[curStorageInfo.displayName] = curStorageInfo.uri
}
else -> {
Log.e(TAG, "Unknown storage URI type: $curStorageInfo")
}
}
}
}
override fun createFileSystem(session: SessionContext?): FileSystem {
return SafFileSystem(provider, roots, context)
}
companion object {
private const val TAG = "SafFileSystemFactory"
}
override fun getUserHomeDir(session: SessionContext?): Path? = null
}

View File

@@ -0,0 +1,604 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.util.Log
import org.kde.kdeconnect.Helpers.MediaStoreHelper
import java.io.FileNotFoundException
import java.io.IOException
import java.lang.reflect.Method
import java.net.URI
import java.nio.channels.FileChannel
import java.nio.channels.SeekableByteChannel
import java.nio.file.AccessMode
import java.nio.file.CopyOption
import java.nio.file.DirectoryStream
import java.nio.file.FileAlreadyExistsException
import java.nio.file.FileStore
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.OpenOption
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributeView
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileAttribute
import java.nio.file.attribute.FileAttributeView
import java.nio.file.attribute.FileTime
import java.nio.file.attribute.GroupPrincipal
import java.nio.file.attribute.PosixFileAttributeView
import java.nio.file.attribute.PosixFileAttributes
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.UserPrincipal
import java.nio.file.spi.FileSystemProvider
class SafFileSystemProvider(
private val context: Context,
val roots: MutableMap<String, Uri>
) : FileSystemProvider() {
override fun getScheme(): String = "saf"
override fun newFileSystem(uri: URI, env: MutableMap<String, *>?): FileSystem {
// SSHD Core does not use this method, so we can just throw an exception
Log.w(TAG, "newFileSystem($uri) not implemented")
throw NotImplementedError("newFileSystem($uri) not implemented")
}
override fun getFileSystem(uri: URI): FileSystem {
// SSHD Core does not use this method, so we can just throw an exception
Log.w(TAG, "getFileSystem($uri) not implemented")
throw NotImplementedError("getFileSystem($uri) not implemented")
}
override fun getPath(uri: URI): Path {
// SSHD Core does not use this method, so we can just throw an exception
Log.w(TAG, "getPath($uri) not implemented")
throw NotImplementedError("getPath($uri) not implemented")
}
/**
* @see org.apache.sshd.sftp.server.FileHandle.getOpenOptions
*/
override fun newByteChannel(
path: Path,
options: Set<OpenOption>,
vararg attrs_: FileAttribute<*>
): SeekableByteChannel {
val channel = newFileChannel(path, options, *attrs_)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return convertMaybeLegacyFileChannelFromLibraryFunction.invoke(
null,
channel
) as SeekableByteChannel
}
return channel
}
private fun createFile(path: SafPath, failedWhenExists: Boolean): Uri {
if (path.isRoot()) {
throw IOException("Cannot create root directory")
}
if (failedWhenExists && Files.exists(path)) {
throw FileAlreadyExistsException(path.toString())
}
val parent = path.parent.getDocumentFile(context)
?: throw IOException("Parent directory does not exist")
val docFile = parent.createFile(Files.probeContentType(path), path.names.last())
?: throw IOException("Failed to create $path")
val uri = docFile.uri
path.safUri = uri
return uri
}
/**
* @see org.apache.sshd.sftp.server.FileHandle.getOpenOptions
*/
override fun newFileChannel(
path: Path,
options: Set<OpenOption>,
vararg attrs_: FileAttribute<*>
): FileChannel {
check(path is SafPath)
check(!path.isRoot())
/*
* According to https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#page-33
*
* The 'attrs' field is ignored if an existing file is opened.
*/
val attrs = if (Files.exists(path)) {
emptyArray()
} else {
attrs_
}
when {
// READ
options.contains(StandardOpenOption.READ) -> {
if (options.contains(StandardOpenOption.WRITE)) {
throw IllegalArgumentException("Cannot open a file for both reading and writing")
}
if (options.contains(StandardOpenOption.CREATE_NEW) || options.contains(StandardOpenOption.CREATE)) {
createFile(path, options.contains(StandardOpenOption.CREATE_NEW))
}
val docFile = path.getDocumentFile(context)!!
return ParcelFileDescriptor.AutoCloseInputStream(
context.contentResolver.openFileDescriptor(docFile.uri, "r")!!
).channel
}
// WRITE
options.contains(StandardOpenOption.WRITE) -> {
if (options.contains(StandardOpenOption.CREATE_NEW) || options.contains(StandardOpenOption.CREATE)) {
createFile(path, options.contains(StandardOpenOption.CREATE_NEW))
}
val docFile =
path.getDocumentFile(context) ?: throw IOException("Failed to create $path")
check(docFile.exists())
val mode = when {
options.contains(StandardOpenOption.APPEND) -> "wa"
options.contains(StandardOpenOption.TRUNCATE_EXISTING) -> "wt"
else -> "w"
}
return ParcelFileDescriptor.AutoCloseOutputStream(
context.contentResolver.openFileDescriptor(docFile.uri, mode)!!
).channel
}
else -> {
Log.w(TAG, "newFileChannel($path, $options, $attrs) not implemented")
throw IOException("newFileChannel($path, $options, $attrs) not implemented")
}
}
}
override fun newDirectoryStream(
dir: Path,
filter: DirectoryStream.Filter<in Path>
): DirectoryStream<Path> {
check(dir is SafPath)
if (dir.isRoot()) {
return object : DirectoryStream<Path> {
override fun iterator(): MutableIterator<Path> {
return roots.mapNotNull { (name, uri) ->
val newPath = SafPath(dir.fileSystem, uri, null, listOf(name))
if (filter.accept(newPath)) newPath else null
}.toMutableList().iterator()
}
override fun close() {
// no-op
}
}
}
check(dir.names.isNotEmpty())
return object : DirectoryStream<Path> {
override fun iterator(): MutableIterator<Path> {
val documentFile = dir.getDocumentFile(context)!!
return documentFile.listFiles().mapNotNull {
if (it.uri.path?.endsWith(".android_secure") == true) return@mapNotNull null
val newPath = SafPath(dir.fileSystem, it.uri, null, dir.names + it.name!!)
if (filter.accept(newPath)) newPath else null
}.toMutableList().iterator()
}
override fun close() {
// no-op
}
}
}
override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) {
check(dir is SafPath)
if (dir.isRoot()) {
throw IOException("Cannot create root directory")
}
if (dir.parent == null) {
throw IOException("Parent directory does not exist")
}
val parent = dir.parent.getDocumentFile(context)
?: throw IOException("Parent directory does not exist")
parent.createDirectory(dir.names.last())
}
override fun delete(path: Path) {
check(path is SafPath)
val docFile = path.getDocumentFile(context)
?: throw java.nio.file.NoSuchFileException(
path.toString(),
) // No kotlin.NoSuchFileException, they are different
if (!docFile.delete()) {
throw IOException("Failed to delete $path")
}
}
override fun copy(source: Path, target: Path, vararg options: CopyOption) {
check(source is SafPath)
check(target is SafPath)
val sourceDocFile = source.getDocumentFile(context)
?: throw java.nio.file.NoSuchFileException(
source.toString(),
) // No kotlin.NoSuchFileException, they are different
val targetDocFile = target.apply {
createFile(this, false)
}.getDocumentFile(context)
?: throw java.nio.file.NoSuchFileException(
target.toString(),
) // No kotlin.NoSuchFileException, they are different
context.contentResolver.openOutputStream(targetDocFile.uri)?.use { os ->
context.contentResolver.openInputStream(sourceDocFile.uri)?.use { is_ ->
is_.copyTo(os)
}
}
}
override fun move(source: Path, target: Path, vararg options: CopyOption) {
check(source is SafPath)
check(target is SafPath)
val sourceUri = source.getDocumentFile(context)!!.uri
val parentUri = source.parent.getDocumentFile(context)!!.uri
val destParentUri = target.parent.getDocumentFile(context)!!.uri
// 1. If dest parent is the same as source parent, rename the file
run firstStep@{
if (parentUri == destParentUri) {
try {
val newUri = DocumentsContract.renameDocument(
context.contentResolver,
sourceUri,
target.names.last()
)
if (newUri == null) { // renameDocument returns null on failure
return@firstStep
}
source.safUri = newUri
return
} catch (ignored: FileNotFoundException) {
// no-op: fallback to the next method
}
}
}
val sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri)
val destTreeDocumentId = DocumentsContract.getTreeDocumentId(destParentUri)
// 2. If source and dest are in the same tree, and the API level is high enough, move the file
if (sourceTreeDocumentId == destTreeDocumentId &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
) {
val newUri = DocumentsContract.moveDocument(
context.contentResolver,
sourceUri,
parentUri,
destParentUri
)
source.safUri = newUri!!
return
}
// 3. Else copy and delete the file
copy(source, target, *options)
delete(source)
}
override fun isSameFile(p1: Path, p2: Path): Boolean {
check(p1 is SafPath)
check(p2 is SafPath)
return p1.root == p2.root && p1.names == p2.names &&
p1.getDocumentFile(context)!!.uri == p2.getDocumentFile(context)!!.uri
}
override fun isHidden(path: Path): Boolean {
check(path is SafPath)
if (path.isRoot()) {
return false
}
return path.names.last().startsWith(".")
}
override fun getFileStore(path: Path): FileStore? {
// SAF does not support file store
Log.i(TAG, "getFileStore($path) not implemented")
return null
}
override fun checkAccess(path: Path, vararg modes: AccessMode) {
check(path is SafPath)
if (path.isRoot()) {
modes.forEach {
when (it) {
AccessMode.READ -> {
// Root is always readable
}
AccessMode.WRITE -> {
// Root is not writable
throw java.nio.file.AccessDeniedException("/") // No kotlin.AccessDeniedException, they are different
}
AccessMode.EXECUTE -> {
// Root is not executable
throw java.nio.file.AccessDeniedException("/") // No kotlin.AccessDeniedException, they are different
}
}
}
return
}
val docFile = path.getDocumentFile(context)
?: throw java.nio.file.NoSuchFileException(
path.toString(),
) // No kotlin.NoSuchFileException, they are different
if (!docFile.exists()) {
throw java.nio.file.NoSuchFileException(
docFile.uri.toString(),
) // No kotlin.NoSuchFileException, they are different
}
modes.forEach {
when (it) {
AccessMode.READ -> {
if (!docFile.canRead()) {
throw java.nio.file.AccessDeniedException(docFile.uri.toString()) // No kotlin.AccessDeniedException, they are different
}
}
AccessMode.WRITE -> {
if (!docFile.canWrite()) {
throw java.nio.file.AccessDeniedException(docFile.uri.toString()) // No kotlin.AccessDeniedException, they are different
}
}
AccessMode.EXECUTE -> {
// SAF files is not executable
throw java.nio.file.AccessDeniedException(docFile.uri.toString()) // No kotlin.AccessDeniedException, they are different
}
}
}
}
override fun <V : FileAttributeView> getFileAttributeView(
path: Path,
type: Class<V>,
vararg options: LinkOption?
): V? {
check(path is SafPath)
if (path.isRoot()) {
if (type == BasicFileAttributeView::class.java) {
@Suppress("UNCHECKED_CAST")
return RootBasicFileAttributeView as V
}
if (type == PosixFileAttributeView::class.java) {
@Suppress("UNCHECKED_CAST")
return RootPosixFileAttributeView as V
}
}
if (type == BasicFileAttributeView::class.java) {
@Suppress("UNCHECKED_CAST")
return object : BasicFileAttributeView {
override fun name(): String = "basic"
override fun readAttributes(): BasicFileAttributes =
readAttributes(path, BasicFileAttributes::class.java)
override fun setTimes(
lastModifiedTime: FileTime?,
lastAccessTime: FileTime?,
createTime: FileTime?
) {
Log.w(
TAG,
"setTimes($path, $lastModifiedTime, $lastAccessTime, $createTime) for SAF is impossible. Ignored."
)
}
} as V
}
if (type == PosixFileAttributeView::class.java) {
@Suppress("UNCHECKED_CAST")
return object : PosixFileAttributeView {
override fun name(): String = "posix"
override fun readAttributes(): PosixFileAttributes =
readAttributes(path, PosixFileAttributes::class.java)
override fun setTimes(
lastModifiedTime: FileTime?,
lastAccessTime: FileTime?,
createTime: FileTime?
) {
Log.w(
TAG,
"setTimes($path, $lastModifiedTime, $lastAccessTime, $createTime) for SAF is impossible. Ignored."
)
}
override fun getOwner(): UserPrincipal? {
Log.i(TAG, "getOwner($path) not implemented")
return null
}
override fun setOwner(owner: UserPrincipal?) {
Log.i(TAG, "setOwner($path, $owner) not implemented")
}
override fun setPermissions(perms: MutableSet<PosixFilePermission>?) {
Log.i(TAG, "setPermissions($path, $perms) not implemented")
}
override fun setGroup(group: GroupPrincipal?) {
Log.i(TAG, "setGroup($path, $group) not implemented")
}
} as V
}
Log.w(TAG, "getFileAttributeView($path)[${type.getSimpleName()}] not implemented")
return null
}
override fun <A : BasicFileAttributes> readAttributes(
path: Path,
type: Class<A>,
vararg options: LinkOption?
): A {
check(path is SafPath)
if (path.isRoot()) {
if (type == BasicFileAttributes::class.java || type == PosixFileAttributes::class.java) {
@Suppress("UNCHECKED_CAST")
return RootFileAttributes as A
}
}
path.getDocumentFile(context).let {
if (it == null) {
throw java.nio.file.NoSuchFileException(
path.toString(),
) // No kotlin.NoSuchFileException, they are different
}
if (type == BasicFileAttributes::class.java || type == PosixFileAttributes::class.java) {
@Suppress("UNCHECKED_CAST")
return object : PosixFileAttributes {
override fun lastModifiedTime(): FileTime =
FileTime.fromMillis(it.lastModified())
override fun lastAccessTime(): FileTime = FileTime.fromMillis(it.lastModified())
override fun creationTime(): FileTime = FileTime.fromMillis(it.lastModified())
override fun size(): Long = it.length()
override fun fileKey(): Any? = null
override fun isDirectory(): Boolean = it.isDirectory
override fun isRegularFile(): Boolean = it.isFile
override fun isSymbolicLink(): Boolean = false
override fun isOther(): Boolean = false
override fun owner(): UserPrincipal? = null
override fun group(): GroupPrincipal? = null
override fun permissions(): Set<PosixFilePermission> = // 660 for SAF
setOf(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
)
} as A
}
}
Log.w(TAG, "readAttributes($path)[${type.getSimpleName()}] not implemented")
throw UnsupportedOperationException("readAttributes($path)[${type.getSimpleName()}] N/A")
}
override fun readAttributes(
path: Path,
attributes: String,
vararg options: LinkOption?
): Map<String, Any?> {
check(path is SafPath)
if (path.isRoot()) {
if (attributes == "basic" || attributes.startsWith("basic:")) {
return mapOf(
"isDirectory" to true,
"isRegularFile" to false,
"isSymbolicLink" to false,
"isOther" to false,
"size" to 0L,
"fileKey" to null,
"lastModifiedTime" to FileTime.fromMillis(0),
"lastAccessTime" to FileTime.fromMillis(0),
"creationTime" to FileTime.fromMillis(0)
)
}
if (attributes == "posix" || attributes.startsWith("posix:")) {
return mapOf(
"owner" to null,
"group" to null,
"permissions" to setOf(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
)
)
}
}
val documentFile = path.getDocumentFile(context)
check(documentFile != null)
if (attributes == "basic" || attributes.startsWith("basic:")) {
return mapOf(
"isDirectory" to documentFile.isDirectory,
"isRegularFile" to documentFile.isFile,
"isSymbolicLink" to false,
"isOther" to false,
"size" to documentFile.length(),
"fileKey" to null,
"lastModifiedTime" to FileTime.fromMillis(documentFile.lastModified()),
"lastAccessTime" to FileTime.fromMillis(documentFile.lastModified()),
"creationTime" to FileTime.fromMillis(documentFile.lastModified())
)
}
if (attributes == "posix" || attributes.startsWith("posix:")) {
return mapOf(
"owner" to null,
"group" to null,
"permissions" to setOf(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
)
)
}
Log.w(TAG, "readAttributes($path, $attributes) not implemented")
throw UnsupportedOperationException("readAttributes($path, $attributes) N/A")
}
override fun setAttribute(
path: Path,
attribute: String,
value: Any?,
vararg options: LinkOption?
) {
check(path is SafPath)
when (attribute) {
"basic:lastModifiedTime", "basic:lastAccessTime", "basic:creationTime" -> {
check(value is FileTime)
throw UnsupportedOperationException("$attribute is read-only")
}
"posix:owner", "posix:group", "posix:permissions" -> {
Log.w(TAG, "set posix attribute $attribute not implemented")
// We can't throw an exception here because the SSHD server will crash
return
}
else -> {
Log.w(TAG, "setAttribute($path, $attribute, $value) not implemented")
// We can't throw an exception here because the SSHD server will crash
}
}
}
companion object {
private const val TAG = "SafFileSystemProvider"
private val convertMaybeLegacyFileChannelFromLibraryFunction: Method by lazy {
val clazz = Class.forName("j$.nio.channels.DesugarChannels")
clazz.getDeclaredMethod(
"convertMaybeLegacyFileChannelFromLibrary",
FileChannel::class.java
)
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import org.apache.sshd.common.file.util.BasePath
import java.net.URI
import java.nio.file.LinkOption
class SafPath(
fileSystem: SafFileSystem,
var safUri: Uri?,
val root: String?, val names: List<String>
) : BasePath<SafPath, SafFileSystem>(fileSystem, root, names) {
override fun toRealPath(vararg options: LinkOption?): SafPath {
return this.normalize()
}
override fun toUri(): URI {
return URI.create(safUri.toString()) ?: throw IllegalStateException("SafUri is null")
}
fun getDocumentFile(ctx: Context): DocumentFile? {
if (safUri == null) return null
return DocumentFile.fromTreeUri(ctx, safUri!!)
}
fun isRoot(): Boolean {
return (root == "/") && names.isEmpty()
}
companion object {
fun newRootPath(fileSystem: SafFileSystem): SafPath {
return SafPath(fileSystem, null, "/", emptyList())
}
}
}

View File

@@ -8,6 +8,7 @@ package org.kde.kdeconnect.Plugins.SharePlugin;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
@@ -160,7 +161,10 @@ public class ShareActivity extends AppCompatActivity {
super.onStart();
final Intent intent = getIntent();
final String deviceId = intent.getStringExtra("deviceId");
String deviceId = intent.getStringExtra("deviceId");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && deviceId == null) {
deviceId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID);
}
if (deviceId != null) {
SharePlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, SharePlugin.class);

View File

@@ -24,6 +24,10 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat;
import androidx.core.content.LocusIdCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.preference.PreferenceManager;
import org.apache.commons.lang3.ArrayUtils;
@@ -33,6 +37,7 @@ import org.kde.kdeconnect.Helpers.IntentHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.UserInterface.MainActivity;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect.async.BackgroundJob;
import org.kde.kdeconnect.async.BackgroundJobHandler;
@@ -43,6 +48,7 @@ import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
/**
@@ -84,11 +90,34 @@ public class SharePlugin extends Plugin {
public boolean onCreate() {
super.onCreate();
mSharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
Intent shortcutIntent = new Intent(context, MainActivity.class);
shortcutIntent.setAction(Intent.ACTION_VIEW);
shortcutIntent.putExtra(MainActivity.EXTRA_DEVICE_ID, device.getDeviceId());
IconCompat icon = IconCompat.createWithResource(context, device.getDeviceType().toShortcutDrawableId());
ShortcutInfoCompat shortcut = new ShortcutInfoCompat
.Builder(context, device.getDeviceId())
.setIntent(shortcutIntent)
.setIcon(icon)
.setShortLabel(device.getName())
.setCategories(Set.of("org.kde.kdeconnect.category.SHARE_TARGET"))
.setLocusId(new LocusIdCompat(device.getDeviceId()))
.build();
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut);
// Deliver URLs previously shared to this device now that it's connected
deliverPreviouslySentIntents();
return true;
}
@Override
public void onDestroy() {
ShortcutManagerCompat.removeLongLivedShortcuts(context, List.of(device.getDeviceId()));
super.onDestroy();
}
private void deliverPreviouslySentIntents() {
Set<String> currentUrlSet = mSharedPrefs.getStringSet(KEY_UNREACHABLE_URL_LIST + device.getDeviceId(), null);
if (currentUrlSet != null) {

View File

@@ -37,7 +37,7 @@ fun getApplicationAboutData(context: Context): AboutData {
aboutData.authors += AboutPerson("Maxim Leshchenko", R.string.maxim_leshchenko_task, "cnmaks90@gmail.com")
aboutData.authors += AboutPerson("Holger Kaelberer", R.string.holger_kaelberer_task, "holger.k@elberer.de")
aboutData.authors += AboutPerson("Saikrishna Arcot", R.string.saikrishna_arcot_task, "saiarcot895@gmail.com")
aboutData.authors += AboutPerson("ShellWen Chen", R.string.bug_fixes_and_general_improvements, "me@shellwen.com")
aboutData.authors += AboutPerson("ShellWen Chen", R.string.shellwen_chen_task, "me@shellwen.com")
// Have you made some contributions and think your name should be here? Open a MR to add yourself to the list :)

View File

@@ -6,7 +6,6 @@
package org.kde.kdeconnect.UserInterface
import android.Manifest
import android.content.DialogInterface
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
@@ -49,11 +48,11 @@ class TrustedNetworksActivity : AppCompatActivity() {
setDisplayShowHomeEnabled(true)
}
trustedNetworks.addAll(trustedNetworkHelper.read())
trustedNetworks.addAll(trustedNetworkHelper.trustedNetworks)
allowAllCheckBox.setOnCheckedChangeListener { _, isChecked ->
if (trustedNetworkHelper.hasPermissions()) {
trustedNetworkHelper.allAllowed(isChecked)
if (trustedNetworkHelper.hasPermissions) {
trustedNetworkHelper.allNetworksAllowed = isChecked
updateTrustedNetworkListView()
addNetworkButton()
} else {
@@ -66,18 +65,18 @@ class TrustedNetworksActivity : AppCompatActivity() {
.create().show(supportFragmentManager, null)
}
}
allowAllCheckBox.isChecked = trustedNetworkHelper.allAllowed()
allowAllCheckBox.isChecked = trustedNetworkHelper.allNetworksAllowed
updateTrustedNetworkListView()
}
private fun updateEmptyListMessage() {
val isVisible = trustedNetworks.isEmpty() && !trustedNetworkHelper.allAllowed()
val isVisible = trustedNetworks.isEmpty() && !trustedNetworkHelper.allNetworksAllowed
binding.trustedNetworkListEmpty.visibility = if (isVisible) View.VISIBLE else View.GONE
}
private fun updateTrustedNetworkListView() {
val allAllowed = trustedNetworkHelper.allAllowed()
val allAllowed = trustedNetworkHelper.allNetworksAllowed
updateEmptyListMessage()
trustedNetworksView.visibility = if (allAllowed) View.GONE else View.VISIBLE
if (allAllowed) {
@@ -91,7 +90,7 @@ class TrustedNetworksActivity : AppCompatActivity() {
.setMessage("Delete $targetItem ?")
.setPositiveButton("Yes") { _, _ ->
trustedNetworks.removeAt(position)
trustedNetworkHelper.update(trustedNetworks)
trustedNetworkHelper.trustedNetworks = trustedNetworks
(trustedNetworksView.adapter as ArrayAdapter<*>).notifyDataSetChanged()
addNetworkButton()
updateEmptyListMessage()
@@ -104,20 +103,19 @@ class TrustedNetworksActivity : AppCompatActivity() {
private fun addNetworkButton() {
val addButton = binding.button1
if (trustedNetworkHelper.allAllowed()) {
if (trustedNetworkHelper.allNetworksAllowed) {
addButton.visibility = View.GONE
return
}
val currentSSID = trustedNetworkHelper.currentSSID()
if (currentSSID.isNotEmpty() && !trustedNetworks.contains(currentSSID)) {
val buttonText = getString(R.string.add_trusted_network, currentSSID)
addButton.text = buttonText
val currentSSID = trustedNetworkHelper.currentSSID
if (currentSSID != null && currentSSID !in trustedNetworks) {
addButton.text = getString(R.string.add_trusted_network, currentSSID)
addButton.setOnClickListener { v ->
if (trustedNetworks.contains(currentSSID)) {
return@setOnClickListener
}
trustedNetworks.add(currentSSID)
trustedNetworkHelper.update(trustedNetworks)
trustedNetworkHelper.trustedNetworks = trustedNetworks
(trustedNetworksView.adapter as ArrayAdapter<*>).notifyDataSetChanged()
v.visibility = View.GONE
updateEmptyListMessage()

View File

@@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: 2024 TPJ Schikhof <kde@schikhof.eu>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers
import org.junit.Assert
import org.junit.Test
class VideoUrlsHelperTest {
@Test
fun checkYoutubeURL() {
val url = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L)
val expected = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=51"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkYoutubeURLSubSecond() {
val url = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 450L)
val expected = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkVimeoURL() {
val url = "https://vimeo.com/347119375?foo=bar&t=13s"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L)
val expected = "https://vimeo.com/347119375?foo=bar&t=51s"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkVimeoURLSubSecond() {
val url = "https://vimeo.com/347119375?foo=bar&t=13s"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 450L)
val expected = "https://vimeo.com/347119375?foo=bar&t=13s"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkVimeoURLParamOrderCrash() {
val url = "https://vimeo.com/347119375?t=13s"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L)
val expected = "https://vimeo.com/347119375?t=51s"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkDailymotionURL() {
val url = "https://www.dailymotion.com/video/xnopyt?foo=bar&start=13"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L)
val expected = "https://www.dailymotion.com/video/xnopyt?foo=bar&start=51"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkTwitchURL() {
val url = "https://www.twitch.tv/videos/123?foo=bar&t=1h2m3s"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 10_000_000)
val expected = "https://www.twitch.tv/videos/123?foo=bar&t=02h46m40s"
Assert.assertEquals(expected, formatted.toString())
}
@Test
fun checkUnknownURL() {
val url = "https://example.org/cool_video.mp4"
val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L)
val expected = "https://example.org/cool_video.mp4"
Assert.assertEquals(expected, formatted.toString())
}
}