mirror of
https://github.com/KDE/kdeconnect-android
synced 2025-08-31 22:25:08 +00:00
Compare commits
69 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
eb6784b626 | ||
|
1beb8d4581 | ||
|
16067d7523 | ||
|
dea0bb4e1f | ||
|
c9fb81363d | ||
|
35e8ea0c4c | ||
|
7c5c7933c9 | ||
|
0dfa44aeac | ||
|
dd527f661c | ||
|
ed89fb43ed | ||
|
aed2b64416 | ||
|
4bdbb8f74a | ||
|
155ebf4fb2 | ||
|
46ad0c62ba | ||
|
df0f2d651c | ||
|
167e2c7176 | ||
|
906326f837 | ||
|
3c9c49fa87 | ||
|
24a6beb600 | ||
|
0775a45316 | ||
|
90dbdee282 | ||
|
7d28c52c35 | ||
|
7686e012c3 | ||
|
2cb9666678 | ||
|
0ab4e0d1d2 | ||
|
30cc95713f | ||
|
ae49aa6456 | ||
|
89454fcba9 | ||
|
5a6453729c | ||
|
0706ec1a0b | ||
|
405e828683 | ||
|
34a78e635e | ||
|
c327c15825 | ||
|
6d027ae810 | ||
|
104013c916 | ||
|
8df1f04141 | ||
|
beab3599bf | ||
|
819d3ea158 | ||
|
83fd2440ce | ||
|
e13451061f | ||
|
e82c0fea84 | ||
|
e391750e0e | ||
|
6513bb1320 | ||
|
0fb6e25682 | ||
|
7fbfc9df90 | ||
|
cd8237d773 | ||
|
aaa750bbc6 | ||
|
3d54da75cc | ||
|
358584ba6f | ||
|
adfab5f0f3 | ||
|
e37a519e3a | ||
|
6783f0a167 | ||
|
6d78fe749a | ||
|
2120c7967e | ||
|
de861ce781 | ||
|
e222937736 | ||
|
e289811097 | ||
|
067a000b2b | ||
|
f9d05824a7 | ||
|
d753f1eea4 | ||
|
3c81b527eb | ||
|
96147bf6df | ||
|
8b33ce64a4 | ||
|
73fdd4b47e | ||
|
680e404d05 | ||
|
aae6f1a7e9 | ||
|
5cda1ceb0c | ||
|
7ed4efedc3 | ||
|
96ecd620cf |
@@ -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"
|
||||
|
147
build.gradle.kts
147
build.gradle.kts
@@ -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
|
||||
|
21
fastlane/metadata/android/bg/full_description.txt
Normal file
21
fastlane/metadata/android/bg/full_description.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
KDE Connect предоставя набор от функции за интегриране на вашия работен процес на различни устройства:
|
||||
|
||||
- Прехвърляйте файлове между вашите устройства.
|
||||
- Осъществявайте достъп до файлове на телефона си от компютъра си, без кабели.
|
||||
- Споделен клипборд: копирайте и поставяйте между вашите устройства.
|
||||
- Получавайте известия за входящи обаждания и съобщения на вашия компютър.
|
||||
- Виртуален тъчпад: Използвайте екрана на телефона си като тъчпад на компютъра.
|
||||
- Синхронизиране на известия: Достъп до известията на телефона ви от вашия компютър и отговаряне на съобщения.
|
||||
- Мултимедийно дистанционно управление: Използвайте телефона си като дистанционно за Linux медийни плейъри.
|
||||
- WiFi връзка: не е необходим USB кабел или bluetooth.
|
||||
- TLS криптиране от край до край: информацията ви е в безопасност.
|
||||
|
||||
Моля, имайте предвид, че ще трябва да инсталирате KDE Connect на вашия компютър, за да работи това приложение, и поддържайте версията за настолен компютър актуална с версията за Android, за да работят най-новите функции.
|
||||
|
||||
Поверителна информация за разрешения:
|
||||
* Разрешение за достъпност: Изисква се за получаване на вход от друго устройство за управление на вашия телефон с Android, ако използвате функцията за отдалечено въвеждане.
|
||||
* Разрешение за местоположение във фонов режим: Изисква се, за да знаете към коя WiFi мрежа сте свързани, ако използвате функцията Trusted Networks.
|
||||
|
||||
KDE Connect никога не изпраща никаква информация на KDE или на трета страна. KDE Connect изпраща данни от едно устройство на друго директно чрез локалната мрежа, никога през интернет, и чрез криптиране от край до край.
|
||||
|
||||
Това приложение е част от проект с отворен код и съществува благодарение на всички хора, които са допринесли за него. Посетете уебсайта, за да вземете изходния код.
|
1
fastlane/metadata/android/bg/short_description.txt
Normal file
1
fastlane/metadata/android/bg/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
KDE Connect интегрира вашия смартфон и компютър
|
1
fastlane/metadata/android/bg/title.txt
Normal file
1
fastlane/metadata/android/bg/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
KDE Connect
|
10
fastlane/metadata/android/en-US/changelogs/13200.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/13200.txt
Normal 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
|
13
fastlane/metadata/android/en-US/changelogs/13201.txt
Normal file
13
fastlane/metadata/android/en-US/changelogs/13201.txt
Normal 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
|
21
fastlane/metadata/android/no-NO/full_description.txt
Normal file
21
fastlane/metadata/android/no-NO/full_description.txt
Normal 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 telefonskjermen som styreplate for datamaskina
|
||||
– Synkronisering av varslingar: få tilgang til telefonvarslingar 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»
|
||||
– Bakgrunnslø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.
|
1
fastlane/metadata/android/no-NO/short_description.txt
Normal file
1
fastlane/metadata/android/no-NO/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
KDE Connect koplar telefonen din saman med datamaskina
|
1
fastlane/metadata/android/no-NO/title.txt
Normal file
1
fastlane/metadata/android/no-NO/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
KDE Connect
|
@@ -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" }
|
||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
85
po/bg/kdeconnect-android-store-full.po
Normal file
85
po/bg/kdeconnect-android-store-full.po
Normal 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"
|
19
po/bg/kdeconnect-android-store-short.po
Normal file
19
po/bg/kdeconnect-android-store-short.po
Normal 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 интегрира вашия смартфон и компютър"
|
@@ -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 ""
|
||||
|
85
po/nn/kdeconnect-android-store-full.po
Normal file
85
po/nn/kdeconnect-android-store-full.po
Normal 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 telefonskjermen som styreplate for datamaskina\n"
|
||||
"– Synkronisering av varslingar: få tilgang til telefonvarslingar 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"
|
||||
"– Bakgrunnslø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"
|
13
res/drawable/ic_device_desktop_shortcut.xml
Normal file
13
res/drawable/ic_device_desktop_shortcut.xml
Normal 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>
|
17
res/drawable/ic_device_laptop_shortcut.xml
Normal file
17
res/drawable/ic_device_laptop_shortcut.xml
Normal 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>
|
18
res/drawable/ic_device_phone_shortcut.xml
Normal file
18
res/drawable/ic_device_phone_shortcut.xml
Normal 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>
|
18
res/drawable/ic_device_tablet_shortcut.xml
Normal file
18
res/drawable/ic_device_tablet_shortcut.xml
Normal 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>
|
17
res/drawable/ic_device_tv_shortcut.xml
Normal file
17
res/drawable/ic_device_tv_shortcut.xml
Normal 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>
|
@@ -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>
|
||||
|
@@ -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"><h1>Quant al</h1> <p>El KDE és una comunitat mundial d\'enginyers, artistes, escriptors, traductors i creadors de programari compromesos amb el desenvolupament de <a href=https://www.gnu.org/philosophy/free-sw.html>programari lliure</a>. El KDE produeix l\'entorn d\'escriptori Plasma, centenars d\'aplicacions i moltes biblioteques de programari que els donen suport.</p> <p>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 <a href=https://community.kde.org/Get_Involved>unir-se i contribuir</a> al KDE, inclosos vosaltres.</p> Visiteu <a href=https://www.kde.org/ca/>https://www.kde.org/ca/</a> 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"><h1>Informeu dels errors o desitjos</h1> <p>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.</p> <p>El KDE té un sistema de seguiment d\'errors. Per a informar-ne d\'un, visiteu <a href=https://bugs.kde.org/>https://bugs.kde.org/</a> o useu el botó \"Informa d\'un error\" des de la pantalla Quant al.</p> 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"><h1>Informeu dels errors o desitjos</h1> <p>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.</p> <p>El KDE té un sistema de seguiment d\'errors. Per a informar-ne d\'un, visiteu <a href=https://bugs.kde.org/>https://bugs.kde.org/</a> o useu el botó «Informeu d\'un error» des de la pantalla Quant al.</p> 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"><h1>Uniu-vos al KDE</h1> <p>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!</p> <p>Visiteu <a href=https://community.kde.org/Get_Involved>https://community.kde.org/Get_Involved</a> per a obtenir informació sobre alguns projectes en què podeu participar-hi.</p> Si us cal més informació o documentació, una visita a <a href=https://techbase.kde.org/>https://techbase.kde.org/</a> us proporcionarà el que necessiteu.</string>
|
||||
<string name="about_kde_support_kde"><h1>Contribució al KDE</h1> <p>El programari KDE està i sempre estarà disponible de forma gratuïta, però la creació no està lliure de càrrecs.</p> <p>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 <a href=https://ev.kde.org/>https://ev.kde.org/</a>.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.</p> <p>Us animem a ajudar al KDE mitjançant donacions monetàries, usant un dels mitjans descrits a <a href=https://kde.org/ca/community/donations/>https://kde.org/ca/community/donations/</a>.</p>. 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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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 fjernstyringstillegget, 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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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ğlan’a 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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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
7
res/xml/shortcuts.xml
Normal 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>
|
@@ -20,4 +20,15 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("org.ow2.asm:asm-util:9.6")
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "kdeconnect-android"
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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) =
|
||||
|
871
src/org/kde/kdeconnect/Helpers/CollectionsBackport.java
Normal file
871
src/org/kde/kdeconnect/Helpers/CollectionsBackport.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
25
src/org/kde/kdeconnect/Helpers/SafeTextChecker.kt
Normal file
25
src/org/kde/kdeconnect/Helpers/SafeTextChecker.kt
Normal 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 }
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
@@ -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());
|
||||
}
|
||||
}
|
70
src/org/kde/kdeconnect/Helpers/TrustedNetworkHelper.kt
Normal file
70
src/org/kde/kdeconnect/Helpers/TrustedNetworkHelper.kt
Normal 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
|
||||
}
|
||||
}
|
@@ -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));
|
||||
}
|
||||
}
|
80
src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.kt
Normal file
80
src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.kt
Normal 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}"
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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 = ""
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -163,4 +163,8 @@ class MprisReceiverPlayer {
|
||||
|
||||
return metadata.getLong(MediaMetadata.METADATA_KEY_DURATION);
|
||||
}
|
||||
|
||||
MediaMetadata getMetadata() {
|
||||
return controller.getMetadata();
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
@@ -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() {
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
259
src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt
Normal file
259
src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt
Normal 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()
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
291
src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt
Normal file
291
src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
100
src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt
Normal file
100
src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt
Normal 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)
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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")
|
||||
}
|
||||
}
|
@@ -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,
|
||||
)
|
||||
}
|
@@ -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")
|
||||
}
|
||||
}
|
@@ -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"
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
42
src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafPath.kt
Normal file
42
src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafPath.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
@@ -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) {
|
||||
|
@@ -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 :)
|
||||
|
||||
|
@@ -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()
|
||||
|
75
tests/org/kde/kdeconnect/Helpers/VideoUrlsHelperTest.kt
Normal file
75
tests/org/kde/kdeconnect/Helpers/VideoUrlsHelperTest.kt
Normal 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())
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user