diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index eae9afc13..a1c117e66 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,16 +9,18 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/checkout@v4 with: - distribution: 'adopt' - java-version: '11' + submodules: true + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' - name: Build with Gradle run: ./gradlew assembleDebug lintVitalRelease - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: app-debug.apk path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore index 256df604e..63a656514 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.gradle/ /.idea/ +/.kotlin/ /build/ /captures/ /local.properties diff --git a/README.md b/README.md index b41cfdda5..81ae655a4 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,9 @@ An open source Material Design file manager, for Android 5.0+. -[Get it on Google Play](https://play.google.com/store/apps/details?id=me.zhanghai.android.files) [Get it on F-Droid](https://f-droid.org/packages/me.zhanghai.android.files) +[Get it on Google Play](https://play.google.com/store/apps/details?id=me.zhanghai.android.files) [Get it on F-Droid](https://f-droid.org/packages/me.zhanghai.android.files) [Get it on GitHub](https://github.com/zhanghai/MaterialFiles/releases/latest/download/app-release-universal.apk) -[Get it on Coolapk](https://www.coolapk.com/apk/me.zhanghai.android.files) [Get the APK](https://github.com/zhanghai/MaterialFiles/releases/latest/download/app-release.apk) - -[Help translation on Transifex](https://www.transifex.com/zhanghai/MaterialFiles/) ([Search Android & GNOME translations](https://translations.zhanghai.me/), [Search Microsoft translations](https://www.microsoft.com/en-us/language), [MIME type translations](https://github.com/freedesktop/xdg-shared-mime-info/tree/master/po)) +[Help translation on Transifex](https://www.transifex.com/zhanghai/MaterialFiles/) ([Search Android & GNOME translations](https://translations.zhanghai.me/), [Microsoft language resources](https://learn.microsoft.com/en-us/globalization/reference/microsoft-language-resources), [MIME type translations](https://gitlab.freedesktop.org/xdg/shared-mime-info/-/tree/master/po)) ## Preview @@ -24,9 +22,9 @@ An open source Material Design file manager, for Android 5.0+. - Breadcrumbs: Navigate in the filesystem with ease. - Root support: View and manage files with root access. - Archive support: View, extract and create common compressed files. -- NAS support: View and manage files on FTP, SFTP and SMB servers. +- NAS support: View and manage files on FTP, SFTP, SMB and WebDAV servers. - Themes: Customizable UI colors, plus night mode with optional true black. -- Linux-aware: Like [Nautilus](https://wiki.gnome.org/action/show/Apps/Files), knows symbolic links, file permissions and SELinux context. +- Linux-aware: Like [Nautilus](https://apps.gnome.org/Nautilus/), knows symbolic links, file permissions and SELinux context. - Robust: Uses Linux system calls under the hood, not yet another [`ls` parser](https://news.ycombinator.com/item?id=7994720). - Well-implemented: Built upon the right things, including [Java NIO2 File API](https://docs.oracle.com/javase/8/docs/api/java/nio/file/package-summary.html) and [LiveData](https://developer.android.com/topic/libraries/architecture/livedata). @@ -34,7 +32,7 @@ An open source Material Design file manager, for Android 5.0+. Because I like Material Design, and clean Material Design. -There are already a handful of powerful file managers, but most of them just isn't Material Design. And even among the ones with Material Design, they usually have various minor design flaws (layout, alignment, padding, icon, font, etc) across the app which makes me uncomfortable, while still being minor enough so that not everybody would care to fix it. So I had to create my own. +There are already a handful of powerful file managers, but most of them just aren't Material Design. And even among the ones with Material Design, they usually have various minor design flaws (layout, alignment, padding, icon, font, etc) across the app which makes me uncomfortable, while still being minor enough so that not everybody would care to fix it. So I had to create my own. Because I want an open source file manager. diff --git a/README_zh-CN.md b/README_zh-CN.md index ea86376c2..5220c1fbf 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -4,11 +4,9 @@ 一个开源的 Material Design 文件管理器,适用于 Android 5.0+。 -[下载应用,请到 Google Play](https://play.google.com/store/apps/details?id=me.zhanghai.android.files) [下载应用,请到 F-Droid](https://f-droid.org/packages/me.zhanghai.android.files) +[下载应用,请到 Google Play](https://play.google.com/store/apps/details?id=me.zhanghai.android.files) [下载应用,请到 F-Droid](https://f-droid.org/packages/me.zhanghai.android.files) [下载应用,请到 GitHub](https://github.com/zhanghai/MaterialFiles/releases/latest/download/app-release-universal.apk) -[在酷安上下载](https://www.coolapk.com/apk/me.zhanghai.android.files) [下载 APK](https://github.com/zhanghai/MaterialFiles/releases/latest/download/app-release.apk) - -[在 Transifex 上帮助翻译](https://www.transifex.com/zhanghai/MaterialFiles/)([搜索 Android 和 GNOME 的翻译](https://translations.zhanghai.me/)、[搜索微软的翻译](https://www.microsoft.com/en-us/language)、[MIME 类型翻译](https://github.com/freedesktop/xdg-shared-mime-info/tree/master/po)) +[在 Transifex 上帮助翻译](https://www.transifex.com/zhanghai/MaterialFiles/)([搜索 Android 和 GNOME 的翻译](https://translations.zhanghai.me/)、[微软语言资源](https://learn.microsoft.com/en-us/globalization/reference/microsoft-language-resources)、[MIME 类型翻译](https://gitlab.freedesktop.org/xdg/shared-mime-info/-/tree/master/po)) ## 预览 @@ -22,7 +20,7 @@ - 面包屑导航栏:点击导航栏所显示路径中的任一文件夹即可快速访问。 - Root 支持:使用 root 权限查看和管理文件。 - 压缩文件支持:查看、提取和创建常见的压缩文件。 -- NAS 支持:查看和管理 FTP、SFTP 和 SMB 服务器上的文件。 +- NAS 支持:查看和管理 FTP、SFTP、SMB 和 WebDAV 服务器上的文件。 - 主题:可定制的界面颜色,以及可选纯黑的夜间模式。 - Linux 友好:类似 [Nautilus](https://wiki.gnome.org/action/show/Apps/Files),支持符号链接、文件权限和 SELinux 上下文。 - 健壮性:使用 Linux 系统调用实现,而不是另一个 [`ls` 解析器](https://news.ycombinator.com/item?id=7994720)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..e7d7a2a53 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Supported Versions + +Only the latest version of this app is currently being supported with security updates. + +## Reporting a Vulnerability + +Please email dreaming.in.code.zh@gmail.com to report a vulnerability. diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 7ec0af964..608a96d11 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -2,8 +2,20 @@ cmake_minimum_required(VERSION 3.6) project(MaterialFiles C) -add_library(hiddenapi SHARED src/main/jni/hiddenapi.c) +add_library(hiddenapi SHARED + src/main/jni/hiddenapi.c) +target_compile_options(hiddenapi + PRIVATE + -Wall + -Werror) find_library(LOG_LIBRARY log) -add_library(syscalls SHARED src/main/jni/syscalls.c) -target_link_libraries(syscalls ${LOG_LIBRARY}) +add_library(syscall SHARED + src/main/jni/syscall.c) +target_compile_options(syscall + PRIVATE + -Wall + -Werror) +target_link_libraries(syscall + PRIVATE + ${LOG_LIBRARY}) diff --git a/app/build.gradle b/app/build.gradle index 1c8cf9f59..62bc31790 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,10 +5,6 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -kapt { - correctErrorTypes = true -} apply plugin: 'kotlin-parcelize' apply from: '../signing.gradle' @@ -20,8 +16,8 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.google.gms:google-services:4.3.10' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' + classpath 'com.google.gms:google-services:4.4.2' + classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2' } } apply plugin: 'com.google.gms.google-services' @@ -30,38 +26,53 @@ apply plugin: 'com.google.firebase.crashlytics' android { namespace 'me.zhanghai.android.files' - compileSdkVersion 31 - ndkVersion '24.0.8215888' - buildToolsVersion '32.0.0' + buildToolsVersion = '35.0.0' + compileSdk 35 + ndkVersion '27.2.12479018' defaultConfig { applicationId 'me.zhanghai.android.files' - minSdkVersion 21 - targetSdkVersion 31 - versionCode 31 - versionName '1.5.2' + minSdk 21 + // Not supporting foreground service timeout yet. + //noinspection OldTargetApi + targetSdk 34 + versionCode 39 + versionName '1.7.4' resValue 'string', 'app_version', versionName + ' (' + versionCode + ')' buildConfigField 'String', 'FILE_PROVIDIER_AUTHORITY', 'APPLICATION_ID + ".file_provider"' resValue 'string', 'app_provider_authority', applicationId + '.app_provider' resValue 'string', 'file_provider_authority', applicationId + '.file_provider' + externalNativeBuild { + cmake { + arguments '-DANDROID_STL=none' + } + } } buildFeatures { + aidl true + buildConfig true viewBinding true } + androidResources { + generateLocaleConfig true + } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() - freeCompilerArgs += [ - '-Xopt-in=kotlin.RequiresOptIn', - ] } externalNativeBuild { cmake { path 'CMakeLists.txt' } } + lint { + // For "Invalid package reference in library; not included in Android: javax.security.sasl. + // Referenced from org.apache.mina.proxy.ProxyAuthException." + warning 'InvalidPackage', 'MissingTranslation' + } buildTypes { release { minifyEnabled true @@ -76,14 +87,20 @@ android { } } packagingOptions { + jniLibs { + useLegacyPackaging true + } resources { - excludes += ['META-INF/DEPENDENCIES'] + excludes += [ + 'META-INF/DEPENDENCIES', + 'org/bouncycastle/pqc/crypto/picnic/*' + ] } } - lint { - // For "Invalid package reference in library; not included in Android: javax.security.sasl. - // Referenced from org.apache.mina.proxy.ProxyAuthException." - warning 'InvalidPackage', 'MissingTranslation' + bundle { + language { + enableSplit = false + } } } @@ -93,69 +110,71 @@ repositories { } } dependencies { + implementation('com.github.bitfireAT:dav4jvm:c1bc143') { + exclude group: 'org.ogce', module: 'xpp3' + } implementation 'com.github.chrisbanes:PhotoView:2.3.0' - releaseImplementation 'com.github.mypplication:stetho-noop:1.1' - implementation 'com.github.topjohnwu.libsu:service:5.0.1' + implementation 'com.github.topjohnwu.libsu:service:5.2.2' } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2") + // kotlinx-coroutines-android depends on kotlin-stdlib-jdk8 implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - def kotlinx_coroutines_version = '1.6.0' + def kotlinx_coroutines_version = '1.9.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinx_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version" - implementation 'androidx.appcompat:appcompat:1.4.1' - implementation 'androidx.core:core-ktx:1.7.0' - implementation 'androidx.exifinterface:exifinterface:1.3.3' - implementation 'androidx.fragment:fragment-ktx:1.4.1' - def androidx_lifecycle_version = '2.4.1' + implementation 'androidx.activity:activity-ktx:1.9.3' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.drawerlayout:drawerlayout:1.2.0' + implementation 'androidx.exifinterface:exifinterface:1.3.7' + implementation 'androidx.fragment:fragment-ktx:1.8.5' + def androidx_lifecycle_version = '2.8.7' implementation "androidx.lifecycle:lifecycle-common-java8:$androidx_lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_lifecycle_version" implementation "androidx.lifecycle:lifecycle-process:$androidx_lifecycle_version" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$androidx_lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_lifecycle_version" - implementation 'androidx.preference:preference-ktx:1.2.0' - implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.google.android.material:material:1.6.0-rc01' + implementation 'com.google.android.material:material:1.12.0' implementation 'com.caverock:androidsvg-aar:1.4' - implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' + implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.drakeet.drawer:drawer:1.0.3' - debugImplementation 'com.facebook.stetho:stetho:1.6.0' - // If added along with okhttp3, remove the related dontwarn from proguard-rules.pro. - //debugImplementation 'com.facebook.stetho:stetho-okhttp3:1.6.0' implementation 'com.h6ah4i.android.materialshadowninepatch:materialshadowninepatch:1.0.0' implementation 'com.h6ah4i.android.widget.advrecyclerview:advrecyclerview:1.0.0' + // SMBJ 0.12.0 breaks anonymous authentication: https://github.com/hierynomus/smbj/issues/792 + //noinspection GradleDependency implementation ('com.hierynomus:smbj:0.11.5') { // org.bouncycastle:bcprov-jdk15on uses bytecode version unsupported by Jetifier, so use // org.bouncycastle:bcprov-jdk15to18 instead. exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on' } - implementation ('com.hierynomus:sshj:0.32.0') { - // org.bouncycastle:bcprov-jdk15on uses bytecode version unsupported by Jetifier, so use - // org.bouncycastle:bcprov-jdk15to18 instead. - exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on' + def bouncy_castle_version = '1.78.1' + implementation "org.bouncycastle:bcprov-jdk15to18:$bouncy_castle_version" + implementation ('com.hierynomus:sshj:0.39.0') { + exclude group: 'org.bouncycastle', module: 'bcprov-jdk18on' + exclude group: 'org.bouncycastle', module: 'bcpkix-jdk18on' } - implementation 'com.jakewharton.threetenabp:threetenabp:1.4.0' - // FloatingActionButtonSpeedDial 3.2.0 forces the user out of touch mode upon open and leaves a - // strange focus effect on the first mini FAB. - //noinspection GradleDependency - implementation 'com.leinardi.android:speed-dial:3.1.1' - implementation ('com.rapid7.client:dcerpc:0.12.0') { - // org.bouncycastle:bcprov-jdk15on uses bytecode version unsupported by Jetifier, so use - // org.bouncycastle:bcprov-jdk15to18 instead. + implementation "org.bouncycastle:bcpkix-jdk15to18:$bouncy_castle_version" + implementation 'com.leinardi.android:speed-dial:3.3.0' + implementation ('com.rapid7.client:dcerpc:0.12.1') { + // SMBJ-RPC depends on the JRE flavor of Guava which targets Java 8. + exclude group: 'com.google.guava', module: 'guava' exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on' } - implementation 'org.bouncycastle:bcprov-jdk15to18:1.70' - // SMBJ-RPC depends on com.google.guava:guava:18.0, which conflicts with - // com.google.guava:listenablefuture:1.0 pulled in by AndroidX Core + implementation 'com.google.guava:guava:33.3.1-android' + // Guava conflicts with com.google.guava:listenablefuture:1.0 pulled in by AndroidX Core implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' implementation 'com.takisoft.preferencex:preferencex:1.1.0' - implementation 'commons-net:commons-net:3.8.0' + implementation 'commons-net:commons-net:3.11.1' // LicensesDialog 2.2.0 pulls in androidx.webkit and uses setForceDark() instead of correctly // setting colors. //noinspection GradleDependency @@ -163,28 +182,22 @@ dependencies { // dev.chrisbanesinsetter:insetter:0.6.0 makes inset unstable when entering immersive. implementation 'dev.chrisbanes:insetter-ktx:0.3.1' implementation 'dev.rikka.rikkax.preference:simplemenu-preference:1.0.3' - implementation 'dev.rikka.shizuku:api:12.0.0' - implementation 'eu.agno3.jcifs:jcifs-ng:2.1.6' - def coil_version = '1.4.0' - implementation "io.coil-kt:coil:$coil_version" - implementation "io.coil-kt:coil-gif:$coil_version" - implementation "io.coil-kt:coil-svg:$coil_version" - implementation "io.coil-kt:coil-video:$coil_version" - implementation 'me.zhanghai.android.appiconloader:appiconloader:1.4.0' - implementation 'me.zhanghai.android.fastscroll:library:1.1.8' + implementation 'dev.rikka.shizuku:api:13.1.5' + implementation ('eu.agno3.jcifs:jcifs-ng:2.1.10') { + exclude group: 'org.bouncycastle', module: 'bcprov-jdk18on' + } + implementation platform('io.coil-kt:coil-bom:2.7.0') + implementation 'io.coil-kt:coil' + implementation 'io.coil-kt:coil-gif' + implementation 'io.coil-kt:coil-svg' + implementation 'io.coil-kt:coil-video' + implementation 'me.zhanghai.android.appiconloader:appiconloader:1.5.0' + implementation 'me.zhanghai.android.fastscroll:library:1.3.0' implementation 'me.zhanghai.android.foregroundcompat:library:1.0.2' + implementation 'me.zhanghai.android.libarchive:library:1.1.4' implementation 'me.zhanghai.android.libselinux:library:2.1.0' - implementation 'me.zhanghai.android.retrofile:library:1.1.1' + implementation 'me.zhanghai.android.retrofile:library:1.2.0' implementation 'me.zhanghai.android.systemuihelper:library:1.0.0' - implementation 'net.sourceforge.streamsupport:android-retrostreams:1.7.4' - // Commons Compress 1.21 requires Android 8.0. - // See also https://issues.apache.org/jira/browse/COMPRESS-611 - // Run `git revert fb1628e` if we ever created a fork that supports Android 5.0, or we raised - // minimum SDK version to Android 8.0. - //noinspection GradleDependency - implementation 'org.apache.commons:commons-compress:1.20' - // Optional dependency of Commons Compress for 7Z LZMA. - implementation 'org.tukaani:xz:1.9' implementation 'org.apache.ftpserver:ftpserver-core:1.2.0' // This is a dependency of org.apache.ftpserver:ftpserver-core but org.apache.mina:mina-core // 2.1.3+ became incompatible before API 24 due to dependency on StandardSocketOptions @@ -194,12 +207,11 @@ dependencies { strictly '2.1.3' } } - // Also a dependency of jCIFS-NG and Junrar. + // Also a dependency of jCIFS-NG. implementation 'org.slf4j:slf4j-android:1.7.36' //#ifdef NONFREE - implementation 'com.github.junrar:junrar:7.4.1' - implementation platform('com.google.firebase:firebase-bom:29.0.3') + implementation platform('com.google.firebase:firebase-bom:33.5.1') implementation 'com.google.firebase:firebase-analytics' implementation 'com.google.firebase:firebase-crashlytics-ndk' //#endif diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8cc37fa22..6c70e0117 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -33,18 +33,10 @@ -keepnames class * extends java.lang.Exception # For Class.getEnumConstants() -keepclassmembers enum * { - public static **[] values(); + public static **[] values(); } -keepnames class me.zhanghai.android.files.** implements android.os.Parcelable -# Apache Commons Compress --dontwarn org.apache.commons.compress.compressors.** --dontwarn org.apache.commons.compress.archivers.** -# me.zhanghai.android.files.provider.archive.archiver.ArchiveWriter.sTarArchiveEntryLinkFlagsField --keepclassmembers class org.apache.commons.compress.archivers.tar.TarArchiveEntry { - byte linkFlag; -} - # Apache FtpServer -keepclassmembers class * implements org.apache.mina.core.service.IoProcessor { public (java.util.concurrent.ExecutorService); @@ -56,6 +48,10 @@ -keep class org.bouncycastle.jcajce.provider.** { *; } -keep class org.bouncycastle.jce.provider.** { *; } -# Stetho No-op -# This library includes the no-op for stetho-okhttp3 which requires okhttp3, but we never used it. --dontwarn com.facebook.stetho.okhttp3.StethoInterceptor +# SMBJ +-dontwarn javax.el.** +-dontwarn org.ietf.jgss.** +-dontwarn sun.security.x509.X509Key + +# SMBJ-RPC +-dontwarn java.rmi.UnmarshalException diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31abbca20..4479e3e09 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,15 +9,19 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + + + + @@ -25,7 +29,7 @@ + android:maxSdkVersion="32" /> @@ -38,16 +42,19 @@ --> + @@ -76,8 +84,11 @@ + + + @@ -89,26 +100,23 @@ - + - - + - - + - --> - + @@ -170,6 +178,16 @@ android:label="@string/storage_edit_device_storage_title" android:theme="@style/Theme.MaterialFiles.Translucent" /> + + + + + + + + @@ -267,6 +295,26 @@ android:autoRemoveFromRecents="true" android:theme="@style/Theme.MaterialFiles.Translucent" /> + + + + + + + + + + + + + + @@ -298,9 +347,12 @@ - + - + + + + + + * All Rights Reserved. + */ + +package at.bitfire.dav4jvm; + +import java.io.IOException; + +import androidx.annotation.NonNull; +import at.bitfire.dav4jvm.exception.DavException; +import at.bitfire.dav4jvm.exception.HttpException; +import kotlin.jvm.functions.Function0; +import okhttp3.Response; + +public class DavResourceAccessor { + private DavResourceAccessor() {} + + public static void checkStatus(@NonNull DavResource davResource, @NonNull Response response) + throws HttpException { + davResource.checkStatus(response); + } + + public static Response followRedirects(@NonNull DavResource davResource, + @NonNull Function0 sendRequest) throws DavException, IOException { + return davResource.followRedirects$build(sendRequest); + } +} diff --git a/app/src/main/java/com/google/android/material/shape/MaterialShapeDrawableAccessor.java b/app/src/main/java/com/google/android/material/shape/MaterialShapeDrawableAccessor.java index 8890aff93..ed44d116f 100644 --- a/app/src/main/java/com/google/android/material/shape/MaterialShapeDrawableAccessor.java +++ b/app/src/main/java/com/google/android/material/shape/MaterialShapeDrawableAccessor.java @@ -5,6 +5,8 @@ package com.google.android.material.shape; +import android.annotation.SuppressLint; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -13,6 +15,7 @@ public class MaterialShapeDrawableAccessor { private MaterialShapeDrawableAccessor() {} + @SuppressLint("RestrictedApi") public static ElevationOverlayProvider getElevationOverlayProvider( @NonNull MaterialShapeDrawable drawable) { MaterialShapeDrawable.MaterialShapeDrawableState drawableState = @@ -20,6 +23,7 @@ public static ElevationOverlayProvider getElevationOverlayProvider( return drawableState.elevationOverlayProvider; } + @SuppressLint("RestrictedApi") public static void setElevationOverlayProvider( @NonNull MaterialShapeDrawable drawable, @Nullable ElevationOverlayProvider elevationOverlayProvider) { diff --git a/app/src/main/java/me/zhanghai/android/files/about/AboutFragment.kt b/app/src/main/java/me/zhanghai/android/files/about/AboutFragment.kt index 9aadc7d1d..e93a4c988 100644 --- a/app/src/main/java/me/zhanghai/android/files/about/AboutFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/about/AboutFragment.kt @@ -50,9 +50,6 @@ class AboutFragment : Fragment() { binding.authorGitHubLayout.setOnClickListener { startActivitySafe(AUTHOR_GITHUB_URI.createViewIntent()) } - binding.authorGooglePlusLayout.setOnClickListener { - startActivitySafe(AUTHOR_GOOGLE_PLUS_URI.createViewIntent()) - } binding.authorTwitterLayout.setOnClickListener { startActivitySafe(AUTHOR_TWITTER_URI.createViewIntent()) } @@ -64,8 +61,6 @@ class AboutFragment : Fragment() { Uri.parse("https://github.com/zhanghai/MaterialFiles/blob/master/PRIVACY.md") private val AUTHOR_RESUME_URI = Uri.parse("https://resume.zhanghai.me/") private val AUTHOR_GITHUB_URI = Uri.parse("https://github.com/zhanghai") - private val AUTHOR_GOOGLE_PLUS_URI = - Uri.parse("https://plus.google.com/100015937320889992498") private val AUTHOR_TWITTER_URI = Uri.parse("https://twitter.com/zhanghai95") } } diff --git a/app/src/main/java/me/zhanghai/android/files/app/AppInitializers.kt b/app/src/main/java/me/zhanghai/android/files/app/AppInitializers.kt index 0cfcd0ed9..9f3f40c63 100644 --- a/app/src/main/java/me/zhanghai/android/files/app/AppInitializers.kt +++ b/app/src/main/java/me/zhanghai/android/files/app/AppInitializers.kt @@ -8,8 +8,6 @@ package me.zhanghai.android.files.app import android.os.AsyncTask import android.os.Build import android.webkit.WebView -import com.facebook.stetho.Stetho -import com.jakewharton.threetenabp.AndroidThreeTen import jcifs.context.SingletonContext import me.zhanghai.android.files.BuildConfig import me.zhanghai.android.files.coil.initializeCoil @@ -22,18 +20,26 @@ import me.zhanghai.android.files.storage.FtpServerAuthenticator import me.zhanghai.android.files.storage.SftpServerAuthenticator import me.zhanghai.android.files.storage.SmbServerAuthenticator import me.zhanghai.android.files.storage.StorageVolumeListLiveData +import me.zhanghai.android.files.storage.WebDavServerAuthenticator import me.zhanghai.android.files.theme.custom.CustomThemeHelper import me.zhanghai.android.files.theme.night.NightModeHelper import java.util.Properties import me.zhanghai.android.files.provider.ftp.client.Client as FtpClient import me.zhanghai.android.files.provider.sftp.client.Client as SftpClient import me.zhanghai.android.files.provider.smb.client.Client as SmbClient +import me.zhanghai.android.files.provider.webdav.client.Client as WebDavClient val appInitializers = listOf( - ::initializeCrashlytics, ::disableHiddenApiChecks, ::initializeThreeTen, - ::initializeWebViewDebugging, ::initializeStetho, ::initializeCoil, - ::initializeFileSystemProviders, ::upgradeApp, ::initializeLiveDataObjects, - ::initializeCustomTheme, ::initializeNightMode, ::createNotificationChannels + ::initializeCrashlytics, + ::disableHiddenApiChecks, + ::initializeWebViewDebugging, + ::initializeCoil, + ::initializeFileSystemProviders, + ::upgradeApp, + ::initializeLiveDataObjects, + ::initializeCustomTheme, + ::initializeNightMode, + ::createNotificationChannels ) private fun initializeCrashlytics() { @@ -46,20 +52,12 @@ private fun disableHiddenApiChecks() { HiddenApi.disableHiddenApiChecks() } -private fun initializeThreeTen() { - AndroidThreeTen.init(application) -} - private fun initializeWebViewDebugging() { if (BuildConfig.DEBUG) { WebView.setWebContentsDebuggingEnabled(true) } } -private fun initializeStetho() { - Stetho.initializeWithDefaults(application) -} - private fun initializeFileSystemProviders() { FileSystemProviders.install() FileSystemProviders.overflowWatchEvents = true @@ -75,6 +73,7 @@ private fun initializeFileSystemProviders() { FtpClient.authenticator = FtpServerAuthenticator SftpClient.authenticator = SftpServerAuthenticator SmbClient.authenticator = SmbServerAuthenticator + WebDavClient.authenticator = WebDavServerAuthenticator } private fun initializeLiveDataObjects() { diff --git a/app/src/main/java/me/zhanghai/android/files/app/AppUpgrader.kt b/app/src/main/java/me/zhanghai/android/files/app/AppUpgrader.kt index 9879f2669..5faea314b 100644 --- a/app/src/main/java/me/zhanghai/android/files/app/AppUpgrader.kt +++ b/app/src/main/java/me/zhanghai/android/files/app/AppUpgrader.kt @@ -16,6 +16,8 @@ private const val VERSION_CODE_1_2_0 = 22 private const val VERSION_CODE_1_3_0 = 24 private const val VERSION_CODE_1_4_0 = 26 private const val VERSION_CODE_1_5_0 = 29 +private const val VERSION_CODE_1_6_0 = 32 +private const val VERSION_CODE_1_7_2 = 37 private const val VERSION_CODE_LATEST = BuildConfig.VERSION_CODE private var lastVersionCode: Int @@ -52,5 +54,11 @@ private fun upgradeAppFrom(lastVersionCode: Int) { if (lastVersionCode < VERSION_CODE_1_5_0) { upgradeAppTo1_5_0() } + if (lastVersionCode < VERSION_CODE_1_6_0) { + upgradeAppTo1_6_0() + } + if (lastVersionCode < VERSION_CODE_1_7_2) { + upgradeAppTo1_7_2() + } // Continue with new `if`s on lastVersionCode instead of `else if`. } diff --git a/app/src/main/java/me/zhanghai/android/files/app/AppUpgraders.kt b/app/src/main/java/me/zhanghai/android/files/app/AppUpgraders.kt index 25d76dbea..b6b24c7fd 100644 --- a/app/src/main/java/me/zhanghai/android/files/app/AppUpgraders.kt +++ b/app/src/main/java/me/zhanghai/android/files/app/AppUpgraders.kt @@ -7,6 +7,7 @@ package me.zhanghai.android.files.app import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Parcel import android.os.Parcelable import androidx.annotation.StringRes @@ -18,6 +19,7 @@ import me.zhanghai.android.files.compat.readBooleanCompat import me.zhanghai.android.files.compat.writeBooleanCompat import me.zhanghai.android.files.compat.writeParcelableListCompat import me.zhanghai.android.files.file.DocumentTreeUri +import me.zhanghai.android.files.file.asExternalStorageUriOrNull import me.zhanghai.android.files.file.displayName import me.zhanghai.android.files.file.storageVolume import me.zhanghai.android.files.filelist.FileSortOptions @@ -28,6 +30,7 @@ import me.zhanghai.android.files.provider.common.ByteString import me.zhanghai.android.files.provider.common.moveToByteString import me.zhanghai.android.files.provider.content.ContentFileSystem import me.zhanghai.android.files.provider.document.DocumentFileSystem +import me.zhanghai.android.files.provider.document.resolver.ExternalStorageProviderHacks import me.zhanghai.android.files.provider.linux.LinuxFileSystem import me.zhanghai.android.files.provider.root.RootStrategy import me.zhanghai.android.files.provider.sftp.SftpFileSystem @@ -35,6 +38,7 @@ import me.zhanghai.android.files.provider.smb.SmbFileSystem import me.zhanghai.android.files.storage.DocumentTree import me.zhanghai.android.files.storage.FileSystemRoot import me.zhanghai.android.files.storage.PrimaryStorageVolume +import me.zhanghai.android.files.util.StableUriParceler import me.zhanghai.android.files.util.asBase64 import me.zhanghai.android.files.util.readParcelable import me.zhanghai.android.files.util.readParcelableListCompat @@ -94,8 +98,8 @@ private fun migrateFileSortOptionsSetting1_1_0(sharedPreferences: SharedPreferen oldParcel.unmarshall(oldBytes, 0, oldBytes.size) oldParcel.setDataPosition(0) newParcel.writeString(oldParcel.readString()) - newParcel.writeString(FileSortOptions.By.values()[oldParcel.readInt()].name) - newParcel.writeString(FileSortOptions.Order.values()[oldParcel.readInt()].name) + newParcel.writeString(FileSortOptions.By.entries[oldParcel.readInt()].name) + newParcel.writeString(FileSortOptions.Order.entries[oldParcel.readInt()].name) newParcel.writeInt(oldParcel.readByte().toInt()) } newParcel.marshall() @@ -520,3 +524,109 @@ private fun migrateSftpServersSetting1_5_0() { } defaultSharedPreferences.edit { putString(key, newBytes?.toBase64()?.value) } } + +internal fun upgradeAppTo1_6_0() { + addViewTypePathSetting1_6_0() +} + +private fun addViewTypePathSetting1_6_0() { + val keys = pathSharedPreferences.all.keys.toSet() + val sortOptionsKey = application.getString(R.string.pref_key_file_list_sort_options) + val viewTypeKey = application.getString(R.string.pref_key_file_list_view_type) + val defaultViewType = application.getString(R.string.pref_default_value_file_list_view_type) + for (key in keys) { + if (!key.startsWith(sortOptionsKey)) { + continue + } + val newKey = key.replaceFirst(sortOptionsKey, viewTypeKey) + if (newKey in keys) { + continue + } + pathSharedPreferences.edit { putString(newKey, defaultViewType) } + } +} + +internal fun upgradeAppTo1_7_2() { + migrateDocumentManagerShortcutSetting1_7_2() +} + +private fun migrateDocumentManagerShortcutSetting1_7_2() { + val key = application.getString(R.string.pref_key_storages) + val oldBytes = + defaultSharedPreferences.getString(key, null)?.asBase64()?.toByteArray() ?: return + val newBytes = + try { + Parcel.obtain().use { newParcel -> + Parcel.obtain().use { oldParcel -> + oldParcel.unmarshall(oldBytes, 0, oldBytes.size) + oldParcel.setDataPosition(0) + newParcel.writeInt(oldParcel.readInt()) + readWriteLengthPrefixedValue(oldParcel, newParcel) { + val size = oldParcel.readInt() + newParcel.writeInt(size) + repeat(size) { + val oldPosition = oldParcel.dataPosition() + oldParcel.readInt() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Skip prefix length. + oldParcel.readInt() + } + val className = oldParcel.readString() + oldParcel.setDataPosition(oldPosition) + when (className) { + "me.zhanghai.android.files.storage.DocumentManagerShortcut" -> { + newParcel.writeInt(oldParcel.readInt()) + readWriteLengthPrefixedValue(oldParcel, newParcel) { + oldParcel.readString() + newParcel.writeString( + "me.zhanghai.android.files.storage" + + ".ExternalStorageShortcut" + ) + val id = oldParcel.readLong() + newParcel.writeLong(id) + val customName = oldParcel.readString() + newParcel.writeString(customName) + var uri = StableUriParceler.create(oldParcel)!! + if (uri.asExternalStorageUriOrNull() == null) { + // Reset to a valid external storage URI. + uri = + ExternalStorageProviderHacks + .DOCUMENT_URI_ANDROID_DATA + } + with(StableUriParceler) { uri.write(newParcel, 0) } + } + } + else -> { + val storage = oldParcel.readValue(appClassLoader) + newParcel.writeValue(storage) + } + } + } + } + } + newParcel.marshall() + } + } catch (e: Exception) { + e.printStackTrace() + null + } + defaultSharedPreferences.edit { putString(key, newBytes?.toBase64()?.value) } +} + +private fun readWriteLengthPrefixedValue(oldParcel: Parcel, newParcel: Parcel, block: () -> Unit) { + var lengthPosition = 0 + var startPosition = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + oldParcel.readInt() + lengthPosition = newParcel.dataPosition() + newParcel.writeInt(-1) + startPosition = newParcel.dataPosition() + } + block() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val endPosition = newParcel.dataPosition() + newParcel.setDataPosition(lengthPosition) + newParcel.writeInt(endPosition - startPosition) + newParcel.setDataPosition(endPosition) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/app/BackgroundActivityStarter.kt b/app/src/main/java/me/zhanghai/android/files/app/BackgroundActivityStarter.kt index 3f3392ca4..415d5aadf 100644 --- a/app/src/main/java/me/zhanghai/android/files/app/BackgroundActivityStarter.kt +++ b/app/src/main/java/me/zhanghai/android/files/app/BackgroundActivityStarter.kt @@ -18,7 +18,7 @@ import me.zhanghai.android.files.util.NotificationChannelTemplate import me.zhanghai.android.files.util.NotificationTemplate import me.zhanghai.android.files.util.startActivitySafe -val backgroundActivityStartNotificationTemplate: NotificationTemplate = +val backgroundActivityStartNotificationTemplate = NotificationTemplate( NotificationChannelTemplate( "background_activity_start", diff --git a/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt b/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt index 3b1818c56..388fdbbb1 100644 --- a/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt +++ b/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt @@ -17,32 +17,35 @@ import androidx.core.app.NotificationManagerCompat import androidx.preference.PreferenceManager import me.zhanghai.android.files.compat.getSystemServiceCompat import me.zhanghai.android.files.compat.mainExecutorCompat +import okhttp3.OkHttpClient import java.util.concurrent.Executor val appClassLoader = AppProvider::class.java.classLoader +val clipboardManager: ClipboardManager by lazy { + application.getSystemServiceCompat(ClipboardManager::class.java) +} + val contentResolver: ContentResolver by lazy { application.contentResolver } val defaultSharedPreferences: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(application) } -val mainExecutor: Executor by lazy { application.mainExecutorCompat } - -val packageManager: PackageManager by lazy { application.packageManager } - -val clipboardManager: ClipboardManager by lazy { - application.getSystemServiceCompat(ClipboardManager::class.java) -} +val okHttpClient: OkHttpClient by lazy { OkHttpClient() } val inputMethodManager: InputMethodManager by lazy { application.getSystemServiceCompat(InputMethodManager::class.java) } +val mainExecutor: Executor by lazy { application.mainExecutorCompat } + val notificationManager: NotificationManagerCompat by lazy { NotificationManagerCompat.from(application) } +val packageManager: PackageManager by lazy { application.packageManager } + val powerManager: PowerManager by lazy { application.getSystemServiceCompat(PowerManager::class.java) } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/AppIconApplicationInfoFetcher.kt b/app/src/main/java/me/zhanghai/android/files/coil/AppIconApplicationInfoFetcherFactory.kt similarity index 70% rename from app/src/main/java/me/zhanghai/android/files/coil/AppIconApplicationInfoFetcher.kt rename to app/src/main/java/me/zhanghai/android/files/coil/AppIconApplicationInfoFetcherFactory.kt index acac50cfa..f9f804a27 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/AppIconApplicationInfoFetcher.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/AppIconApplicationInfoFetcherFactory.kt @@ -7,19 +7,25 @@ package me.zhanghai.android.files.coil import android.content.Context import android.content.pm.ApplicationInfo +import coil.key.Keyer +import coil.request.Options import me.zhanghai.android.appiconloader.AppIconLoader import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.longVersionCodeCompat import me.zhanghai.android.files.util.getDimensionPixelSize import java.io.Closeable -class AppIconApplicationInfoFetcher(private val context: Context) : AppIconFetcher( +class AppIconApplicationInfoKeyer : Keyer { + override fun key(data: ApplicationInfo, options: Options): String = + AppIconLoader.getIconKey(data, data.longVersionCodeCompat, options.context) +} + +class AppIconApplicationInfoFetcherFactory( + context: Context +) : AppIconFetcher.Factory( // This is used by PrincipalListAdapter. context.getDimensionPixelSize(R.dimen.icon_size), context ) { - override fun key(data: ApplicationInfo): String? = - AppIconLoader.getIconKey(data, data.longVersionCodeCompat, context) - override fun getApplicationInfo(data: ApplicationInfo): Pair = data to null } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/AppIconFetcher.kt b/app/src/main/java/me/zhanghai/android/files/coil/AppIconFetcher.kt index 118463708..7a8c8c920 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/AppIconFetcher.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/AppIconFetcher.kt @@ -8,34 +8,38 @@ package me.zhanghai.android.files.coil import android.content.Context import android.content.pm.ApplicationInfo import androidx.core.graphics.drawable.toDrawable -import coil.bitmap.BitmapPool +import coil.ImageLoader import coil.decode.DataSource -import coil.decode.Options import coil.fetch.DrawableResult import coil.fetch.FetchResult import coil.fetch.Fetcher -import coil.size.Size +import coil.request.Options import me.zhanghai.android.appiconloader.AppIconLoader import java.io.Closeable -abstract class AppIconFetcher( - iconSize: Int, - private val context: Context, - shrinkNonAdaptiveIcons: Boolean = false -) : Fetcher { - private val appIconLoader = AppIconLoader(iconSize, shrinkNonAdaptiveIcons, context) - - abstract fun getApplicationInfo(data: T): Pair - - override suspend fun fetch( - pool: BitmapPool, - data: T, - size: Size, - options: Options - ): FetchResult { - val (applicationInfo, closeable) = getApplicationInfo(data) +class AppIconFetcher( + private val options: Options, + private val appIconLoader: AppIconLoader, + private val getApplicationInfo: () -> Pair +) : Fetcher { + override suspend fun fetch(): FetchResult { + val (applicationInfo, closeable) = getApplicationInfo() val icon = closeable.use { appIconLoader.loadIcon(applicationInfo) } // Not sampled because we only load with one fixed size. - return DrawableResult(icon.toDrawable(context.resources), false, DataSource.DISK) + return DrawableResult(icon.toDrawable(options.context.resources), false, DataSource.DISK) + } + + abstract class Factory( + iconSize: Int, + context: Context, + shrinkNonAdaptiveIcons: Boolean = false + ) : Fetcher.Factory { + private val appIconLoader = + AppIconLoader(iconSize, shrinkNonAdaptiveIcons, context) + + override fun create(data: T, options: Options, imageLoader: ImageLoader): Fetcher = + AppIconFetcher(options, appIconLoader) { getApplicationInfo(data) } + + abstract fun getApplicationInfo(data: T): Pair } } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/AppIconPackageNameFetcher.kt b/app/src/main/java/me/zhanghai/android/files/coil/AppIconPackageNameFetcherFactory.kt similarity index 78% rename from app/src/main/java/me/zhanghai/android/files/coil/AppIconPackageNameFetcher.kt rename to app/src/main/java/me/zhanghai/android/files/coil/AppIconPackageNameFetcherFactory.kt index 669a9405c..4e8977660 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/AppIconPackageNameFetcher.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/AppIconPackageNameFetcherFactory.kt @@ -7,6 +7,8 @@ package me.zhanghai.android.files.coil import android.content.Context import android.content.pm.ApplicationInfo +import coil.key.Keyer +import coil.request.Options import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.PackageManagerCompat import me.zhanghai.android.files.util.getDimensionPixelSize @@ -14,13 +16,17 @@ import java.io.Closeable data class AppIconPackageName(val packageName: String) -class AppIconPackageNameFetcher(private val context: Context) : AppIconFetcher( +class AppIconPackageNameKeyer : Keyer { + override fun key(data: AppIconPackageName, options: Options): String = data.packageName +} + +class AppIconPackageNameFetcherFactory( + private val context: Context +) : AppIconFetcher.Factory( // This is used by FileListAdapter, and shrinking non-adaptive icons makes it look better as a // badge. context.getDimensionPixelSize(R.dimen.badge_size_plus_1dp), context, true ) { - override fun key(data: AppIconPackageName): String? = data.packageName - override fun getApplicationInfo(data: AppIconPackageName): Pair { // PackageManager.MATCH_UNINSTALLED_PACKAGES allows using PackageManager.MATCH_ANY_USER // without the INTERACT_ACROSS_USERS permission when we are in the system user and it has a diff --git a/app/src/main/java/me/zhanghai/android/files/coil/CoilExtensions.kt b/app/src/main/java/me/zhanghai/android/files/coil/CoilExtensions.kt index c1803f199..90ca73813 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/CoilExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/CoilExtensions.kt @@ -5,13 +5,11 @@ package me.zhanghai.android.files.coil -import coil.annotation.ExperimentalCoilApi import coil.request.ImageRequest import coil.transition.CrossfadeTransition -@OptIn(ExperimentalCoilApi::class) fun ImageRequest.Builder.fadeIn(durationMillis: Int): ImageRequest.Builder = apply { placeholder(android.R.color.transparent) - transition(CrossfadeTransition(durationMillis, true)) + transitionFactory(CrossfadeTransition.Factory(durationMillis, true)) } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/CoilInitializer.kt b/app/src/main/java/me/zhanghai/android/files/coil/CoilInitializer.kt index 4d81b95e5..4072b3b06 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/CoilInitializer.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/CoilInitializer.kt @@ -16,18 +16,21 @@ import me.zhanghai.android.files.app.application fun initializeCoil() { Coil.setImageLoader( ImageLoader.Builder(application) - .componentRegistry { - add(AppIconApplicationInfoFetcher(application)) - add(AppIconPackageNameFetcher(application)) - add(PathAttributesFetcher(application)) + .components { + add(AppIconApplicationInfoKeyer()) + add(AppIconApplicationInfoFetcherFactory(application)) + add(AppIconPackageNameKeyer()) + add(AppIconPackageNameFetcherFactory(application)) + add(PathAttributesKeyer()) + add(PathAttributesFetcher.Factory(application)) add( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoderDecoder(application) + ImageDecoderDecoder.Factory() } else { - GifDecoder() + GifDecoder.Factory() } ) - add(SvgDecoder(application, false)) + add(SvgDecoder.Factory(false)) } .build() ) diff --git a/app/src/main/java/me/zhanghai/android/files/coil/CoilUtils.kt b/app/src/main/java/me/zhanghai/android/files/coil/CoilUtils.kt new file mode 100644 index 000000000..4617f40e3 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/coil/CoilUtils.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.coil + +import android.graphics.Bitmap +import android.os.Build +import coil.decode.DataSource +import coil.size.Dimension +import coil.size.Scale +import coil.size.Size +import coil.size.isOriginal +import coil.size.pxOrElse +import java8.nio.file.Path +import me.zhanghai.android.files.filelist.isRemotePath + +val Bitmap.Config.isHardware: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this == Bitmap.Config.HARDWARE + +fun Bitmap.Config.toSoftware(): Bitmap.Config = if (isHardware) Bitmap.Config.ARGB_8888 else this + +val Path.dataSource: DataSource + get() = if (isRemotePath) DataSource.NETWORK else DataSource.DISK + +inline fun Size.widthPx(scale: Scale, original: () -> Int): Int = + if (isOriginal) original() else width.toPx(scale) + +inline fun Size.heightPx(scale: Scale, original: () -> Int): Int = + if (isOriginal) original() else height.toPx(scale) + +fun Dimension.toPx(scale: Scale) = + pxOrElse { + when (scale) { + Scale.FILL -> Int.MIN_VALUE + Scale.FIT -> Int.MAX_VALUE + } + } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/PathAttributesFetcher.kt b/app/src/main/java/me/zhanghai/android/files/coil/PathAttributesFetcher.kt index ea2e7f889..b32fd18bd 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/PathAttributesFetcher.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/PathAttributesFetcher.kt @@ -10,16 +10,15 @@ import android.content.pm.ApplicationInfo import android.media.MediaMetadataRetriever import android.os.ParcelFileDescriptor import androidx.core.graphics.drawable.toDrawable -import coil.bitmap.BitmapPool -import coil.decode.DataSource -import coil.decode.Options +import coil.ImageLoader +import coil.decode.ImageSource import coil.fetch.DrawableResult import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult -import coil.fetch.VideoFrameFetcher -import coil.size.PixelSize -import coil.size.Size +import coil.key.Keyer +import coil.request.Options +import coil.size.Dimension import java8.nio.file.Path import java8.nio.file.attribute.BasicFileAttributes import me.zhanghai.android.files.R @@ -32,6 +31,7 @@ import me.zhanghai.android.files.file.isMedia import me.zhanghai.android.files.file.isPdf import me.zhanghai.android.files.file.isVideo import me.zhanghai.android.files.file.lastModifiedInstant +import me.zhanghai.android.files.filelist.isRemotePath import me.zhanghai.android.files.provider.common.AndroidFileTypeDetector import me.zhanghai.android.files.provider.common.newInputStream import me.zhanghai.android.files.provider.content.resolver.ResolverException @@ -44,6 +44,7 @@ import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.util.getDimensionPixelSize import me.zhanghai.android.files.util.getPackageArchiveInfoCompat import me.zhanghai.android.files.util.isGetPackageArchiveInfoCompatible +import me.zhanghai.android.files.util.isMediaMetadataRetrieverCompatible import me.zhanghai.android.files.util.runWithCancellationSignal import me.zhanghai.android.files.util.setDataSource import me.zhanghai.android.files.util.valueCompat @@ -53,75 +54,35 @@ import java.io.Closeable import java.io.IOException import me.zhanghai.android.files.util.setDataSource as appSetDataSource -class PathAttributesFetcher( - private val context: Context -) : Fetcher> { - private val appIconFetcher = object : AppIconFetcher( - // This is used by FileListAdapter. - context.getDimensionPixelSize(R.dimen.large_icon_size), context - ) { - override fun key(data: Path): String? { - throw AssertionError(data) - } - - override fun getApplicationInfo(data: Path): Pair { - val (packageInfo, closeable) = - context.packageManager.getPackageArchiveInfoCompat(data, 0) - val applicationInfo = packageInfo?.applicationInfo - if (applicationInfo == null) { - closeable?.close() - throw IOException("ApplicationInfo is null") - } - return applicationInfo to closeable - } - } - - private val videoFrameFetcher = object : VideoFrameFetcher(context) { - override fun key(data: Path): String? { - throw AssertionError(data) - } - - override fun MediaMetadataRetriever.setDataSource(data: Path) { - appSetDataSource(data) - } - } - - private val pdfPageFetcher = object : PdfPageFetcher(context) { - override fun key(data: Path): String? { - throw AssertionError(data) - } - - override fun openParcelFileDescriptor(data: Path): ParcelFileDescriptor = - when { - data.isLinuxPath -> - ParcelFileDescriptor.open(data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) - data.isDocumentPath -> - DocumentResolver.openParcelFileDescriptor(data as DocumentResolver.Path, "r") - else -> throw IllegalArgumentException(data.toString()) - } - } - - override fun key(data: Pair): String { +class PathAttributesKeyer : Keyer> { + override fun key(data: Pair, options: Options): String { val (path, attributes) = data return "$path:${attributes.lastModifiedInstant.toEpochMilli()}" } +} - override suspend fun fetch( - pool: BitmapPool, - data: Pair, - size: Size, - options: Options - ): FetchResult { +class PathAttributesFetcher( + private val data: Pair, + private val options: Options, + private val imageLoader: ImageLoader, + private val appIconFetcherFactory: AppIconFetcher.Factory, + private val videoFrameFetcherFactory: VideoFrameFetcher.Factory, + private val pdfPageFetcherFactory: PdfPageFetcher.Factory +) : Fetcher { + override suspend fun fetch(): FetchResult? { val (path, attributes) = data + val (width, height) = options.size // @see android.provider.MediaStore.ThumbnailConstants.MINI_SIZE - val isThumbnail = size is PixelSize && size.width <= 512 && size.height <= 384 + val isThumbnail = width is Dimension.Pixels && width.px <= 512 + && height is Dimension.Pixels && height.px <= 384 if (isThumbnail) { + width as Dimension.Pixels + height as Dimension.Pixels if (path.isDocumentPath && attributes.documentSupportsThumbnail) { - size as PixelSize val thumbnail = runWithCancellationSignal { signal -> try { DocumentResolver.getThumbnail( - path as DocumentResolver.Path, size.width, size.height, signal + path as DocumentResolver.Path, width.px, height.px, signal ) } catch (e: ResolverException) { e.printStackTrace() @@ -130,24 +91,24 @@ class PathAttributesFetcher( } if (thumbnail != null) { return DrawableResult( - thumbnail.toDrawable(context.resources), true, DataSource.DISK + thumbnail.toDrawable(options.context.resources), true, path.dataSource ) } } - val isLocalPath = path.isLinuxPath - || (path.isDocumentPath && DocumentResolver.isLocal(path as DocumentResolver.Path)) - // FTP doesn't support random access and requires one connection per parallel read. - val shouldReadRemotePath = !path.isFtpPath - && Settings.READ_REMOTE_FILES_FOR_THUMBNAIL.valueCompat - if (!(isLocalPath || shouldReadRemotePath)) { - error("Cannot read $path for thumbnail") + if (path.isRemotePath) { + // FTP doesn't support random access and requires one connection per parallel read. + val shouldReadRemotePath = !path.isFtpPath + && Settings.READ_REMOTE_FILES_FOR_THUMBNAIL.valueCompat + if (!shouldReadRemotePath) { + error("Cannot read $path for thumbnail") + } } } val mimeType = AndroidFileTypeDetector.getMimeType(data.first, data.second).asMimeType() when { mimeType.isApk && path.isGetPackageArchiveInfoCompatible -> { try { - return appIconFetcher.fetch(pool, path, size, options) + return appIconFetcherFactory.create(path, options, imageLoader).fetch() } catch (e: Exception) { e.printStackTrace() } @@ -155,12 +116,11 @@ class PathAttributesFetcher( mimeType.isImage || mimeType == MimeType.GENERIC -> { val inputStream = path.newInputStream() return SourceResult( - inputStream.source().buffer(), - if (mimeType != MimeType.GENERIC) mimeType.value else null, - DataSource.DISK + ImageSource(inputStream.source().buffer(), options.context), + if (mimeType != MimeType.GENERIC) mimeType.value else null, path.dataSource ) } - mimeType.isMedia && (path.isLinuxPath || path.isDocumentPath) -> { + mimeType.isMedia && path.isMediaMetadataRetrieverCompatible -> { val embeddedPicture = try { MediaMetadataRetriever().use { retriever -> retriever.setDataSource(path) @@ -172,12 +132,14 @@ class PathAttributesFetcher( } if (embeddedPicture != null) { return SourceResult( - embeddedPicture.inputStream().source().buffer(), null, DataSource.DISK + ImageSource( + embeddedPicture.inputStream().source().buffer(), options.context + ), null, path.dataSource ) } if (mimeType.isVideo) { try { - return videoFrameFetcher.fetch(pool, path, size, options) + return videoFrameFetcherFactory.create(path, options, imageLoader).fetch() } catch (e: Exception) { e.printStackTrace() } @@ -185,12 +147,57 @@ class PathAttributesFetcher( } mimeType.isPdf && (path.isLinuxPath || path.isDocumentPath) -> { try { - return pdfPageFetcher.fetch(pool, path, size, options) + return pdfPageFetcherFactory.create(path, options, imageLoader).fetch() } catch (e: Exception) { e.printStackTrace() } } } - error("Cannot fetch $path") + return null + } + + class Factory(private val context: Context) : Fetcher.Factory> { + private val appIconFetcherFactory = object : AppIconFetcher.Factory( + // This is used by FileListAdapter. + context.getDimensionPixelSize(R.dimen.large_icon_size), context + ) { + override fun getApplicationInfo(data: Path): Pair { + val (packageInfo, closeable) = + context.packageManager.getPackageArchiveInfoCompat(data, 0) + val applicationInfo = packageInfo?.applicationInfo + if (applicationInfo == null) { + closeable?.close() + throw IOException("ApplicationInfo is null") + } + return applicationInfo to closeable + } + } + + private val videoFrameFetcherFactory = object : VideoFrameFetcher.Factory() { + override fun MediaMetadataRetriever.setDataSource(data: Path) { + appSetDataSource(data) + } + } + + private val pdfPageFetcherFactory = object : PdfPageFetcher.Factory() { + override fun openParcelFileDescriptor(data: Path): ParcelFileDescriptor = + when { + data.isLinuxPath -> + ParcelFileDescriptor.open(data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + data.isDocumentPath -> + DocumentResolver.openParcelFileDescriptor(data as DocumentResolver.Path, "r") + else -> throw IllegalArgumentException(data.toString()) + } + } + + override fun create( + data: Pair, + options: Options, + imageLoader: ImageLoader + ): Fetcher = + PathAttributesFetcher( + data, options, imageLoader, appIconFetcherFactory, videoFrameFetcherFactory, + pdfPageFetcherFactory + ) } } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/PdfPageFetcher.kt b/app/src/main/java/me/zhanghai/android/files/coil/PdfPageFetcher.kt index 6fb1859ec..253d68c8a 100644 --- a/app/src/main/java/me/zhanghai/android/files/coil/PdfPageFetcher.kt +++ b/app/src/main/java/me/zhanghai/android/files/coil/PdfPageFetcher.kt @@ -5,31 +5,26 @@ package me.zhanghai.android.files.coil -import android.content.Context -import android.graphics.Bitmap import android.graphics.Color import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor import androidx.annotation.ColorInt import androidx.annotation.IntRange +import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toDrawable -import coil.bitmap.BitmapPool +import coil.ImageLoader import coil.decode.DataSource import coil.decode.DecodeUtils -import coil.decode.Options import coil.fetch.DrawableResult import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.request.ImageRequest +import coil.request.Options import coil.request.Parameters -import coil.size.OriginalSize -import coil.size.PixelSize -import coil.size.Size import kotlin.math.roundToInt -fun ImageRequest.Builder.pdfBackgroundColor(@ColorInt backgroundColor: Int): ImageRequest.Builder { - return setParameter(PdfPageFetcher.PDF_BACKGROUND_COLOR_KEY, backgroundColor) -} +fun ImageRequest.Builder.pdfBackgroundColor(@ColorInt backgroundColor: Int): ImageRequest.Builder = + setParameter(PdfPageFetcher.PDF_BACKGROUND_COLOR_KEY, backgroundColor) fun ImageRequest.Builder.pdfPageIndex(@IntRange(from = 0) pageIndex: Int): ImageRequest.Builder { require(pageIndex >= 0) { "pageIndex must be >= 0." } @@ -42,66 +37,62 @@ fun Parameters.pdfBackgroundColor(): Int? = value(PdfPageFetcher.PDF_BACKGROUND_ @IntRange(from = 0) fun Parameters.pdfPageIndex(): Int? = value(PdfPageFetcher.PDF_PAGE_INDEX_KEY) as Int? -abstract class PdfPageFetcher(private val context: Context) : Fetcher { - protected abstract fun openParcelFileDescriptor(data: T): ParcelFileDescriptor - - override suspend fun fetch( - pool: BitmapPool, - data: T, - size: Size, - options: Options - ): FetchResult { - val pfd = openParcelFileDescriptor(data) - PdfRenderer(pfd).use { renderer -> - val pageIndex = options.parameters.pdfPageIndex() ?: 0 - renderer.openPage(pageIndex).use { page -> - val srcWidth = page.width - val srcHeight = page.height - val dstSize = when (size) { - is PixelSize -> { - if (srcWidth > 0 && srcHeight > 0) { - val rawScale = DecodeUtils.computeSizeMultiplier( - srcWidth = srcWidth, - srcHeight = srcHeight, - dstWidth = size.width, - dstHeight = size.height, - scale = options.scale - ) - val scale = if (options.allowInexactSize) { - rawScale.coerceAtMost(1.0) - } else { - rawScale - } - val width = (scale * srcWidth).roundToInt() - val height = (scale * srcHeight).roundToInt() - PixelSize(width, height) - } else { - OriginalSize - } +class PdfPageFetcher( + private val options: Options, + private val openParcelFileDescriptor: () -> ParcelFileDescriptor +) : Fetcher { + override suspend fun fetch(): FetchResult = + openParcelFileDescriptor().use { pfd -> + PdfRenderer(pfd).use { renderer -> + val pageIndex = options.parameters.pdfPageIndex() ?: 0 + renderer.openPage(pageIndex).use { page -> + val srcWidth = page.width + check(srcWidth > 0) { + "PDF page $pageIndex width $srcWidth isn't greater than 0" } - is OriginalSize -> OriginalSize - } - val bitmap = if (dstSize is PixelSize) { - pool.getDirty(dstSize.width, dstSize.height, Bitmap.Config.ARGB_8888) - } else { - pool.getDirty(srcWidth, srcHeight, Bitmap.Config.ARGB_8888) + val srcHeight = page.height + check(srcWidth > 0) { + "PDF page $pageIndex height $srcHeight isn't greater than 0" + } + val dstWidth = options.size.widthPx(options.scale) { srcWidth } + val dstHeight = options.size.heightPx(options.scale) { srcHeight } + val rawScale = DecodeUtils.computeSizeMultiplier( + srcWidth = srcWidth, + srcHeight = srcHeight, + dstWidth = dstWidth, + dstHeight = dstHeight, + scale = options.scale + ) + val scale = if (options.allowInexactSize) { + rawScale.coerceAtMost(1.0) + } else { + rawScale + } + val width = (scale * srcWidth).roundToInt() + val height = (scale * srcHeight).roundToInt() + val config = options.config.toSoftware() + val bitmap = createBitmap(width, height, config) + val backgroundColor = options.parameters.pdfBackgroundColor() ?: Color.WHITE + bitmap.eraseColor(backgroundColor) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = scale < 1.0, + dataSource = DataSource.DISK + ) } - val backgroundColor = options.parameters.pdfBackgroundColor() ?: Color.WHITE - bitmap.eraseColor(backgroundColor) - page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - val isSampled = dstSize is PixelSize - && (dstSize.width < srcWidth || dstSize.height < srcHeight) - return DrawableResult( - drawable = bitmap.toDrawable(context.resources), - isSampled = isSampled, - dataSource = DataSource.DISK - ) } } - } companion object { const val PDF_BACKGROUND_COLOR_KEY = "coil#pdf_background_color" const val PDF_PAGE_INDEX_KEY = "coil#pdf_page_index" } + + abstract class Factory : Fetcher.Factory { + override fun create(data: T, options: Options, imageLoader: ImageLoader): Fetcher = + PdfPageFetcher(options) { openParcelFileDescriptor(data) } + + protected abstract fun openParcelFileDescriptor(data: T): ParcelFileDescriptor + } } diff --git a/app/src/main/java/me/zhanghai/android/files/coil/VideoFrameFetcher.kt b/app/src/main/java/me/zhanghai/android/files/coil/VideoFrameFetcher.kt new file mode 100644 index 000000000..d18005b03 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/coil/VideoFrameFetcher.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2022 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.coil + +import android.graphics.Paint +import android.media.MediaMetadataRetriever +import android.os.Build +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.DecodeUtils +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.request.Options +import coil.request.videoFrameOption +import coil.request.videoFramePercent +import me.zhanghai.android.files.compat.getFrameAtTimeCompat +import me.zhanghai.android.files.compat.getScaledFrameAtTimeCompat +import me.zhanghai.android.files.compat.use +import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +class VideoFrameFetcher( + private val options: Options, + private val setDataSource: MediaMetadataRetriever.() -> Unit +) : Fetcher { + override suspend fun fetch(): FetchResult = + MediaMetadataRetriever().use { retriever -> + retriever.setDataSource() + val rotation = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + var srcWidth: Int + var srcHeight: Int + when (rotation) { + 90, 270 -> { + srcWidth = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() ?: 0 + srcHeight = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() ?: 0 + } + else -> { + srcWidth = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() ?: 0 + srcHeight = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() ?: 0 + } + } + val durationMillis = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() ?: 0L + // 1/3 is the first percentage tried by totem-video-thumbnailer. + // @see https://gitlab.gnome.org/GNOME/totem/-/blob/master/src/totem-video-thumbnailer.c#L543 + val framePercent = options.parameters.videoFramePercent() ?: (1.0 / 3.0) + val frameMicros = TimeUnit.MICROSECONDS.convert( + (framePercent * durationMillis).roundToLong(), TimeUnit.MILLISECONDS + ) + val frameOption = options.parameters.videoFrameOption() + ?: MediaMetadataRetriever.OPTION_CLOSEST_SYNC + val bitmapParams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + MediaMetadataRetriever.BitmapParams().apply { preferredConfig = options.config } + } else { + null + } + val outBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 + && srcWidth > 0 && srcHeight > 0) { + val dstWidth = options.size.widthPx(options.scale) { srcWidth } + val dstHeight = options.size.heightPx(options.scale) { srcHeight } + val rawScale = DecodeUtils.computeSizeMultiplier( + srcWidth = srcWidth, + srcHeight = srcHeight, + dstWidth = dstWidth, + dstHeight = dstHeight, + scale = options.scale + ) + val scale = if (options.allowInexactSize) { + rawScale.coerceAtMost(1.0) + } else { + rawScale + } + val width = (scale * srcWidth).roundToInt() + val height = (scale * srcHeight).roundToInt() + retriever.getScaledFrameAtTimeCompat( + frameMicros, frameOption, width, height, bitmapParams + ) + } else { + retriever.getFrameAtTimeCompat(frameMicros, frameOption, bitmapParams)?.also { + srcWidth = it.width + srcHeight = it.height + } + } + val dstWidth = options.size.widthPx(options.scale) { srcWidth } + val dstHeight = options.size.heightPx(options.scale) { srcHeight } + val rawScale = DecodeUtils.computeSizeMultiplier( + srcWidth = srcWidth, + srcHeight = srcHeight, + dstWidth = dstWidth, + dstHeight = dstHeight, + scale = options.scale + ) + checkNotNull(outBitmap) { "Failed to decode frame at $frameMicros microseconds" } + val scale = if (options.allowInexactSize) { + rawScale.coerceAtMost(1.0) + } else { + rawScale + } + val width = (scale * srcWidth).roundToInt() + val height = (scale * srcHeight).roundToInt() + val isValidSize = if (options.allowInexactSize) { + outBitmap.width <= width && outBitmap.height <= height + } else { + outBitmap.width == width && outBitmap.height == height + } + val isValidConfig = outBitmap.config?.isHardware != true || options.config.isHardware + val bitmap = if (isValidSize && isValidConfig) { + outBitmap + } else { + val config = options.config.toSoftware() + createBitmap(width, height, config).applyCanvas { + scale(scale.toFloat(), scale.toFloat()) + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + drawBitmap(outBitmap, 0f, 0f, paint) + outBitmap.recycle() + } + } + DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = scale < 1.0, + dataSource = DataSource.DISK + ) + } + + abstract class Factory : Fetcher.Factory { + override fun create(data: T, options: Options, imageLoader: ImageLoader): Fetcher = + VideoFrameFetcher(options) { setDataSource(data) } + + protected abstract fun MediaMetadataRetriever.setDataSource(data: T) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/compat/ActivityCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/ActivityCompat.kt index c9c2136eb..24dd791db 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/ActivityCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/ActivityCompat.kt @@ -11,7 +11,6 @@ import android.graphics.Color import android.os.Build import androidx.annotation.StyleRes import androidx.core.app.ActivityCompat -import me.zhanghai.android.files.R import me.zhanghai.android.files.util.getColorByAttr fun Activity.recreateCompat() { @@ -21,7 +20,7 @@ fun Activity.recreateCompat() { fun Activity.setThemeCompat(@StyleRes resid: Int) { setTheme(resid) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val surfaceColor = getColorByAttr(R.attr.colorSurface) + val surfaceColor = getColorByAttr(com.google.android.material.R.attr.colorSurface) if (surfaceColor != 0 && Color.alpha(surfaceColor) == 0xFF) { @Suppress("DEPRECATION") setTaskDescription(TaskDescription(null, null, surfaceColor)) diff --git a/app/src/main/java/me/zhanghai/android/files/compat/ContextCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/ContextCompat.kt index 5b686320d..49f696e7f 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/ContextCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/ContextCompat.kt @@ -6,7 +6,10 @@ package me.zhanghai.android.files.compat import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.res.ColorStateList import android.graphics.drawable.Drawable import android.util.AttributeSet @@ -38,6 +41,12 @@ fun Context.getColorStateListCompat(@ColorRes id: Int): ColorStateList = fun Context.getDrawableCompat(@DrawableRes id: Int): Drawable = AppCompatResources.getDrawable(this, id)!! +fun Context.getSystemServiceCompat(serviceClass: Class): T = + ContextCompat.getSystemService(this, serviceClass)!! + +val Context.mainExecutorCompat: Executor + get() = ContextCompat.getMainExecutor(this) + @SuppressLint("RestrictedApi") fun Context.obtainStyledAttributesCompat( set: AttributeSet? = null, @@ -60,11 +69,11 @@ inline fun TintTypedArray.use(block: (TintTypedArray) -> R): R { } } -val Context.mainExecutorCompat: Executor - get() = ContextCompat.getMainExecutor(this) - -fun Context.getSystemServiceCompat(serviceClass: Class): T = - ContextCompat.getSystemService(this, serviceClass)!! +fun Context.registerReceiverCompat( + receiver: BroadcastReceiver?, + filter: IntentFilter, + flags: Int +): Intent? = ContextCompat.registerReceiver(this, receiver, filter, flags) @RestrictedHiddenApi private val getThemeResIdMethod by lazyReflectedMethod(Context::class.java, "getThemeResId") diff --git a/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt deleted file mode 100644 index 6a39604ab..000000000 --- a/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt +++ /dev/null @@ -1,7 +0,0 @@ -package me.zhanghai.android.files.compat - -import org.threeten.bp.DateTimeUtils -import org.threeten.bp.Instant -import java.util.Calendar - -fun Calendar.toInstantCompat(): Instant = DateTimeUtils.toInstant(this) diff --git a/app/src/main/java/me/zhanghai/android/files/compat/DocumentsContractCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/DocumentsContractCompat.kt index 0cf1e3736..c5e6d4d13 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/DocumentsContractCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/DocumentsContractCompat.kt @@ -9,6 +9,7 @@ import android.content.ContentResolver import android.net.Uri import android.os.Build import android.provider.DocumentsContract +import me.zhanghai.android.files.app.packageManager object DocumentsContractCompat { const val EXTRA_INITIAL_URI = "android.provider.extra.INITIAL_URI" @@ -21,6 +22,17 @@ object DocumentsContractCompat { private const val PATH_CHILDREN = "children" private const val PATH_TREE = "tree" + /** @see DocumentsContract.PACKAGE_DOCUMENTS_UI */ + fun getDocumentsUiPackage(): String? { + // See android.permission.cts.ProviderPermissionTest.testManageDocuments() + val packageInfos = packageManager.getPackagesHoldingPermissions( + arrayOf(android.Manifest.permission.MANAGE_DOCUMENTS), 0 + ) + val packageInfo = packageInfos.firstOrNull { it.packageName.endsWith(".documentsui") } + ?: packageInfos.firstOrNull() + return packageInfo?.packageName + } + /** @see DocumentsContract.isDocumentUri */ fun isDocumentUri(uri: Uri): Boolean { if (uri.scheme != ContentResolver.SCHEME_CONTENT) { diff --git a/app/src/main/java/me/zhanghai/android/files/compat/InputStreamCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/InputStreamCompat.kt new file mode 100644 index 000000000..445e9187b --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/compat/InputStreamCompat.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.compat + +import java.io.IOException +import java.io.InputStream +import kotlin.reflect.KClass + +fun KClass.nullInputStream(): InputStream = + object : InputStream() { + private var closed = false + + override fun read(): Int { + ensureOpen() + return -1 + } + + override fun read(bytes: ByteArray, offset: Int, length: Int): Int { + if (!(offset >= 0 && length >= 0 && length <= bytes.size - offset)) { + throw IndexOutOfBoundsException() + } + ensureOpen() + return if (length == 0) 0 else -1 + } + + override fun skip(length: Long): Long { + ensureOpen() + return 0 + } + + override fun available(): Int { + ensureOpen() + return 0 + } + + override fun close() { + closed = true + } + + private fun ensureOpen() { + if (closed) { + throw IOException("Stream closed") + } + } + } diff --git a/app/src/main/java/me/zhanghai/android/files/compat/KotlinCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/KotlinCompat.kt index 459dfa354..cadc64136 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/KotlinCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/KotlinCompat.kt @@ -5,30 +5,12 @@ package me.zhanghai.android.files.compat -import java.io.Closeable -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract import kotlin.comparisons.reversed as kotlinReversed -import kotlin.io.use as kotlinUse -import kotlin.use as kotlinUse +import kotlin.collections.removeFirst as kotlinRemoveFirst +import kotlin.collections.removeLast as kotlinRemoveLast -// @see https://youtrack.jetbrains.com/issue/KT-35216 -@OptIn(ExperimentalContracts::class) -inline fun T.use(block: (T) -> R): R { - contract { - callsInPlace(block, InvocationKind.EXACTLY_ONCE) - } - return kotlinUse(block) -} +fun Comparator.reversedCompat(): Comparator = kotlinReversed() -// @see https://youtrack.jetbrains.com/issue/KT-35216 -@OptIn(ExperimentalContracts::class) -inline fun T.use(block: (T) -> R): R { - contract { - callsInPlace(block, InvocationKind.EXACTLY_ONCE) - } - return kotlinUse(block) -} +fun MutableList.removeFirstCompat(): T = kotlinRemoveFirst() -fun Comparator.reversedCompat(): Comparator = kotlinReversed() +fun MutableList.removeLastCompat(): T = kotlinRemoveLast() diff --git a/app/src/main/java/me/zhanghai/android/files/compat/LocaleConfigCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/LocaleConfigCompat.kt new file mode 100644 index 000000000..ed98883f6 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/compat/LocaleConfigCompat.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.compat + +import android.app.LocaleConfig +import android.content.Context +import android.content.res.XmlResourceParser +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.XmlRes +import androidx.core.content.res.ResourcesCompat +import androidx.core.os.LocaleListCompat +import org.xmlpull.v1.XmlPullParser +import java.io.FileNotFoundException + +/** + * @see android.app.LocaleConfig + */ +class LocaleConfigCompat(context: Context) { + var status = 0 + private set + + var supportedLocales: LocaleListCompat? = null + private set + + init { + val impl = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Api33Impl(context) + } else { + Api21Impl(context) + } + status = impl.status + supportedLocales = impl.supportedLocales + } + + companion object { + /** + * Succeeded reading the LocaleConfig structure stored in an XML file. + */ + const val STATUS_SUCCESS = 0 + + /** + * No android:localeConfig tag on . + */ + const val STATUS_NOT_SPECIFIED = 1 + + /** + * Malformed input in the XML file where the LocaleConfig was stored. + */ + const val STATUS_PARSING_FAILED = 2 + } + + private abstract class Impl { + abstract val status: Int + abstract val supportedLocales: LocaleListCompat? + } + + private class Api21Impl(context: Context) : Impl() { + override var status = 0 + private set + + override var supportedLocales: LocaleListCompat? = null + private set + + init { + val resourceId = try { + getLocaleConfigResourceId(context) + } catch (e: Exception) { + Log.w(TAG, "The resource file pointed to by the given resource ID isn't found.", e) + } + if (resourceId == ResourcesCompat.ID_NULL) { + status = STATUS_NOT_SPECIFIED + } else { + val resources = context.resources + try { + supportedLocales = resources.getXml(resourceId).use { parseLocaleConfig(it) } + status = STATUS_SUCCESS + } catch (e: Exception) { + val resourceEntryName = resources.getResourceEntryName(resourceId) + Log.w(TAG, "Failed to parse XML configuration from $resourceEntryName", e) + status = STATUS_PARSING_FAILED + } + } + } + + // @see com.android.server.pm.pkg.parsing.ParsingPackageUtils + @XmlRes + private fun getLocaleConfigResourceId(context: Context): Int { + // Java cookies starts at 1, while passing 0 (invalid cookie for Java) makes + // AssetManager pick the last asset containing such a file name. + // We should go over all the assets containing AndroidManifest.xml, however there's no + // API to do that, so the best we can do is to start from the first asset and iterate + // until we can't find the next asset containing AndroidManifest.xml. + var cookie = 1 + var isAndroidManifestFound = false + while (true) { + val parser = try { + context.assets.openXmlResourceParser(cookie, FILE_NAME_ANDROID_MANIFEST) + } catch (e: FileNotFoundException) { + if (!isAndroidManifestFound) { + ++cookie + continue + } else { + break + } + } + isAndroidManifestFound = true + parser.use { + do { + if (parser.eventType != XmlPullParser.START_TAG) { + continue + } + if (parser.name != TAG_MANIFEST) { + parser.skipCurrentTag() + continue + } + if (parser.getAttributeValue(null, ATTR_PACKAGE) != context.packageName) { + break + } + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.eventType != XmlPullParser.START_TAG) { + continue + } + if (parser.name != TAG_APPLICATION) { + parser.skipCurrentTag() + continue + } + return parser.getAttributeResourceValue( + NAMESPACE_ANDROID, ATTR_LOCALE_CONFIG, ResourcesCompat.ID_NULL + ) + } + } while (parser.next() != XmlPullParser.END_DOCUMENT) + } + ++cookie + } + return ResourcesCompat.ID_NULL + } + + private fun parseLocaleConfig(parser: XmlResourceParser): LocaleListCompat { + val localeNames = mutableSetOf() + do { + if (parser.eventType != XmlPullParser.START_TAG) { + continue + } + if (parser.name != TAG_LOCALE_CONFIG) { + parser.skipCurrentTag() + continue + } + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.eventType != XmlPullParser.START_TAG) { + continue + } + if (parser.name != TAG_LOCALE) { + parser.skipCurrentTag() + continue + } + localeNames += parser.getAttributeValue(NAMESPACE_ANDROID, ATTR_NAME) + parser.skipCurrentTag() + } + } while (parser.next() != XmlPullParser.END_DOCUMENT) + return LocaleListCompat.forLanguageTags(localeNames.joinToString(",")) + } + + private fun XmlPullParser.skipCurrentTag() { + val outerDepth = depth + var type: Int + do { + type = next() + } while (type != XmlPullParser.END_DOCUMENT && + (type != XmlPullParser.END_TAG || depth > outerDepth)) + } + + companion object { + private const val TAG = "LocaleConfigCompat" + + private const val FILE_NAME_ANDROID_MANIFEST = "AndroidManifest.xml" + + private const val TAG_APPLICATION = "application" + private const val TAG_LOCALE_CONFIG = "locale-config" + private const val TAG_LOCALE = "locale" + private const val TAG_MANIFEST = "manifest" + + private const val NAMESPACE_ANDROID = "http://schemas.android.com/apk/res/android" + + private const val ATTR_LOCALE_CONFIG = "localeConfig" + private const val ATTR_NAME = "name" + private const val ATTR_PACKAGE = "package" + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private class Api33Impl(context: Context) : Impl() { + override var status: Int = 0 + private set + + override var supportedLocales: LocaleListCompat? = null + private set + + init { + val platformLocaleConfig = LocaleConfig(context) + status = platformLocaleConfig.status + supportedLocales = platformLocaleConfig.supportedLocales + ?.let { LocaleListCompat.wrap(it) } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/compat/MediaMetadataRetrieverCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/MediaMetadataRetrieverCompat.kt index 972723fb0..012f29d53 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/MediaMetadataRetrieverCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/MediaMetadataRetrieverCompat.kt @@ -5,6 +5,7 @@ package me.zhanghai.android.files.compat +import android.graphics.Bitmap import android.media.MediaMetadataRetriever import android.os.Build import androidx.annotation.RequiresApi @@ -17,6 +18,31 @@ val KClass.METADATA_KEY_SAMPLERATE: Int @RequiresApi(Build.VERSION_CODES.Q) get() = 38 +fun MediaMetadataRetriever.getFrameAtTimeCompat( + timeUs: Long, + option: Int, + params: MediaMetadataRetriever.BitmapParams? +): Bitmap? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && params != null) { + getFrameAtTime(timeUs, option, params) + } else { + getFrameAtTime(timeUs, option) + } + +@RequiresApi(Build.VERSION_CODES.O_MR1) +fun MediaMetadataRetriever.getScaledFrameAtTimeCompat( + timeUs: Long, + option: Int, + dstWidth: Int, + dstHeight: Int, + params: MediaMetadataRetriever.BitmapParams? +): Bitmap? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && params != null) { + getScaledFrameAtTime(timeUs, option, dstWidth, dstHeight, params) + } else { + getScaledFrameAtTime(timeUs, option, dstWidth, dstHeight) + } + @OptIn(ExperimentalContracts::class) inline fun MediaMetadataRetriever.use(block: (MediaMetadataRetriever) -> R): R { contract { diff --git a/app/src/main/java/me/zhanghai/android/files/compat/MenuCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/MenuCompat.kt new file mode 100644 index 000000000..2e7846ca6 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/compat/MenuCompat.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.compat + +import android.view.Menu +import androidx.core.view.MenuCompat + +fun Menu.setGroupDividerEnabledCompat(groupDividerEnabled: Boolean) { + MenuCompat.setGroupDividerEnabled(this, groupDividerEnabled) +} diff --git a/app/src/main/java/me/zhanghai/android/files/compat/MimeTypeMapCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/MimeTypeMapCompat.kt index 1702eb78e..a351c0ff8 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/MimeTypeMapCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/MimeTypeMapCompat.kt @@ -10,141 +10,353 @@ import android.webkit.MimeTypeMap // Generated by mime/generate-extensions.sh and mime/generate-code.sh . private val extensionToMimeTypeMap = mapOf( "%" to "application/x-trash", - "323" to "text/h323", + "123" to "application/vnd.lotus-1-2-3", + "1905.1" to "application/vnd.ieee.1905", + "1clr" to "application/clr", + "1km" to "application/vnd.1000minds.decision-model+xml", + "210" to "application/p21", + "3dm" to "text/vnd.in3d.3dml", + "3dml" to "text/vnd.in3d.3dml", "3g2" to "video/3gpp2", "3ga" to "audio/3gpp", "3gp" to "video/3gpp", "3gp2" to "video/3gpp2", "3gpp" to "video/3gpp", "3gpp2" to "video/3gpp2", + "3mf" to "application/vnd.ms-3mfdocument", + "3tz" to "application/vnd.maxar.archive.3tz+zip", + "726" to "audio/32kadpcm", "7z" to "application/x-7z-compressed", + "AMR" to "audio/AMR", + "AWB" to "audio/AMR-WB", + "CQL" to "text/cql", + "ELN" to "application/vnd.eln+zip", + "J2C" to "image/j2c", + "J2K" to "image/j2c", + "PGB" to "image/vnd.globalgraphics.pgb", + "QCP" to "audio/EVRC-QCP", + "SAR" to "application/vnd.sar", + "VES" to "application/vnd.ves.encrypted", + "VFK" to "text/vnd.exchangeable", + "a" to "text/vnd.a", + "a2l" to "application/A2L", "a52" to "audio/ac3", + "aa3" to "audio/ATRAC3", "aac" to "audio/aac", + "aal" to "audio/ATRAC-ADVANCED-LOSSLESS", + "abc" to "text/vnd.abc", "abw" to "application/x-abiword", + "ac" to "application/pkix-attr-cert", + "ac2" to "application/vnd.banana-accounting", "ac3" to "audio/ac3", + "acc" to "application/vnd.americandynamics.acc", + "acn" to "audio/asc", + "acu" to "application/vnd.acucobol", + "acutc" to "application/vnd.acucorp", "adt" to "audio/aac", "adts" to "audio/aac", + "aep" to "application/vnd.audiograph", + "afp" to "application/vnd.afpc.modca", + "age" to "application/vnd.age", + "ahead" to "application/vnd.ahead.space", + "ahv" to "application/vnd.android.haptics.vibration+xml", "ai" to "application/postscript", "aif" to "audio/x-aiff", "aifc" to "audio/x-aiff", "aiff" to "audio/x-aiff", + "aion" to "application/vnd.veritone.aion+json", + "ait" to "application/vnd.dvb.ait", "alc" to "chemical/x-alchemy", + "ami" to "application/vnd.amiga.ami", + "aml" to "application/AML", + "amlx" to "application/automationml-amlx+zip", "amr" to "audio/amr", "anx" to "application/annodex", + "apex" to "application/vnd.apexlang", + "apexlang" to "application/vnd.apexlang", "apk" to "application/vnd.android.package-archive", + "apkg" to "application/vnd.anki", + "apng" to "image/apng", "appcache" to "text/cache-manifest", - "application" to "application/x-ms-application", + "apr" to "application/vnd.lotus-approach", + "apxml" to "application/auth-policy+xml", + "arrow" to "application/vnd.apache.arrow.file", + "arrows" to "application/vnd.apache.arrow.stream", "art" to "image/x-jg", + "artisan" to "application/vnd.artisan+json", "arw" to "image/x-sony-arw", - "asc" to "text/plain", - "asf" to "video/x-ms-asf", + "asc" to "application/pgp-keys", + "ascii" to "text/vnd.ascii-art", + "asf" to "application/vnd.ms-asf", + "asice" to "application/vnd.etsi.asic-e+zip", + "asics" to "application/vnd.etsi.asic-s+zip", "asn" to "chemical/x-ncbi-asn1-spec", "aso" to "chemical/x-ncbi-asn1-binary", - "asx" to "video/x-ms-asf", + "ass" to "audio/aac", + "at3" to "audio/ATRAC3", + "atc" to "application/vnd.acucorp", + "atf" to "application/ATF", + "atfx" to "application/ATFX", "atom" to "application/atom+xml", "atomcat" to "application/atomcat+xml", + "atomdeleted" to "application/atomdeleted+xml", "atomsrv" to "application/atomserv+xml", + "atomsvc" to "application/atomsvc+xml", + "atx" to "audio/ATRAC-X", + "atxml" to "application/ATXML", "au" to "audio/basic", + "auc" to "application/tamp-apex-update-confirm", + "avci" to "image/avci", + "avcs" to "image/avcs", "avi" to "video/avi", - "awb" to "audio/amr-wb", + "avif" to "image/avif", + "awb" to "audio/AMR-WB", "axa" to "audio/annodex", "axv" to "video/annodex", + "azf" to "application/vnd.airzip.filesecure.azf", + "azs" to "application/vnd.airzip.filesecure.azs", + "azv" to "image/vnd.airzip.accelerator.azv", + "azw3" to "application/vnd.amazon.mobi8-ebook", "b" to "chemical/x-molconn-Z", + "b16" to "image/vnd.pco.b16", "bak" to "application/x-trash", + "bar" to "application/vnd.qualcomm.brew-app-res", + "bary" to "model/vnd.bary", "bat" to "application/x-msdos-program", "bcpio" to "application/x-bcpio", + "bdm" to "application/vnd.syncml.dm+wbxml", + "bed" to "application/vnd.realvnc.bed", + "bh2" to "application/vnd.fujitsu.oasysprs", "bib" to "text/x-bibtex", + "bik" to "video/vnd.radgamettools.bink", "bin" to "application/octet-stream", + "bk2" to "video/vnd.radgamettools.bink", + "bkm" to "application/vnd.nervana", + "bmed" to "multipart/vnd.bint.med-plus", + "bmi" to "application/vnd.bmi", + "bmml" to "application/vnd.balsamiq.bmml+xml", "bmp" to "image/x-ms-bmp", + "bmpr" to "application/vnd.balsamiq.bmpr", "boo" to "text/x-boo", "book" to "application/x-maker", + "box" to "application/vnd.previewsystems.box", + "bpd" to "application/vnd.hbci", "brf" to "text/plain", "bsd" to "chemical/x-crossfire", + "bsp" to "model/vnd.valve.source.compiled-map", + "btf" to "image/prs.btif", + "btif" to "image/prs.btif", "c" to "text/x-csrc", "c++" to "text/x-c++src", + "c11amc" to "application/vnd.cluetrust.cartomobile-config", + "c11amz" to "application/vnd.cluetrust.cartomobile-config-pkg", "c3d" to "chemical/x-chem3d", - "cab" to "application/x-cab", + "c3ex" to "application/cccex", + "c4d" to "application/vnd.clonk.c4group", + "c4f" to "application/vnd.clonk.c4group", + "c4g" to "application/vnd.clonk.c4group", + "c4p" to "application/vnd.clonk.c4group", + "c4u" to "application/vnd.clonk.c4group", + "c9r" to "application/vnd.cryptomator.encrypted", + "c9s" to "application/vnd.cryptomator.encrypted", + "cab" to "application/vnd.ms-cab-compressed", "cac" to "chemical/x-cache", "cache" to "chemical/x-cache", "cap" to "application/vnd.tcpdump.pcap", + "car" to "application/vnd.ipld.car", + "carjson" to "application/vnd.eu.kasparian.car+json", "cascii" to "chemical/x-cactvs-binary", "cat" to "application/vnd.ms-pki.seccat", "cbin" to "chemical/x-cactvs-binary", - "cbr" to "application/x-cbr", - "cbz" to "application/x-cbz", + "cbor" to "application/cbor", + "cbr" to "application/vnd.comicbook-rar", + "cbz" to "application/vnd.comicbook+zip", "cc" to "text/x-c++src", + "ccc" to "text/vnd.net2phone.commcenter.command", + "ccmp" to "application/ccmp+xml", + "ccxml" to "application/ccxml+xml", "cda" to "application/x-cdf", + "cdbcmsg" to "application/vnd.contact.cmsg", "cdf" to "application/x-cdf", + "cdfx" to "application/CDFX+XML", + "cdkey" to "application/vnd.mediastation.cdkey", + "cdmia" to "application/cdmi-capability", + "cdmic" to "application/cdmi-container", + "cdmid" to "application/cdmi-domain", + "cdmio" to "application/cdmi-object", + "cdmiq" to "application/cdmi-queue", "cdr" to "image/x-coreldraw", "cdt" to "image/x-coreldrawtemplate", "cdx" to "chemical/x-cdx", + "cdxml" to "application/vnd.chemdraw+xml", "cdy" to "application/vnd.cinderella", + "cea" to "application/CEA", "cef" to "chemical/x-cxf", + "cellml" to "application/cellml+xml", "cer" to "application/pkix-cert", + "cgm" to "image/cgm", "chm" to "chemical/x-chemdraw", - "chrt" to "application/x-kchart", + "chrt" to "application/vnd.kde.kchart", "cif" to "chemical/x-cif", + "cii" to "application/vnd.anser-web-certificate-issue-initiation", + "cil" to "application/vnd.ms-artgalry", + "cl" to "application/simple-filter+xml", + "cla" to "application/vnd.claymore", "class" to "application/java-vm", + "cld" to "model/vnd.cld", + "clkk" to "application/vnd.crick.clicker.keyboard", + "clkp" to "application/vnd.crick.clicker.palette", + "clkt" to "application/vnd.crick.clicker.template", + "clkw" to "application/vnd.crick.clicker.wordbank", + "clkx" to "application/vnd.crick.clicker", "cls" to "text/x-tex", + "clue" to "application/clue_info+xml", + "cmc" to "application/vnd.cosmocaller", "cmdf" to "chemical/x-cmdf", "cml" to "chemical/x-cml", + "cmp" to "application/vnd.yellowriver-custom-menu", + "cmsc" to "application/cms", + "cnd" to "text/jcr-cnd", "cod" to "application/vnd.rim.cod", + "coffee" to "application/vnd.coffeescript", "com" to "application/x-msdos-program", + "copyright" to "text/vnd.debian.copyright", + "coswid" to "application/swid+cbor", "cpa" to "chemical/x-compass", "cpio" to "application/x-cpio", + "cpkg" to "application/vnd.xmpie.cpkg", + "cpl" to "application/cpl+xml", "cpp" to "text/x-c++src", "cpt" to "image/x-corelphotopaint", "cr2" to "image/x-canon-cr2", - "crl" to "application/x-pkcs7-crl", + "crl" to "application/pkix-crl", "crt" to "application/x-x509-ca-cert", + "crtr" to "application/vnd.multiad.creator", "crw" to "image/x-canon-crw", + "cryptomator" to "application/vnd.cryptomator.vault", + "cryptonote" to "application/vnd.rig.cryptonote", "csd" to "audio/csound", "csf" to "chemical/x-cache-csf", "csh" to "text/x-csh", + "csl" to "application/vnd.citationstyles.style+xml", "csm" to "chemical/x-csml", "csml" to "chemical/x-csml", + "csp" to "application/vnd.commonspace", + "csrattrs" to "application/csrattrs", "css" to "text/css", + "cst" to "application/vnd.commonspace", "csv" to "text/comma-separated-values", + "csvs" to "text/csv-schema", "ctab" to "chemical/x-cactvs-binary", "ctx" to "chemical/x-ctx", "cu" to "application/cu-seeme", "cub" to "chemical/x-gaussian-cube", + "cuc" to "application/tamp-community-update-confirm", "cur" to "image/ico", + "curl" to "text/vnd.curl", + "cw" to "application/prs.cww", + "cwl" to "application/cwl", + "cwl.json" to "application/cwl+json", + "cww" to "application/prs.cww", "cxf" to "chemical/x-cxf", "cxx" to "text/x-c++src", "d" to "text/x-dsrc", + "dae" to "model/vnd.collada+xml", + "daf" to "application/vnd.Mobius.DAF", + "dart" to "application/vnd.dart", + "dataless" to "application/vnd.fdsn.seed", "davmount" to "application/davmount+xml", + "dbf" to "application/vnd.dbf", + "dcd" to "application/DCD", "dcm" to "application/dicom", "dcr" to "application/x-director", + "dd2" to "application/vnd.oma.dd2+xml", + "ddd" to "application/vnd.fujixerox.ddd", "ddeb" to "application/vnd.debian.binary-package", - "deb" to "application/x-debian-package", + "ddf" to "application/vnd.syncml.dmddf+xml", + "deb" to "application/vnd.debian.binary-package", "deploy" to "application/octet-stream", + "der" to "application/x-x509-ca-cert", + "dfac" to "application/vnd.dreamfactory", "dfxp" to "application/ttml+xml", "dif" to "video/dv", "diff" to "text/plain", + "dii" to "application/DII", + "dim" to "application/vnd.fastcopy-disk-image", "dir" to "application/x-director", + "dis" to "application/vnd.Mobius.DIS", + "dist" to "application/vnd.apple.installer+xml", + "distz" to "application/vnd.apple.installer+xml", + "dit" to "application/DIT", + "dive" to "application/vnd.patentdive", "djv" to "image/vnd.djvu", "djvu" to "image/vnd.djvu", - "dl" to "video/dl", + "dl" to "application/vnd.datalog", "dll" to "application/x-msdos-program", + "dls" to "audio/dls", "dmg" to "application/x-apple-diskimage", - "dms" to "application/x-dms", + "dmp" to "application/vnd.tcpdump.pcap", + "dms" to "text/vnd.DMClientScript", + "dna" to "application/vnd.dna", "dng" to "image/x-adobe-dng", "doc" to "application/msword", + "docjson" to "application/vnd.document+json", "docm" to "application/vnd.ms-word.document.macroEnabled.12", "docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "dot" to "application/msword", + "dor" to "model/vnd.gdl", + "dot" to "text/vnd.graphviz", "dotm" to "application/vnd.ms-word.template.macroEnabled.12", "dotx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "dp" to "application/vnd.osgi.dp", + "dpg" to "application/vnd.dpgraph", + "dpgraph" to "application/vnd.dpgraph", + "dpkg" to "application/vnd.xmpie.dpkg", + "dpx" to "image/dpx", + "drle" to "image/dicom-rle", + "dsc" to "text/prs.lines.tag", + "dsm" to "application/vnd.desmume.movie", + "dssc" to "application/dssc+der", + "dtd" to "application/xml-dtd", + "dts" to "audio/vnd.dts", + "dtshd" to "audio/vnd.dts.hd", "dv" to "video/dv", + "dvb" to "video/vnd.dvb.file", + "dvc" to "application/dvcs", "dvi" to "application/x-dvi", + "dwd" to "application/atsc-dwd+xml", + "dwf" to "model/vnd.dwf", + "dwg" to "image/vnd.dwg", "dx" to "chemical/x-jcamp-dx", + "dxf" to "image/vnd.dxf", + "dxp" to "application/vnd.spotfire.dxp", "dxr" to "application/x-director", + "dzr" to "application/vnd.dzr", + "ebuild" to "application/vnd.gentoo.ebuild", + "ecelp4800" to "audio/vnd.nuera.ecelp4800", + "ecelp7470" to "audio/vnd.nuera.ecelp7470", + "ecelp9600" to "audio/vnd.nuera.ecelp9600", + "ecig" to "application/vnd.evolv.ecig.settings", + "ecigprofile" to "application/vnd.evolv.ecig.profile", + "ecigtheme" to "application/vnd.evolv.ecig.theme", + "eclass" to "application/vnd.gentoo.eclass", + "edm" to "application/vnd.novadigm.EDM", + "edx" to "application/vnd.novadigm.EDX", + "efi" to "application/efi", + "efif" to "application/vnd.picsel", + "ei6" to "application/vnd.pg.osasli", "emb" to "chemical/x-embl-dl-nucleotide", "embl" to "chemical/x-embl-dl-nucleotide", + "emf" to "image/emf", "eml" to "message/rfc822", - "ent" to "chemical/x-pdb", + "emm" to "application/vnd.ibm.electronic-media", + "emma" to "application/emma+xml", + "emotionml" to "application/emotionml+xml", + "ent" to "application/xml-external-parsed-entity", + "entity" to "application/vnd.nervana", + "enw" to "audio/EVRCNW", + "eol" to "audio/vnd.digital-winds", "eot" to "application/vnd.ms-fontobject", + "ep" to "application/vnd.bluetooth.ep.oob", "eps" to "application/postscript", "eps2" to "application/postscript", "eps3" to "application/postscript", @@ -152,125 +364,334 @@ private val extensionToMimeTypeMap = mapOf( "epsi" to "application/postscript", "epub" to "application/epub+zip", "erf" to "image/x-epson-erf", - "es" to "application/ecmascript", + "es" to "text/javascript", + "es3" to "application/vnd.eszigno3+xml", + "esa" to "application/vnd.osgi.subsystem", + "esf" to "application/vnd.epson.esf", + "espass" to "application/vnd.espass-espass+zip", + "et3" to "application/vnd.eszigno3+xml", "etx" to "text/x-setext", + "evb" to "audio/EVRCB", + "evc" to "audio/EVRC", + "evw" to "audio/EVRCWB", "exe" to "application/x-msdos-program", + "exi" to "application/exi", + "exp" to "application/express", + "exr" to "image/aces", + "ext" to "application/vnd.novadigm.EXT", "ez" to "application/andrew-inset", + "ez2" to "application/vnd.ezpix-album", + "ez3" to "application/vnd.ezpix-package", "f4a" to "audio/mp4", "f4b" to "audio/mp4", "f4p" to "audio/mp4", "f4v" to "video/mp4", "fb" to "application/x-maker", "fbdoc" to "application/x-maker", + "fbs" to "image/vnd.fastbidsheet", + "fcdt" to "application/vnd.adobe.formscentral.fcdt", "fch" to "chemical/x-gaussian-checkpoint", "fchk" to "chemical/x-gaussian-checkpoint", + "fcs" to "application/vnd.isac.fcs", + "fdf" to "application/fdf", + "fdt" to "application/fdt+xml", + "fe_launch" to "application/vnd.denovo.fcselayout-link", + "fg5" to "application/vnd.fujitsu.oasysgp", "fig" to "application/x-xfig", + "finf" to "application/fastinfoset", + "fit" to "image/fits", + "fits" to "image/fits", "fl" to "application/x-android-drm-fl", + "fla" to "application/vnd.dtg.local.flash", "flac" to "audio/flac", + "flb" to "application/vnd.ficlab.flb+zip", "fli" to "video/fli", + "flo" to "application/vnd.micrografx.flo", + "flt" to "text/vnd.ficlab.flt", "flv" to "video/x-flv", + "flw" to "application/vnd.kde.kivio", + "flx" to "text/vnd.fmi.flexstor", + "fly" to "text/vnd.fly", "fm" to "application/x-maker", + "fo" to "application/vnd.software602.filler.form+xml", + "fpx" to "image/vnd.fpx", "frame" to "application/x-maker", "frm" to "application/x-maker", + "fsc" to "application/vnd.fsc.weblaunch", + "fst" to "image/vnd.fst", + "ftc" to "application/vnd.fluxtime.clip", + "fti" to "application/vnd.anser-web-funds-transfer-initiation", + "fts" to "image/fits", + "fvt" to "video/vnd.fvt", + "fxp" to "application/vnd.adobe.fxp", + "fxpl" to "application/vnd.adobe.fxp", + "fzs" to "application/vnd.fuzzysheet", + "g2w" to "application/vnd.geoplan", + "g3w" to "application/vnd.geospace", + "gac" to "application/vnd.groove-account", "gal" to "chemical/x-gaussian-log", "gam" to "chemical/x-gamess-input", "gamin" to "chemical/x-gamess-input", "gan" to "application/x-ganttproject", "gau" to "chemical/x-gaussian-input", + "gbr" to "application/rpki-ghostbusters", "gcd" to "text/x-pcs-gcd", "gcf" to "application/x-graphing-calculator", "gcg" to "chemical/x-gcg8-sequence", + "gdl" to "model/vnd.gdl", + "gdz" to "application/vnd.familysearch.gedcom+zip", + "ged" to "text/vnd.familysearch.gedcom", "gen" to "chemical/x-genbank", + "genozip" to "application/vnd.genozip", + "geo" to "application/vnd.dynageo", + "geojson" to "application/geo+json", + "gex" to "application/vnd.geometry-explorer", "gf" to "application/x-tex-gf", + "gff3" to "text/gff3", + "ggb" to "application/vnd.geogebra.file", + "ggs" to "application/vnd.geogebra.slides", + "ggt" to "application/vnd.geogebra.tool", + "ghf" to "application/vnd.groove-help", "gif" to "image/gif", + "gim" to "application/vnd.groove-identity-message", "gjc" to "chemical/x-gaussian-input", "gjf" to "chemical/x-gaussian-input", "gl" to "video/gl", + "glb" to "model/gltf-binary", + "glbin" to "application/gltf-buffer", + "glbuf" to "application/gltf-buffer", + "gltf" to "model/gltf+json", + "gml" to "application/gml+xml", "gnumeric" to "application/x-gnumeric", + "gph" to "application/vnd.FloGraphIt", + "gpkg" to "application/geopackage+sqlite3", + "gpkg.tar" to "application/vnd.gentoo.gpkg", "gpt" to "chemical/x-mopac-graph", + "gqf" to "application/vnd.grafeq", + "gqs" to "application/vnd.grafeq", + "gram" to "application/srgs", + "grd" to "application/vnd.gentics.grd+json", + "gre" to "application/vnd.geometry-explorer", + "grv" to "application/vnd.groove-injector", + "grxml" to "application/srgs+xml", "gsf" to "application/x-font", + "gsheet" to "application/urc-grpsheet+xml", "gsm" to "audio/x-gsm", "gtar" to "application/x-gtar", + "gtm" to "application/vnd.groove-tool-message", + "gtw" to "model/vnd.gtw", + "gv" to "text/vnd.graphviz", + "gxt" to "application/vnd.geonext", "gz" to "application/gzip", "h" to "text/x-chdr", "h++" to "text/x-c++hdr", + "hal" to "application/vnd.hal+xml", + "hans" to "text/vnd.hans", + "hbc" to "application/vnd.hbci", + "hbci" to "application/vnd.hbci", "hdf" to "application/x-hdf", + "hdr" to "image/vnd.radiance", + "hdt" to "application/vnd.hdt", "heic" to "image/heic", "heics" to "image/heic-sequence", "heif" to "image/heif", "heifs" to "image/heif-sequence", + "hej2" to "image/hej2k", + "held" to "application/atsc-held+xml", + "hgl" to "text/vnd.hgl", "hh" to "text/x-c++hdr", "hif" to "image/heif", "hin" to "chemical/x-hin", + "hpgl" to "application/vnd.hp-HPGL", + "hpi" to "application/vnd.hp-hpid", + "hpid" to "application/vnd.hp-hpid", "hpp" to "text/x-c++hdr", + "hps" to "application/vnd.hp-hps", + "hpub" to "application/prs.hpub+zip", "hqx" to "application/mac-binhex40", "hs" to "text/x-haskell", + "hsj2" to "image/hsj2", + "hsl" to "application/vnd.hsl", "hta" to "application/hta", "htc" to "text/x-component", + "htke" to "application/vnd.kenameaapp", "htm" to "text/html", "html" to "text/html", + "hvd" to "application/vnd.yamaha.hv-dic", + "hvp" to "application/vnd.yamaha.hv-voice", + "hvs" to "application/vnd.yamaha.hv-script", "hwp" to "application/x-hwp", "hxx" to "text/x-c++hdr", + "i2g" to "application/vnd.intergeo", + "ic0" to "application/vnd.commerce-battelle", + "ic1" to "application/vnd.commerce-battelle", + "ic2" to "application/vnd.commerce-battelle", + "ic3" to "application/vnd.commerce-battelle", + "ic4" to "application/vnd.commerce-battelle", + "ic5" to "application/vnd.commerce-battelle", + "ic6" to "application/vnd.commerce-battelle", + "ic7" to "application/vnd.commerce-battelle", + "ic8" to "application/vnd.commerce-battelle", "ica" to "application/x-ica", - "ice" to "x-conference/x-cooltalk", + "icc" to "application/vnd.iccprofile", + "icd" to "application/vnd.commerce-battelle", + "icf" to "application/vnd.commerce-battelle", + "icm" to "application/vnd.iccprofile", "ico" to "image/x-icon", "ics" to "text/calendar", - "icz" to "text/calendar", "ief" to "image/ief", + "ifb" to "text/calendar", + "ifc" to "application/p21", + "ifm" to "application/vnd.shana.informed.formdata", "iges" to "model/iges", + "igl" to "application/vnd.igloader", + "igm" to "application/vnd.insors.igm", + "ign" to "application/vnd.coreos.ignition+json", + "ignition" to "application/vnd.coreos.ignition+json", "igs" to "model/iges", + "igx" to "application/vnd.micrografx.igx", + "iif" to "application/vnd.shana.informed.interchange", "iii" to "application/x-iphone", + "imf" to "application/vnd.imagemeter.folder+zip", + "imgcal" to "application/vnd.3lightssoftware.imagescal", + "imi" to "application/vnd.imagemeter.image+zip", + "imp" to "application/vnd.accpac.simply.imp", + "ims" to "application/vnd.ms-ims", + "imscc" to "application/vnd.ims.imsccv1p1", "imy" to "audio/imelody", "info" to "application/x-info", + "ink" to "application/inkml+xml", + "inkml" to "application/inkml+xml", "inp" to "chemical/x-gamess-input", "ins" to "application/x-internet-signup", + "iota" to "application/vnd.astraea-software.iota", + "ipfix" to "application/ipfix", + "ipk" to "application/vnd.shana.informed.package", + "ipns-record" to "application/vnd.ipfs.ipns-record", + "irm" to "application/vnd.ibm.rights-management", + "irp" to "application/vnd.irepository.package+xml", + "ism" to "model/vnd.gdl", "iso" to "application/x-iso9660-image", "isp" to "application/x-internet-signup", "ist" to "chemical/x-isostar", + "istc" to "application/vnd.veryant.thin", "istr" to "chemical/x-isostar", + "isws" to "application/vnd.veryant.thin", + "itp" to "application/vnd.shana.informed.formtemplate", + "its" to "application/its+xml", + "ivp" to "application/vnd.immervision-ivp", + "ivu" to "application/vnd.immervision-ivu", + "j2c" to "image/j2c", + "j2k" to "image/j2c", "jad" to "text/vnd.sun.j2me.app-descriptor", - "jam" to "application/x-jam", + "jam" to "application/vnd.jam", "jar" to "application/java-archive", "java" to "text/x-java", "jdx" to "chemical/x-jcamp-dx", + "jfif" to "image/jpeg", + "jhc" to "image/jphc", + "jisp" to "application/vnd.jisp", + "jls" to "image/jls", + "jlt" to "application/vnd.hp-jlyt", "jmz" to "application/x-jmol", "jng" to "image/x-jng", "jnlp" to "application/x-java-jnlp-file", + "joda" to "application/vnd.joost.joda-archive", "jp2" to "image/jp2", "jpe" to "image/jpeg", "jpeg" to "image/jpeg", "jpf" to "image/jpx", "jpg" to "image/jpeg", "jpg2" to "image/jp2", + "jpgm" to "image/jpm", + "jph" to "image/jph", + "jphc" to "image/jphc", "jpm" to "image/jpm", "jpx" to "image/jpx", - "js" to "application/javascript", + "jrd" to "application/jrd+json", + "js" to "text/javascript", "json" to "application/json", + "json-patch" to "application/json-patch+json", "jsonld" to "application/ld+json", - "kar" to "audio/midi", + "jsontd" to "application/td+json", + "jsontm" to "application/tm+json", + "jt" to "model/JT", + "jtd" to "text/vnd.esmertec.theme-descriptor", + "jxl" to "image/jxl", + "jxr" to "image/jxr", + "jxra" to "image/jxrA", + "jxrs" to "image/jxrS", + "jxs" to "image/jxs", + "jxsc" to "image/jxsc", + "jxsi" to "image/jxsi", + "jxss" to "image/jxss", + "karbon" to "application/vnd.kde.karbon", + "kcm" to "application/vnd.nervana", "key" to "application/pgp-keys", + "keynote" to "application/vnd.apple.keynote", + "kfo" to "application/vnd.kde.kformula", + "kia" to "application/vnd.kidspiration", "kil" to "application/x-killustrator", "kin" to "chemical/x-kinemage", "kml" to "application/vnd.google-earth.kml+xml", "kmz" to "application/vnd.google-earth.kmz", - "kpr" to "application/x-kpresenter", - "kpt" to "application/x-kpresenter", - "ksp" to "application/x-kspread", - "kwd" to "application/x-kword", - "kwt" to "application/x-kword", + "kne" to "application/vnd.Kinar", + "knp" to "application/vnd.Kinar", + "kom" to "application/vnd.hbci", + "kon" to "application/vnd.kde.kontour", + "koz" to "audio/vnd.audiokoz", + "kpr" to "application/vnd.kde.kpresenter", + "kpt" to "application/vnd.kde.kpresenter", + "ksp" to "application/vnd.kde.kspread", + "ktr" to "application/vnd.kahootz", + "ktx" to "image/ktx", + "ktx2" to "image/ktx2", + "ktz" to "application/vnd.kahootz", + "kwd" to "application/vnd.kde.kword", + "kwt" to "application/vnd.kde.kword", + "l16" to "audio/L16", + "las" to "application/vnd.las", + "lasjson" to "application/vnd.las.las+json", + "lasxml" to "application/vnd.las.las+xml", "latex" to "application/x-latex", + "lbc" to "audio/iLBC", + "lbd" to "application/vnd.llamagraphics.life-balance.desktop", + "lbe" to "application/vnd.llamagraphics.life-balance.exchange+xml", + "lca" to "application/vnd.logipipe.circuit+zip", + "lcs" to "application/vnd.logipipe.circuit+zip", + "le" to "application/vnd.bluetooth.le.oob", + "les" to "application/vnd.hhe.lesson-player", + "lgr" to "application/lgr+xml", "lha" to "application/x-lha", "lhs" to "text/x-literate-haskell", + "lhzd" to "application/vnd.belightsoft.lhzd+zip", + "lhzl" to "application/vnd.belightsoft.lhzl+zip", "lin" to "application/bbolin", + "line" to "application/vnd.nebumind.line", + "link66" to "application/vnd.route66.link66+xml", + "list3820" to "application/vnd.afpc.modca", + "listafp" to "application/vnd.afpc.modca", + "lmp" to "model/vnd.gdl", + "loas" to "audio/usac", + "loom" to "application/vnd.loom", + "lostsyncxml" to "application/lostsync+xml", + "lostxml" to "application/lost+xml", + "lpf" to "application/lpf+zip", "lrc" to "application/lrc", + "lrm" to "application/vnd.ms-lrm", "lsf" to "video/x-la-asf", "lsx" to "video/x-la-asf", "ltx" to "text/x-tex", + "lvp" to "audio/vnd.lucent.voice", + "lwp" to "application/vnd.lotus-wordpro", + "lxf" to "application/LXF", "ly" to "text/x-lilypond", "lyx" to "application/x-lyx", "lzh" to "application/x-lzh", "lzx" to "application/x-lzx", + "m" to "application/vnd.wolfram.mathematica.package", "m1v" to "video/mpeg", + "m21" to "application/mp21", "m2t" to "video/mpeg", "m2ts" to "video/mp2t", "m2v" to "video/mpeg", @@ -281,34 +702,69 @@ private val extensionToMimeTypeMap = mapOf( "m4b" to "audio/mp4", "m4p" to "audio/mp4", "m4r" to "audio/mpeg", + "m4s" to "video/iso.segment", + "m4u" to "video/vnd.mpegurl", "m4v" to "video/mp4", + "ma" to "application/mathematica", + "mads" to "application/mads+xml", + "maei" to "application/mmt-aei+xml", + "mag" to "application/vnd.ecowin.chart", + "mail" to "message/rfc822", "maker" to "application/x-maker", "man" to "application/x-troff-man", - "manifest" to "application/x-ms-manifest", + "manifest" to "text/cache-manifest", "markdown" to "text/markdown", + "mb" to "application/mathematica", + "mbk" to "application/vnd.Mobius.MBK", "mbox" to "application/mbox", + "mbsdf" to "application/vnd.mdl-mbsdf", + "mc1" to "application/vnd.medcalcdata", + "mc2" to "text/vnd.senx.warpscript", + "mcd" to "application/vnd.mcd", "mcif" to "chemical/x-mmcif", "mcm" to "chemical/x-macmolecule", "md" to "text/markdown", "mdb" to "application/msaccess", + "mdc" to "application/vnd.marlin.drm.mdcf", + "mdi" to "image/vnd.ms-modi", + "mdl" to "application/vnd.mdl", "me" to "application/x-troff-me", "mesh" to "model/mesh", - "mid" to "audio/midi", - "midi" to "audio/midi", - "mif" to "application/x-mif", - "mjs" to "application/javascript", + "meta4" to "application/metalink4+xml", + "mets" to "application/mets+xml", + "mf4" to "application/MF4", + "mfm" to "application/vnd.mfmp", + "mft" to "application/rpki-manifest", + "mgp" to "application/vnd.osgeo.mapguide.package", + "mgz" to "application/vnd.proteus.magazine", + "mhas" to "audio/mhas", + "mid" to "audio/sp-midi", + "mif" to "application/vnd.mif", + "miz" to "text/mizar", + "mj2" to "video/mj2", + "mjp2" to "video/mj2", + "mjs" to "text/javascript", "mka" to "audio/x-matroska", "mkv" to "video/x-matroska", + "ml2" to "application/vnd.sybyl.mol2", + "mlp" to "audio/vnd.dolby.mlp", "mm" to "application/x-freemind", - "mmd" to "chemical/x-macromodel-input", + "mmd" to "application/vnd.chipnuts.karaoke-mmd", + "mmdb" to "application/vnd.maxmind.maxmind-db", "mmf" to "application/vnd.smaf", - "mml" to "text/mathml", + "mml" to "application/mathml+xml", "mmod" to "chemical/x-macromodel-input", + "mmr" to "image/vnd.fujixerox.edmics-mmr", "mng" to "video/x-mng", "mobi" to "application/x-mobipocket-ebook", "moc" to "text/x-moc", + "mod" to "application/xml-dtd", + "model-inter" to "application/vnd.vd-study", + "modl" to "application/vnd.modl", + "mods" to "application/mods+xml", "mol" to "chemical/x-mdl-molfile", - "mol2" to "chemical/x-mol2", + "mol2" to "application/vnd.sybyl.mol2", + "moml" to "model/vnd.moml+xml", "moo" to "chemical/x-mopac-out", "mop" to "chemical/x-mopac-input", "mopcrt" to "chemical/x-mopac-input", @@ -317,44 +773,111 @@ private val extensionToMimeTypeMap = mapOf( "mp1" to "audio/mpeg", "mp1v" to "video/mpeg", "mp2" to "audio/mpeg", + "mp21" to "application/mp21", "mp2v" to "video/mpeg", "mp3" to "audio/mpeg", "mp4" to "video/mp4", "mp4v" to "video/mp4", "mpa" to "audio/mpeg", "mpc" to "chemical/x-mopac-input", + "mpd" to "application/dash+xml", + "mpdd" to "application/dashdelta", "mpe" to "video/mpeg", "mpeg" to "video/mpeg", "mpeg1" to "video/mpeg", "mpeg2" to "video/mpeg", "mpeg4" to "video/mp4", "mpega" to "audio/mpeg", + "mpf" to "text/vnd.ms-mediapackage", "mpg" to "video/mpeg", + "mpg4" to "video/mp4", "mpga" to "audio/mpeg", "mph" to "application/x-comsol", + "mpkg" to "application/vnd.apple.installer+xml", + "mpm" to "application/vnd.blueice.multipass", + "mpn" to "application/vnd.mophun.application", + "mpp" to "application/vnd.ms-project", + "mpt" to "application/vnd.ms-project", "mpv" to "video/x-matroska", "mpv1" to "video/mpeg", "mpv2" to "video/mpeg", + "mpw" to "application/vnd.exstream-empower+zip", + "mpy" to "application/vnd.ibm.MiniPay", + "mqy" to "application/vnd.Mobius.MQY", + "mrc" to "application/marc", + "mrcx" to "application/marcxml+xml", "ms" to "application/x-troff-ms", + "msa" to "application/vnd.msa-disk-image", + "msd" to "application/vnd.fdsn.mseed", + "mseed" to "application/vnd.fdsn.mseed", + "mseq" to "application/vnd.mseq", + "msf" to "application/vnd.epson.msf", "msh" to "model/mesh", "msi" to "application/x-msi", + "msl" to "application/vnd.Mobius.MSL", + "msm" to "model/vnd.gdl", "msp" to "application/octet-stream", + "msty" to "application/vnd.muvee.style", "msu" to "application/octet-stream", + "mtl" to "model/mtl", "mts" to "video/mp2t", + "multitrack" to "audio/vnd.presonus.multitrack", + "mus" to "application/vnd.musician", + "musd" to "application/mmt-usd+xml", "mvb" to "chemical/x-mopac-vib", + "mvt" to "application/vnd.mapbox-vector-tile", + "mwc" to "application/vnd.dpgraph", + "mwf" to "application/vnd.MFER", "mxf" to "application/mxf", + "mxi" to "application/vnd.vd-study", + "mxl" to "application/vnd.recordare.musicxml", "mxmf" to "audio/mobile-xmf", + "mxml" to "application/xv+xml", + "mxs" to "application/vnd.triscape.mxs", "mxu" to "video/vnd.mpegurl", - "nb" to "application/mathematica", - "nbp" to "application/mathematica", + "n3" to "text/n3", + "nb" to "application/vnd.wolfram.mathematica", + "nbp" to "application/vnd.wolfram.player", "nc" to "application/x-netcdf", + "ndc" to "application/vnd.osa.netdeploy", + "ndl" to "application/vnd.lotus-notes", + "nds" to "application/vnd.nintendo.nitro.rom", + "nebul" to "application/vnd.nebumind.line", "nef" to "image/x-nikon-nef", + "ngdat" to "application/vnd.nokia.n-gage.data", + "nim" to "video/vnd.nokia.interleaved-multimedia", + "nimn" to "application/vnd.nimn", + "nitf" to "application/vnd.nitf", + "nlu" to "application/vnd.neurolanguage.nlu", + "nml" to "application/vnd.enliven", + "nnd" to "application/vnd.noblenet-directory", + "nns" to "application/vnd.noblenet-sealer", + "nnw" to "application/vnd.noblenet-web", + "notebook" to "application/vnd.smart.notebook", + "nq" to "application/n-quads", "nrw" to "image/x-nikon-nrw", + "ns2" to "application/vnd.lotus-notes", + "ns3" to "application/vnd.lotus-notes", + "ns4" to "application/vnd.lotus-notes", + "nsf" to "application/vnd.lotus-notes", + "nsg" to "application/vnd.lotus-notes", + "nsh" to "application/vnd.lotus-notes", + "nt" to "application/n-triples", + "ntf" to "application/vnd.lotus-notes", + "numbers" to "application/vnd.apple.numbers", "nwc" to "application/x-nwc", "o" to "application/x-object", - "oda" to "application/oda", - "odb" to "application/vnd.oasis.opendocument.database", + "oa2" to "application/vnd.fujitsu.oasys2", + "oa3" to "application/vnd.fujitsu.oasys3", + "oas" to "application/vnd.fujitsu.oasys", + "ob" to "application/vnd.1ob", + "obg" to "application/vnd.openblox.game-binary", + "obgx" to "application/vnd.openblox.game+xml", + "obj" to "model/obj", + "oda" to "application/ODA", + "odb" to "application/vnd.oasis.opendocument.base", "odc" to "application/vnd.oasis.opendocument.chart", + "odd" to "application/tei+xml", "odf" to "application/vnd.oasis.opendocument.formula", "odg" to "application/vnd.oasis.opendocument.graphics", "odi" to "application/vnd.oasis.opendocument.image", @@ -362,225 +885,611 @@ private val extensionToMimeTypeMap = mapOf( "odp" to "application/vnd.oasis.opendocument.presentation", "ods" to "application/vnd.oasis.opendocument.spreadsheet", "odt" to "application/vnd.oasis.opendocument.text", + "odx" to "application/ODX", + "oeb" to "application/vnd.openeye.oeb", "oga" to "audio/ogg", + "ogex" to "model/vnd.opengex", "ogg" to "audio/ogg", "ogv" to "video/ogg", "ogx" to "application/ogg", "old" to "application/x-trash", + "omg" to "audio/ATRAC3", "one" to "application/onenote", "onepkg" to "application/onenote", "onetmp" to "application/onenote", "onetoc2" to "application/onenote", "opf" to "application/oebps-package+xml", + "oprc" to "application/vnd.palm", "opus" to "audio/ogg", + "or2" to "application/vnd.lotus-organizer", + "or3" to "application/vnd.lotus-organizer", "orc" to "audio/csound", "orf" to "image/x-olympus-orf", + "org" to "application/vnd.lotus-organizer", + "orq" to "application/ocsp-request", + "ors" to "application/ocsp-response", + "osf" to "application/vnd.yamaha.openscoreformat", + "osm" to "application/vnd.openstreetmap.data+xml", "ota" to "application/vnd.android.ota", - "otf" to "font/ttf", + "otc" to "application/vnd.oasis.opendocument.chart-template", + "otf" to "font/otf", "otg" to "application/vnd.oasis.opendocument.graphics-template", "oth" to "application/vnd.oasis.opendocument.text-web", + "oti" to "application/vnd.oasis.opendocument.image-template", + "otm" to "application/vnd.oasis.opendocument.text-master-template", "otp" to "application/vnd.oasis.opendocument.presentation-template", "ots" to "application/vnd.oasis.opendocument.spreadsheet-template", "ott" to "application/vnd.oasis.opendocument.text-template", + "ovl" to "application/vnd.afpc.modca-overlay", + "oxlicg" to "application/vnd.oxli.countgraph", + "oxps" to "application/oxps", + "oxt" to "application/vnd.openofficeorg.extension", "oza" to "application/x-oz-application", "p" to "text/x-pascal", + "p10" to "application/pkcs10", "p12" to "application/x-pkcs12", + "p21" to "application/p21", + "p2p" to "application/vnd.wfa.p2p", + "p7c" to "application/pkcs7-mime", + "p7m" to "application/pkcs7-mime", "p7r" to "application/x-pkcs7-certreqresp", + "p7s" to "application/pkcs7-signature", + "p7z" to "application/pkcs7-mime", + "p8" to "application/pkcs8", + "p8e" to "application/pkcs8-encrypted", "pac" to "application/x-ns-proxy-autoconfig", + "package" to "application/vnd.autopackage", + "pages" to "application/vnd.apple.pages", "pas" to "text/x-pascal", "pat" to "image/x-coreldrawpattern", "patch" to "text/x-diff", + "paw" to "application/vnd.pawaafile", + "pbd" to "application/vnd.powerbuilder6", "pbm" to "image/x-portable-bitmap", "pcap" to "application/vnd.tcpdump.pcap", "pcf" to "application/x-font", "pcf.Z" to "application/x-font-pcf", - "pcx" to "image/pcx", + "pcl" to "application/vnd.hp-PCL", + "pcx" to "image/vnd.zbrush.pcx", "pdb" to "chemical/x-pdb", "pdf" to "application/pdf", + "pdx" to "application/PDX", "pef" to "image/x-pentax-pef", "pem" to "application/x-pem-file", "pfa" to "application/x-font", "pfb" to "application/x-font", "pfr" to "application/font-tdpfr", "pfx" to "application/x-pkcs12", + "pgb" to "image/vnd.globalgraphics.pgb", "pgm" to "image/x-portable-graymap", - "pgn" to "application/x-chess-pgn", + "pgn" to "application/vnd.chess-pgn", "pgp" to "application/pgp-signature", "phps" to "text/text", + "pil" to "application/vnd.piaccess.application-licence", "pk" to "application/x-tex-pk", + "pkd" to "application/vnd.hbci", + "pkg" to "application/vnd.apple.installer+xml", + "pki" to "application/pkixcmp", + "pkipath" to "application/pkix-pkipath", + "pkpass" to "application/vnd.apple.pkpass", + "pkpasses" to "application/vnd.apple.pkpasses", "pl" to "text/x-perl", + "plb" to "application/vnd.3gpp.pic-bw-large", + "plc" to "application/vnd.Mobius.PLC", + "plf" to "application/vnd.pocketlearn", + "plj" to "audio/vnd.everad.plj", + "plp" to "application/vnd.panoply", "pls" to "audio/x-scpls", "pm" to "text/x-perl", + "pml" to "application/vnd.ctc-posml", "png" to "image/png", "pnm" to "image/x-portable-anymap", "po" to "text/plain", + "portpkg" to "application/vnd.macports.portpkg", "pot" to "application/vnd.ms-powerpoint", "potm" to "application/vnd.ms-powerpoint.template.macroEnabled.12", "potx" to "application/vnd.openxmlformats-officedocument.presentationml.template", "ppam" to "application/vnd.ms-powerpoint.addin.macroEnabled.12", + "ppd" to "application/vnd.cups-ppd", + "ppkg" to "application/vnd.xmpie.ppkg", "ppm" to "image/x-portable-pixmap", "pps" to "application/vnd.ms-powerpoint", "ppsm" to "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", "ppsx" to "application/vnd.openxmlformats-officedocument.presentationml.slideshow", "ppt" to "application/vnd.ms-powerpoint", "pptm" to "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + "ppttc" to "application/vnd.think-cell.ppttc+json", "pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "pqa" to "application/vnd.palm", "prc" to "application/x-mobipocket-ebook", + "pre" to "application/vnd.lotus-freelance", + "preminet" to "application/vnd.preminet", "prf" to "application/pics-rules", + "provn" to "text/provenance-notation", + "provx" to "application/provenance+xml", "prt" to "chemical/x-ncbi-asn1-ascii", + "prz" to "application/vnd.lotus-freelance", "ps" to "application/postscript", + "psb" to "application/vnd.3gpp.pic-bw-small", "psd" to "image/x-photoshop", + "pseg3820" to "application/vnd.afpc.modca", + "psfs" to "application/vnd.psfs", + "psg" to "application/vnd.afpc.modca-pagesegment", + "psid" to "audio/prs.sid", + "pskcxml" to "application/pskc+xml", + "pt" to "application/vnd.snesdev-page-table", + "pti" to "image/prs.pti", + "ptid" to "application/vnd.pvi.ptid1", + "ptrom" to "application/vnd.snesdev-page-table", + "pub" to "application/vnd.exstream-package", + "pvb" to "application/vnd.3gpp.pic-bw-var", + "pwn" to "application/vnd.3M.Post-it-Notes", "py" to "text/x-python", + "pya" to "audio/vnd.ms-playready.media.pya", "pyc" to "application/x-python-code", "pyo" to "application/x-python-code", + "pyox" to "model/vnd.pytha.pyox", + "pyv" to "video/vnd.ms-playready.media.pyv", + "qam" to "application/vnd.epson.quickanime", + "qbo" to "application/vnd.intu.qbo", + "qca" to "application/vnd.ericsson.quickcall", + "qcall" to "application/vnd.ericsson.quickcall", + "qcp" to "audio/EVRC-QCP", + "qfx" to "application/vnd.intu.qfx", "qgs" to "application/x-qgis", + "qps" to "application/vnd.publishare-delta-tree", "qt" to "video/quicktime", "qtl" to "application/x-quicktimeplayer", + "quiz" to "application/vnd.quobject-quoxdocument", + "quox" to "application/vnd.quobject-quoxdocument", + "qvd" to "application/vnd.theqvd", + "qwd" to "application/vnd.Quark.QuarkXPress", + "qwt" to "application/vnd.Quark.QuarkXPress", + "qxb" to "application/vnd.Quark.QuarkXPress", + "qxd" to "application/vnd.Quark.QuarkXPress", + "qxl" to "application/vnd.Quark.QuarkXPress", + "qxt" to "application/vnd.Quark.QuarkXPress", "ra" to "audio/x-pn-realaudio", "raf" to "image/x-fuji-raf", "ram" to "audio/x-pn-realaudio", - "rar" to "application/rar", + "rapd" to "application/route-apd+xml", + "rar" to "application/vnd.rar", "ras" to "image/x-cmu-raster", "rb" to "application/x-ruby", + "rcprofile" to "application/vnd.ipunplugged.rcprofile", + "rct" to "application/prs.nprend", "rd" to "chemical/x-mdl-rdfile", "rdf" to "application/rdf+xml", + "rdf-crypt" to "application/prs.rdf-xml-crypt", "rdp" to "application/x-rdp", + "rdz" to "application/vnd.data-vision.rdz", + "relo" to "application/p2p-overlay+xml", + "reload" to "application/vnd.resilient.logic", + "rep" to "application/vnd.businessobjects", + "request" to "application/vnd.nervana", + "rfcxml" to "application/rfc+xml", "rgb" to "image/x-rgb", + "rgbe" to "image/vnd.radiance", + "rif" to "application/reginfo+xml", + "rip" to "audio/vnd.rip", + "rl" to "application/resource-lists+xml", + "rlc" to "image/vnd.fujixerox.edmics-rlc", + "rld" to "application/resource-lists-diff+xml", + "rlm" to "application/vnd.resilient.logic", "rm" to "audio/x-pn-realaudio", - "roff" to "application/x-troff", + "rms" to "application/vnd.jcp.javame.midlet-rms", + "rnc" to "application/relax-ng-compact-syntax", + "rnd" to "application/prs.nprend", + "roa" to "application/rpki-roa", + "roff" to "text/troff", "ros" to "chemical/x-rosdal", + "rp9" to "application/vnd.cloanto.rp9", "rpm" to "application/x-redhat-package-manager", + "rpss" to "application/vnd.nokia.radio-presets", + "rpst" to "application/vnd.nokia.radio-preset", + "rq" to "application/sparql-query", + "rs" to "application/rls-services+xml", + "rsat" to "application/atsc-rsat+xml", + "rsheet" to "application/urc-ressheet+xml", + "rsm" to "model/vnd.gdl", "rss" to "application/rss+xml", + "rst" to "text/prs.fallenstein.rst", "rtf" to "text/rtf", "rtttl" to "audio/midi", "rtx" to "audio/midi", + "rusd" to "application/route-usd+xml", "rw2" to "image/x-panasonic-rw2", "rxn" to "chemical/x-mdl-rxnfile", + "rxt" to "application/vnd.medicalholodeck.recordxr", + "s11" to "video/vnd.sealed.mpeg1", + "s14" to "video/vnd.sealed.mpeg4", + "s1a" to "application/vnd.sealedmedia.softseal.pdf", + "s1e" to "application/vnd.sealed.xls", + "s1g" to "image/vnd.sealedmedia.softseal.gif", + "s1h" to "application/vnd.sealedmedia.softseal.html", + "s1j" to "image/vnd.sealedmedia.softseal.jpg", + "s1m" to "audio/vnd.sealedmedia.softseal.mpeg", + "s1n" to "image/vnd.sealed.png", + "s1p" to "application/vnd.sealed.ppt", + "s1q" to "video/vnd.sealedmedia.softseal.mov", + "s1w" to "application/vnd.sealed.doc", + "s3df" to "application/vnd.sealed.3df", + "sac" to "application/tamp-sequence-adjust-confirm", + "saf" to "application/vnd.yamaha.smaf-audio", + "sam" to "application/vnd.lotus-wordpro", + "sarif" to "application/sarif+json", + "sarif-external-properties" to "application/sarif-external-properties+json", + "sarif-external-properties.json" to "application/sarif-external-properties+json", + "sarif.json" to "application/sarif+json", + "sc" to "application/vnd.ibm.secure-container", "scala" to "text/x-scala", + "scd" to "application/vnd.scribus", "sce" to "application/x-scilab", "sci" to "application/x-scilab", + "scim" to "application/scim+json", + "scl" to "application/vnd.sycle+xml", + "scld" to "application/vnd.doremir.scorecloud-binary-document", + "scm" to "application/vnd.lotus-screencam", "sco" to "audio/csound", + "scq" to "application/scvp-cv-request", "scr" to "application/x-silverlight", - "sct" to "text/scriptlet", + "scs" to "application/scvp-cv-response", + "scsf" to "application/vnd.sealed.csf", "sd" to "chemical/x-mdl-sdfile", "sd2" to "audio/x-sd2", "sda" to "application/vnd.stardivision.draw", "sdc" to "application/vnd.stardivision.calc", "sdd" to "application/vnd.stardivision.impress", "sdf" to "chemical/x-mdl-sdfile", + "sdkd" to "application/vnd.solent.sdkm+xml", + "sdkm" to "application/vnd.solent.sdkm+xml", + "sdo" to "application/vnd.sealed.doc", + "sdoc" to "application/vnd.sealed.doc", "sdp" to "application/vnd.stardivision.impress", "sds" to "application/vnd.stardivision.chart", "sdw" to "application/vnd.stardivision.writer", + "see" to "application/vnd.seemail", + "seed" to "application/vnd.fdsn.seed", + "sem" to "application/vnd.sealed.eml", + "sema" to "application/vnd.sema", + "semd" to "application/vnd.semd", + "semf" to "application/vnd.semf", + "seml" to "application/vnd.sealed.eml", + "senml" to "application/senml+json", + "senml-etchc" to "application/senml-etch+cbor", + "senml-etchj" to "application/senml-etch+json", + "senmlc" to "application/senml+cbor", + "senmle" to "application/senml-exi", + "senmlx" to "application/senml+xml", + "sensml" to "application/sensml+json", + "sensmlc" to "application/sensml+cbor", + "sensmle" to "application/sensml-exi", + "sensmlx" to "application/sensml+xml", "ser" to "application/java-serialized-object", + "sfc" to "application/vnd.nintendo.snes.rom", "sfd" to "application/vnd.font-fontforge-sfd", + "sfd-hdstx" to "application/vnd.hydrostatix.sof-data", + "sfs" to "application/vnd.spotfire.sfs", "sfv" to "text/x-sfv", "sgf" to "application/x-go-sgf", + "sgi" to "image/vnd.sealedmedia.softseal.gif", + "sgif" to "image/vnd.sealedmedia.softseal.gif", "sgl" to "application/vnd.stardivision.writer-global", + "sgm" to "text/SGML", + "sgml" to "text/SGML", "sh" to "text/x-sh", + "shaclc" to "text/shaclc", "shar" to "application/x-shar", + "shc" to "text/shaclc", + "shex" to "text/shex", + "shf" to "application/shf+xml", "shp" to "application/x-qgis", "shtml" to "text/html", "shx" to "application/x-qgis", + "si" to "text/vnd.wap.si", + "sic" to "application/vnd.wap.sic", "sid" to "audio/prs.sid", + "sieve" to "application/sieve", "sig" to "application/pgp-signature", "sik" to "application/x-trash", "silo" to "model/mesh", + "sipa" to "application/vnd.smintio.portals.archive", "sis" to "application/vnd.symbian.install", - "sisx" to "x-epoc/x-sisx-app", "sit" to "application/x-stuffit", "sitx" to "application/x-stuffit", - "skd" to "application/x-koan", - "skm" to "application/x-koan", - "skp" to "application/x-koan", - "skt" to "application/x-koan", + "siv" to "application/sieve", + "sjp" to "image/vnd.sealedmedia.softseal.jpg", + "sjpg" to "image/vnd.sealedmedia.softseal.jpg", + "skd" to "application/vnd.koan", + "skm" to "application/vnd.koan", + "skp" to "application/vnd.koan", + "skt" to "application/vnd.koan", + "sl" to "text/vnd.wap.sl", + "sla" to "application/vnd.scribus", + "slaz" to "application/vnd.scribus", + "slc" to "application/vnd.wap.slc", "sldm" to "application/vnd.ms-powerpoint.slide.macroEnabled.12", "sldx" to "application/vnd.openxmlformats-officedocument.presentationml.slide", + "sls" to "application/route-s-tsid+xml", + "slt" to "application/vnd.epson.salt", + "sm" to "application/vnd.stepmania.stepchart", + "smc" to "application/vnd.nintendo.snes.rom", "smf" to "audio/sp-midi", + "smh" to "application/vnd.sealed.mht", + "smht" to "application/vnd.sealed.mht", "smi" to "application/smil+xml", "smil" to "application/smil+xml", + "smk" to "video/vnd.radgamettools.smacker", + "sml" to "application/smil+xml", + "smo" to "video/vnd.sealedmedia.softseal.mov", + "smov" to "video/vnd.sealedmedia.softseal.mov", + "smp" to "audio/vnd.sealedmedia.softseal.mpeg", + "smp3" to "audio/vnd.sealedmedia.softseal.mpeg", + "smpg" to "video/vnd.sealed.mpeg1", + "sms" to "application/vnd.3gpp2.sms", + "smv" to "audio/SMV", + "smzip" to "application/vnd.stepmania.package", "snd" to "audio/basic", + "soa" to "text/dns", + "soc" to "application/sgml-open-catalog", + "sofa" to "audio/sofa", + "sos" to "text/vnd.sosi", "spc" to "chemical/x-galactic-spc", - "spl" to "application/x-futuresplash", + "spd" to "application/vnd.sealedmedia.softseal.pdf", + "spdf" to "application/vnd.sealedmedia.softseal.pdf", + "spdx" to "text/spdx", + "spdx.json" to "application/spdx+json", + "spf" to "application/vnd.yamaha.smaf-phrase", + "spl" to "application/futuresplash", + "spn" to "image/vnd.sealed.png", + "spng" to "image/vnd.sealed.png", + "spo" to "text/vnd.in3d.spot", + "spot" to "text/vnd.in3d.spot", + "spp" to "application/scvp-vp-response", + "sppt" to "application/vnd.sealed.ppt", + "spq" to "application/scvp-vp-request", "spx" to "audio/ogg", - "sql" to "application/x-sql", + "sql" to "application/sql", + "sqlite" to "application/vnd.sqlite3", + "sqlite3" to "application/vnd.sqlite3", + "sr" to "application/vnd.sigrok.session", "src" to "application/x-wais-source", "srt" to "application/x-subrip", + "sru" to "application/sru+xml", "srw" to "image/x-samsung-srw", + "srx" to "application/sparql-results+xml", + "sse" to "application/vnd.kodak-descriptor", + "ssf" to "application/vnd.epson.ssf", + "ssml" to "application/ssml+xml", + "ssv" to "application/vnd.shade-save-file", + "ssvc" to "application/vnd.crypto-shade-file", + "ssw" to "video/vnd.sealed.swf", + "sswf" to "video/vnd.sealed.swf", + "st" to "application/vnd.sailingtracker.track", "stc" to "application/vnd.sun.xml.calc.template", "std" to "application/vnd.sun.xml.draw.template", + "step" to "model/step", + "stf" to "application/vnd.wt.stf", "sti" to "application/vnd.sun.xml.impress.template", + "stif" to "application/vnd.sealed.tiff", + "stix" to "application/stix+json", + "stk" to "application/hyperstudio", "stl" to "application/vnd.ms-pki.stl", + "stml" to "application/vnd.sealedmedia.softseal.html", + "stp" to "model/step", + "stpnc" to "application/p21", + "stpx" to "model/step+xml", + "stpxz" to "model/step-xml+zip", + "stpz" to "model/step+zip", + "str" to "application/vnd.pg.format", + "study-inter" to "application/vnd.vd-study", "stw" to "application/vnd.sun.xml.writer.template", "sty" to "text/x-tex", + "sus" to "application/vnd.sus-calendar", + "susp" to "application/vnd.sus-calendar", "sv4cpio" to "application/x-sv4cpio", "sv4crc" to "application/x-sv4crc", + "svc" to "application/vnd.dvb.service", "svg" to "image/svg+xml", "svgz" to "image/svg+xml", "sw" to "chemical/x-swissprot", - "swf" to "application/x-shockwave-flash", - "swfl" to "application/x-shockwave-flash", + "swf" to "application/vnd.adobe.flash.movie", + "swi" to "application/vnd.aristanetworks.swi", + "swidtag" to "application/swid+xml", "sxc" to "application/vnd.sun.xml.calc", "sxd" to "application/vnd.sun.xml.draw", "sxg" to "application/vnd.sun.xml.writer.global", "sxi" to "application/vnd.sun.xml.impress", + "sxl" to "application/vnd.sealed.xls", + "sxls" to "application/vnd.sealed.xls", "sxm" to "application/vnd.sun.xml.math", "sxw" to "application/vnd.sun.xml.writer", - "t" to "application/x-troff", + "sy2" to "application/vnd.sybyl.mol2", + "syft.json" to "application/vnd.syft+json", + "t" to "text/troff", + "tag" to "text/prs.lines.tag", + "taglet" to "application/vnd.mynfc", + "tam" to "application/vnd.onepager", + "tamp" to "application/vnd.onepagertamp", + "tamx" to "application/vnd.onepagertamx", + "tao" to "application/vnd.tao.intent-module-archive", + "tap" to "image/vnd.tencent.tap", "tar" to "application/x-tar", + "tat" to "application/vnd.onepagertat", + "tatp" to "application/vnd.onepagertatp", + "tatx" to "application/vnd.onepagertatx", + "tau" to "application/tamp-apex-update", "taz" to "application/x-gtar-compressed", + "tcap" to "application/vnd.3gpp2.tcap", "tcl" to "text/x-tcl", + "tcu" to "application/tamp-community-update", + "td" to "application/urc-targetdesc+xml", + "teacher" to "application/vnd.smart.teacher", + "tei" to "application/tei+xml", + "teiCorpus" to "application/tei+xml", + "ter" to "application/tamp-error", "tex" to "text/x-tex", "texi" to "application/x-texinfo", "texinfo" to "application/x-texinfo", "text" to "text/plain", + "tfi" to "application/thraud+xml", + "tfx" to "image/tiff-fx", "tgf" to "chemical/x-mdl-tgf", "tgz" to "application/x-gtar-compressed", "thmx" to "application/vnd.ms-officetheme", "tif" to "image/tiff", "tiff" to "image/tiff", "tk" to "text/x-tcl", + "tlclient" to "application/vnd.cendio.thinlinc.clientconf", "tm" to "text/texmacs", + "tm.json" to "application/tm+json", + "tm.jsonld" to "application/tm+json", + "tmo" to "application/vnd.tmobile-livetv", + "tnef" to "application/vnd.ms-tnef", + "tnf" to "application/vnd.ms-tnef", "torrent" to "application/x-bittorrent", - "tr" to "application/x-troff", + "tpl" to "application/vnd.groove-tool-template", + "tpt" to "application/vnd.trid.tpt", + "tr" to "text/troff", + "tra" to "application/vnd.trueapp", + "tree" to "application/vnd.rainstor.data", + "trig" to "application/trig", "ts" to "video/mp2ts", + "tsa" to "application/tamp-sequence-adjust", + "tsd" to "application/timestamped-data", "tsp" to "application/dsptype", + "tsq" to "application/timestamp-query", + "tsr" to "application/timestamp-reply", + "tst" to "application/vnd.etsi.timestamp-token", "tsv" to "text/tab-separated-values", "ttc" to "font/collection", "ttf" to "font/ttf", "ttl" to "text/turtle", "ttml" to "application/ttml+xml", + "tuc" to "application/tamp-update-confirm", + "tur" to "application/tamp-update", + "twd" to "application/vnd.SimTech-MindMapper", + "twds" to "application/vnd.SimTech-MindMapper", + "txd" to "application/vnd.genomatix.tuxedo", + "txf" to "application/vnd.Mobius.TXF", "txt" to "text/plain", - "udeb" to "application/x-debian-package", - "uls" to "text/iuls", + "u3d" to "model/u3d", + "u8dsn" to "message/global-delivery-status", + "u8hdr" to "message/global-headers", + "u8mdn" to "message/global-disposition-notification", + "u8msg" to "message/global", + "udeb" to "application/vnd.debian.binary-package", + "ufd" to "application/vnd.ufdl", + "ufdl" to "application/vnd.ufdl", + "uis" to "application/urc-uisocketdesc+xml", + "umj" to "application/vnd.umajin", + "unityweb" to "application/vnd.unity", + "uo" to "application/vnd.uoml+xml", + "uoml" to "application/vnd.uoml+xml", + "upa" to "application/vnd.hbci", + "uri" to "text/uri-list", + "urim" to "application/vnd.uri-map", + "urimap" to "application/vnd.uri-map", + "uris" to "text/uri-list", + "usda" to "model/vnd.usda", + "usdz" to "model/vnd.usdz+zip", "ustar" to "application/x-ustar", + "utz" to "application/vnd.uiq.theme", + "uva" to "audio/vnd.dece.audio", + "uvd" to "application/vnd.dece.data", + "uvf" to "application/vnd.dece.data", + "uvg" to "image/vnd.dece.graphic", + "uvh" to "video/vnd.dece.hd", + "uvi" to "image/vnd.dece.graphic", + "uvm" to "video/vnd.dece.mobile", + "uvp" to "video/vnd.dece.pd", + "uvs" to "video/vnd.dece.sd", + "uvt" to "application/vnd.dece.ttml+xml", + "uvu" to "video/vnd.dece.mp4", + "uvv" to "video/vnd.dece.video", + "uvva" to "audio/vnd.dece.audio", + "uvvd" to "application/vnd.dece.data", + "uvvf" to "application/vnd.dece.data", + "uvvg" to "image/vnd.dece.graphic", + "uvvh" to "video/vnd.dece.hd", + "uvvi" to "image/vnd.dece.graphic", + "uvvm" to "video/vnd.dece.mobile", + "uvvp" to "video/vnd.dece.pd", + "uvvs" to "video/vnd.dece.sd", + "uvvt" to "application/vnd.dece.ttml+xml", + "uvvu" to "video/vnd.dece.mp4", + "uvvv" to "video/vnd.dece.video", + "uvvx" to "application/vnd.dece.unspecified", + "uvvz" to "application/vnd.dece.zip", + "uvx" to "application/vnd.dece.unspecified", + "uvz" to "application/vnd.dece.zip", "val" to "chemical/x-ncbi-asn1-binary", + "vbk" to "audio/vnd.nortel.vbk", + "vbox" to "application/vnd.previewsystems.box", "vcard" to "text/vcard", "vcd" to "application/x-cdlink", "vcf" to "text/x-vcard", + "vcg" to "application/vnd.groove-vcard", + "vcj" to "application/voucher-cms+json", "vcs" to "text/x-vcalendar", + "vcx" to "application/vnd.vcx", + "vds" to "model/vnd.sap.vds", + "vew" to "application/vnd.lotus-approach", + "vfr" to "application/vnd.tml", + "viaframe" to "application/vnd.tml", + "vis" to "application/vnd.visionary", + "viv" to "video/vnd.vivo", "vmd" to "chemical/x-vmd", "vms" to "chemical/x-vamas-iso14976", + "vmt" to "application/vnd.valve.source.material", "vor" to "application/vnd.stardivision.writer", - "vrm" to "x-world/x-vrml", - "vrml" to "x-world/x-vrml", + "vpm" to "multipart/voice-message", + "vrm" to "model/vrml", + "vrml" to "model/vrml", + "vsc" to "application/vnd.vidsoft.vidconference", "vsd" to "application/vnd.visio", + "vsf" to "application/vnd.vsf", "vss" to "application/vnd.visio", "vst" to "application/vnd.visio", "vsw" to "application/vnd.visio", + "vtf" to "image/vnd.valve.source.texture", + "vtnstd" to "application/vnd.veritone.aion+json", + "vtt" to "text/vtt", + "vtu" to "model/vnd.vtu", + "vwx" to "application/vnd.vectorworks", + "vxml" to "application/voicexml+xml", "wad" to "application/x-doom", + "wadl" to "application/vnd.sun.wadl+xml", + "wafl" to "application/vnd.wasmflow.wafl", "wasm" to "application/wasm", "wav" to "audio/x-wav", "wax" to "audio/x-ms-wax", "wbmp" to "image/vnd.wap.wbmp", + "wbs" to "application/vnd.criticaltools.wbs+xml", "wbxml" to "application/vnd.wap.wbxml", + "wcm" to "application/vnd.ms-works", + "wdb" to "application/vnd.ms-works", "webarchive" to "application/x-webarchive", "webarchivexml" to "application/x-webarchive-xml", "webm" to "video/webm", + "webmanifest" to "application/manifest+json", "webp" to "image/webp", + "wg" to "application/vnd.pmi.widget", + "wgsl" to "text/wgsl", + "wgt" to "application/widget", + "wif" to "application/watcherinfo+xml", + "win" to "model/vnd.gdl", "wk" to "application/x-123", + "wk1" to "application/vnd.lotus-1-2-3", + "wk3" to "application/vnd.lotus-1-2-3", + "wk4" to "application/vnd.lotus-1-2-3", + "wks" to "application/vnd.ms-works", + "wlnk" to "application/link-format", "wm" to "video/x-ms-wm", "wma" to "audio/x-ms-wma", + "wmc" to "application/vnd.wmc", "wmd" to "application/x-ms-wmd", + "wmf" to "image/wmf", "wml" to "text/vnd.wap.wml", "wmlc" to "application/vnd.wap.wmlc", "wmls" to "text/vnd.wap.wmlscript", @@ -590,24 +1499,58 @@ private val extensionToMimeTypeMap = mapOf( "wmz" to "application/x-ms-wmz", "woff" to "font/woff", "woff2" to "font/woff2", - "wp5" to "application/vnd.wordperfect5.1", "wpd" to "application/vnd.wordperfect", "wpl" to "application/vnd.ms-wpl", + "wps" to "application/vnd.ms-works", + "wqd" to "application/vnd.wqd", "wrf" to "video/x-webex", - "wrl" to "x-world/x-vrml", - "wsc" to "text/scriptlet", + "wrl" to "model/vrml", + "wsc" to "application/vnd.wfa.wsc", + "wsdl" to "application/wsdl+xml", + "wspolicy" to "application/wspolicy+xml", + "wtb" to "application/vnd.webturbo", + "wv" to "application/vnd.wv.csp+wbxml", "wvx" to "video/x-ms-wvx", "wz" to "application/x-wingz", "x3d" to "model/x3d+xml", - "x3db" to "model/x3d+binary", - "x3dv" to "model/x3d+vrml", + "x3db" to "model/x3d+fastinfoset", + "x3dv" to "model/x3d-vrml", + "x3dvz" to "model/x3d-vrml", + "x3dz" to "model/x3d+xml", + "x_b" to "model/vnd.parasolid.transmit.binary", + "x_t" to "model/vnd.parasolid.transmit.text", + "xar" to "application/vnd.xara", + "xav" to "application/xcap-att+xml", + "xbd" to "application/vnd.fujixerox.docuworks.binder", "xbm" to "image/x-xbitmap", - "xcf" to "application/x-xcf", + "xca" to "application/xcap-caps+xml", + "xcf" to "image/x-xcf", "xcos" to "application/x-scilab-xcos", + "xcs" to "application/calendar+xml", + "xct" to "application/vnd.fujixerox.docuworks.container", + "xdd" to "application/bacnet-xdd+zip", + "xdf" to "application/xcap-diff+xml", + "xdm" to "application/vnd.syncml.dm+xml", + "xdp" to "application/vnd.adobe.xdp+xml", + "xdssc" to "application/dssc+xml", + "xdw" to "application/vnd.fujixerox.docuworks", + "xel" to "application/xcap-el+xml", + "xer" to "application/xcap-error+xml", + "xfd" to "application/vnd.xfdl", + "xfdf" to "application/xfdf", + "xfdl" to "application/vnd.xfdl", + "xhe" to "audio/usac", "xht" to "application/xhtml+xml", + "xhtm" to "application/xhtml+xml", "xhtml" to "application/xhtml+xml", + "xhvml" to "application/xv+xml", + "xif" to "image/vnd.xiff", + "xla" to "application/vnd.ms-excel", "xlam" to "application/vnd.ms-excel.addin.macroEnabled.12", - "xlb" to "application/vnd.ms-excel", + "xlc" to "application/vnd.ms-excel", + "xlf" to "application/xliff+xml", + "xlim" to "application/vnd.xmpie.xlim", + "xlm" to "application/vnd.ms-excel", "xls" to "application/vnd.ms-excel", "xlsb" to "application/vnd.ms-excel.sheet.binary.macroEnabled.12", "xlsm" to "application/vnd.ms-excel.sheet.macroEnabled.12", @@ -615,22 +1558,57 @@ private val extensionToMimeTypeMap = mapOf( "xlt" to "application/vnd.ms-excel", "xltm" to "application/vnd.ms-excel.template.macroEnabled.12", "xltx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "xlw" to "application/vnd.ms-excel", "xmf" to "audio/midi", "xml" to "text/xml", + "xmls" to "application/dskpp+xml", + "xmt_bin" to "model/vnd.parasolid.transmit.binary", + "xmt_txt" to "model/vnd.parasolid.transmit.text", + "xns" to "application/xcap-ns+xml", + "xo" to "application/vnd.olpc-sugar", + "xodp" to "application/vnd.collabio.xodocuments.presentation", + "xods" to "application/vnd.collabio.xodocuments.spreadsheet", + "xodt" to "application/vnd.collabio.xodocuments.document", + "xop" to "application/xop+xml", + "xotp" to "application/vnd.collabio.xodocuments.presentation-template", + "xots" to "application/vnd.collabio.xodocuments.spreadsheet-template", + "xott" to "application/vnd.collabio.xodocuments.document-template", + "xpak" to "application/vnd.gentoo.xpak", "xpi" to "application/x-xpinstall", "xpm" to "image/x-xpixmap", - "xsd" to "application/xml", + "xpr" to "application/vnd.is-xpr", + "xps" to "application/vnd.ms-xpsdocument", + "xpw" to "application/vnd.intercon.formnet", + "xpx" to "application/vnd.intercon.formnet", + "xsf" to "application/prs.xsf+xml", "xsl" to "application/xslt+xml", "xslt" to "application/xslt+xml", + "xsm" to "application/vnd.syncml+xml", "xspf" to "application/xspf+xml", "xtel" to "chemical/x-xtel", "xul" to "application/vnd.mozilla.xul+xml", + "xvm" to "application/xv+xml", + "xvml" to "application/xv+xml", "xwd" to "image/x-xwindowdump", "xyz" to "chemical/x-xyz", + "xyze" to "image/vnd.radiance", "xz" to "application/x-xz", + "yaml" to "application/yaml", + "yang" to "application/yang", + "yin" to "application/yin+xml", + "yme" to "application/vnd.yaoweme", + "yml" to "application/yaml", "yt" to "video/vnd.youtube.yt", + "zaz" to "application/vnd.zzazz.deck+xml", + "zfc" to "application/vnd.filmit.zfc", + "zfo" to "application/vnd.software602.filler.form-xml-zip", "zip" to "application/zip", + "zir" to "application/vnd.zul", + "zirz" to "application/vnd.zul", + "zmm" to "application/vnd.HandHeld-Entertainment+xml", "zmt" to "chemical/x-mopac-input", + "zone" to "text/dns", + "zst" to "application/zstd", "~" to "application/x-trash" ) diff --git a/app/src/main/java/me/zhanghai/android/files/compat/NioUtilsCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/NioUtilsCompat.kt index a1e8c113d..9f4880269 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/NioUtilsCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/NioUtilsCompat.kt @@ -26,7 +26,7 @@ object NioUtilsCompat { ) fun newFileChannel(ioObject: Closeable, fd: FileDescriptor, flags: Int): FileChannel = - if (Build.VERSION.SDK_INT in Build.VERSION_CODES.N until Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT in Build.VERSION_CODES.N..> Parcel.readParcelableListCompat( return list } val listSize = list.size - for (index in 0 until size) { + for (index in 0..(classLoader) as E if (index < listSize) { diff --git a/app/src/main/java/me/zhanghai/android/files/compat/ServiceCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/ServiceCompat.kt new file mode 100644 index 000000000..7a98fa0d0 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/compat/ServiceCompat.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.compat + +import android.app.Service +import androidx.core.app.ServiceCompat + +fun Service.stopForegroundCompat(flags: Int) { + ServiceCompat.stopForeground(this, flags) +} diff --git a/app/src/main/java/me/zhanghai/android/files/compat/TextViewCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/TextViewCompat.kt index 5ac7cd1e0..1785d5e58 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/TextViewCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/TextViewCompat.kt @@ -5,9 +5,21 @@ package me.zhanghai.android.files.compat +import android.os.Build import android.widget.TextView import androidx.annotation.StyleRes import androidx.core.widget.TextViewCompat +import me.zhanghai.android.files.util.lazyReflectedMethod + +private val isSingleLineMethod by lazyReflectedMethod(TextView::class.java, "isSingleLine") + +val TextView.isSingleLineCompat: Boolean + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isSingleLine + } else { + isSingleLineMethod.invoke(this) as Boolean + } fun TextView.setTextAppearanceCompat(@StyleRes resId: Int) { TextViewCompat.setTextAppearance(this, resId) diff --git a/app/src/main/java/me/zhanghai/android/files/compat/TileServiceCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/TileServiceCompat.kt new file mode 100644 index 000000000..98f6c4d00 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/compat/TileServiceCompat.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.compat + +import android.graphics.PixelFormat +import android.os.Build +import android.os.IBinder +import android.service.quicksettings.TileService +import android.view.View +import android.view.WindowManager +import androidx.annotation.RequiresApi +import androidx.core.view.doOnPreDraw +import me.zhanghai.android.files.hiddenapi.RestrictedHiddenApi +import me.zhanghai.android.files.util.lazyReflectedField + +// Work around https://issuetracker.google.com/issues/299506164 on U which is fixed in V. +fun TileService.doWithStartForegroundServiceAllowed(action: () -> Unit) { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + action() + return + } + val windowManager = getSystemService(WindowManager::class.java) + val view = View(this) + val layoutParams = + WindowManager.LayoutParams().apply { + type = WindowManager_LayoutParams_TYPE_QS_DIALOG + format = PixelFormat.TRANSLUCENT + token = this@doWithStartForegroundServiceAllowed.token + } + windowManager.addView(view, layoutParams) + // We need to wait for WindowState.onSurfaceShownChanged(), basically when the first draw has + // finished and the surface is about to be shown to the user. However there's no good callback + // for that, while waiting for the second pre-draw seems to work. + view.doOnPreDraw { + view.post { + view.invalidate() + view.doOnPreDraw { + try { + action() + } finally { + windowManager.removeView(view) + } + } + } + } +} + +private const val WindowManager_LayoutParams_TYPE_QS_DIALOG = + WindowManager.LayoutParams.FIRST_SYSTEM_WINDOW + 35 + +@delegate:RequiresApi(Build.VERSION_CODES.N) +@get:RequiresApi(Build.VERSION_CODES.N) +@RestrictedHiddenApi +private val tokenField by lazyReflectedField(TileService::class.qualifiedName!!, "mToken") + +private val TileService.token: IBinder? + @RequiresApi(Build.VERSION_CODES.N) + get() = tokenField.get(this) as IBinder? diff --git a/app/src/main/java/me/zhanghai/android/files/compat/TypedValueCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/TypedValueCompat.kt new file mode 100644 index 000000000..cd1d3dd81 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/compat/TypedValueCompat.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.compat + +import android.util.TypedValue +import androidx.core.util.TypedValueCompat + +val TypedValue.complexUnitCompat: Int + get() = TypedValueCompat.getUnitFromComplexDimension(data) diff --git a/app/src/main/java/me/zhanghai/android/files/file/BasicFileAttributesExtensions.kt b/app/src/main/java/me/zhanghai/android/files/file/BasicFileAttributesExtensions.kt index 2a31246b1..7626fc85c 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/BasicFileAttributesExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/BasicFileAttributesExtensions.kt @@ -5,8 +5,8 @@ package me.zhanghai.android.files.file +import java.time.Instant import java8.nio.file.attribute.BasicFileAttributes -import org.threeten.bp.Instant val BasicFileAttributes.fileSize: FileSize get() = size().asFileSize() diff --git a/app/src/main/java/me/zhanghai/android/files/file/DocumentTreeUri.kt b/app/src/main/java/me/zhanghai/android/files/file/DocumentTreeUri.kt index a42a878f5..9937b9a81 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/DocumentTreeUri.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/DocumentTreeUri.kt @@ -11,10 +11,12 @@ import android.os.Parcelable import android.os.storage.StorageVolume import android.provider.DocumentsContract import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.app.contentResolver import me.zhanghai.android.files.compat.DocumentsContractCompat import me.zhanghai.android.files.compat.createOpenDocumentTreeIntentCompat import me.zhanghai.android.files.storage.StorageVolumeListLiveData +import me.zhanghai.android.files.util.StableUriParceler import me.zhanghai.android.files.util.getParcelableExtraSafe import me.zhanghai.android.files.util.releasePersistablePermission import me.zhanghai.android.files.util.takePersistablePermission @@ -22,7 +24,7 @@ import me.zhanghai.android.files.util.valueCompat @Parcelize @JvmInline -value class DocumentTreeUri(val value: Uri) : Parcelable { +value class DocumentTreeUri(val value: @WriteWith Uri) : Parcelable { val documentId: String get() = DocumentsContract.getTreeDocumentId(value) diff --git a/app/src/main/java/me/zhanghai/android/files/file/DocumentUri.kt b/app/src/main/java/me/zhanghai/android/files/file/DocumentUri.kt index 1300999fd..fac458337 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/DocumentUri.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/DocumentUri.kt @@ -6,12 +6,20 @@ package me.zhanghai.android.files.file import android.net.Uri +import android.os.Parcelable import android.provider.DocumentsContract +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.app.contentResolver import me.zhanghai.android.files.compat.DocumentsContractCompat +import me.zhanghai.android.files.util.StableUriParceler +@Parcelize @JvmInline -value class DocumentUri(val value: Uri) { +value class DocumentUri(val value: @WriteWith Uri) : Parcelable { + val treeDocumentId: String + get() = DocumentsContract.getTreeDocumentId(value) + val documentId: String get() = DocumentsContract.getDocumentId(value) } @@ -50,6 +58,3 @@ val DocumentUri.displayName: String? } return null } - -val DocumentUri.displayNameOrUri: String - get() = displayName ?: value.toString() diff --git a/app/src/main/java/me/zhanghai/android/files/file/DurationExtensions.kt b/app/src/main/java/me/zhanghai/android/files/file/DurationExtensions.kt index b185c65d5..8e1b1f9ab 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/DurationExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/DurationExtensions.kt @@ -6,6 +6,6 @@ package me.zhanghai.android.files.file import android.text.format.DateUtils -import org.threeten.bp.Duration +import java.time.Duration fun Duration.format(): String = DateUtils.formatElapsedTime(seconds) diff --git a/app/src/main/java/me/zhanghai/android/files/file/ExternalStorageUri.kt b/app/src/main/java/me/zhanghai/android/files/file/ExternalStorageUri.kt new file mode 100644 index 000000000..2448615c8 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/file/ExternalStorageUri.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.file + +import android.net.Uri +import android.os.Parcelable +import android.provider.DocumentsContract +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith +import me.zhanghai.android.files.compat.DocumentsContractCompat +import me.zhanghai.android.files.util.StableUriParceler +import me.zhanghai.android.files.util.takeIfNotEmpty + +@Parcelize +@JvmInline +value class ExternalStorageUri(val value: @WriteWith Uri) : Parcelable { + constructor( + rootId: String, + path: String + ) : this( + DocumentsContract.buildDocumentUriUsingTree( + DocumentsContract.buildTreeDocumentUri( + DocumentsContractCompat.EXTERNAL_STORAGE_PROVIDER_AUTHORITY, + rootId + ), + "$rootId:$path" + ) + ) + + val rootId: String + get() = DocumentsContract.getTreeDocumentId(value) + + val path: String + get() = DocumentsContract.getDocumentId(value).removePrefix("$rootId:") +} + +fun Uri.asExternalStorageUriOrNull(): ExternalStorageUri? = + if (isExternalStorageUri) ExternalStorageUri(this) else null + +fun Uri.asExternalStorageUri(): ExternalStorageUri { + require(isExternalStorageUri) + return ExternalStorageUri(this) +} + +/** @see DocumentsContractCompat.isDocumentUri */ +private val Uri.isExternalStorageUri: Boolean + get() = + DocumentsContractCompat.isDocumentUri(this) && + authority == DocumentsContractCompat.EXTERNAL_STORAGE_PROVIDER_AUTHORITY && + pathSegments.size == 4 + +val ExternalStorageUri.displayName: String + get() = path.takeLastWhile { it != '/' }.takeIfNotEmpty() ?: "/" diff --git a/app/src/main/java/me/zhanghai/android/files/file/InstantExtensions.kt b/app/src/main/java/me/zhanghai/android/files/file/InstantExtensions.kt index 11efa2fbd..6a81e1a34 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/InstantExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/InstantExtensions.kt @@ -8,10 +8,10 @@ package me.zhanghai.android.files.file import android.content.Context import android.text.format.DateUtils import android.text.format.Time -import org.threeten.bp.Instant -import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.format.FormatStyle +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle /* @see com.android.documentsui.base.Shared#formatTime(Context, long) */ @Suppress("DEPRECATION") diff --git a/app/src/main/java/me/zhanghai/android/files/file/MimeType.kt b/app/src/main/java/me/zhanghai/android/files/file/MimeType.kt index f74b13264..ade3411c9 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/MimeType.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/MimeType.kt @@ -76,19 +76,19 @@ fun String.asMimeType(): MimeType { private val String.isValidMimeType: Boolean get() { val indexOfSlash = indexOf('/') - if (indexOfSlash == -1 || indexOfSlash !in 1 until length) { + if (indexOfSlash == -1 || indexOfSlash !in 1.. indexOfSemicolon)) { - if (indexOfPlus !in indexOfSlash + 2 - until if (indexOfSemicolon != -1) indexOfSemicolon - 1 else length) { + if (indexOfPlus !in indexOfSlash + 2..< + if (indexOfSemicolon != -1) indexOfSemicolon - 1 else length) { return false } } diff --git a/app/src/main/java/me/zhanghai/android/files/file/MimeTypeConversionExtensions.kt b/app/src/main/java/me/zhanghai/android/files/file/MimeTypeConversionExtensions.kt index 6d0ab2f29..aeea2ce6d 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/MimeTypeConversionExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/MimeTypeConversionExtensions.kt @@ -30,10 +30,8 @@ fun MimeType.Companion.guessFromExtension(extension: String): MimeType { // @see /usr/share/mime/packages/freedesktop.org.xml private val extensionToMimeTypeOverrideMap = mapOf( // Fixes - "cab" to "application/vnd.ms-cab-compressed", // Was "application/x-cab" "csv" to "text/csv", // Was "text/comma-separated-values" "sh" to "application/x-sh", // Was "text/x-sh" - "otf" to "font/otf", // Was "font/ttf" // Addition "bz" to "application/x-bzip", "bz2" to "application/x-bzip2", @@ -41,15 +39,12 @@ private val extensionToMimeTypeOverrideMap = mapOf( "lzma" to "application/x-lzma", "p7b" to "application/x-pkcs7-certificates", "spc" to "application/x-pkcs7-certificates", // Clashes with "chemical/x-galactic-spc" - "p7c" to "application/pkcs7-mime", - "p7s" to "application/pkcs7-signature", "ts" to "application/typescript", // Clashes with "video/mp2ts" "py3" to "text/x-python", "py3x" to "text/x-python", "pyx" to "text/x-python", "wsgi" to "text/x-python", - "yaml" to "text/x-yaml", - "yml" to "text/x-yaml", + "yml" to "application/yaml", "asm" to "text/x-asm", "s" to "text/x-asm", "cs" to "text/x-csharp", @@ -79,6 +74,10 @@ private val specialPosixFileTypeToMimeTypeMap = mapOf( PosixFileType.SOCKET to "inode/socket" ).mapValues { it.value.asMimeType() } +val MimeType.extension: String? + // TODO: Add compat implementation as well. + get() = MimeTypeMap.getSingleton().getExtensionFromMimeType(value) + val MimeType.intentType: String get() = intentMimeType.value @@ -91,6 +90,7 @@ private val mimeTypeToIntentMimeTypeMap = listOf( "application/javascript" to "text/javascript", "application/json" to "text/json", "application/typescript" to "text/typescript", + "application/yaml" to "text/x-yaml", "application/x-sh" to "text/x-shellscript", "application/x-shellscript" to "text/x-shellscript", // Allows matching generic diff --git a/app/src/main/java/me/zhanghai/android/files/file/MimeTypeIcon.kt b/app/src/main/java/me/zhanghai/android/files/file/MimeTypeIcon.kt index 5c891a548..0bfcce9e5 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/MimeTypeIcon.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/MimeTypeIcon.kt @@ -48,9 +48,10 @@ private val mimeTypeToIconMap = mapOf( // Not in IANA list, but Mozilla and Wikipedia say so. "application/java-archive" to MimeTypeIcon.ARCHIVE, "application/mac-binhex40" to MimeTypeIcon.ARCHIVE, - // Not in IANA list, but AOSP MimeUtils says so. + // Not in IANA list, but AOSP MimeUtils used to say so. "application/rar" to MimeTypeIcon.ARCHIVE, "application/zip" to MimeTypeIcon.ARCHIVE, + "application/zstd" to MimeTypeIcon.ARCHIVE, "application/vnd.debian.binary-package" to MimeTypeIcon.ARCHIVE, "application/vnd.ms-cab-compressed" to MimeTypeIcon.ARCHIVE, "application/vnd.rar" to MimeTypeIcon.ARCHIVE, @@ -97,6 +98,7 @@ private val mimeTypeToIconMap = mapOf( "application/json" to MimeTypeIcon.CODE, "application/typescript" to MimeTypeIcon.CODE, "application/xml" to MimeTypeIcon.CODE, + "application/yaml" to MimeTypeIcon.CODE, "application/x-csh" to MimeTypeIcon.CODE, "application/x-ecmascript" to MimeTypeIcon.CODE, "application/x-javascript" to MimeTypeIcon.CODE, @@ -141,6 +143,7 @@ private val mimeTypeToIconMap = mapOf( "inode/directory" to MimeTypeIcon.DIRECTORY, MimeType.DIRECTORY.value to MimeTypeIcon.DIRECTORY, "application/rtf" to MimeTypeIcon.DOCUMENT, + "application/vnd.kde.kword" to MimeTypeIcon.DOCUMENT, "application/vnd.oasis.opendocument.text" to MimeTypeIcon.DOCUMENT, "application/vnd.oasis.opendocument.text-master" to MimeTypeIcon.DOCUMENT, "application/vnd.oasis.opendocument.text-template" to MimeTypeIcon.DOCUMENT, @@ -155,6 +158,9 @@ private val mimeTypeToIconMap = mapOf( "text/rtf" to MimeTypeIcon.DOCUMENT, "application/epub+zip" to MimeTypeIcon.EBOOK, "application/vnd.amazon.ebook" to MimeTypeIcon.EBOOK, + "application/vnd.amazon.mobi8-ebook" to MimeTypeIcon.EBOOK, + "application/vnd.comicbook-rar" to MimeTypeIcon.EBOOK, + "application/vnd.comicbook+zip" to MimeTypeIcon.EBOOK, "application/x-cbr" to MimeTypeIcon.EBOOK, "application/x-cbz" to MimeTypeIcon.EBOOK, "application/x-ibooks+zip" to MimeTypeIcon.EBOOK, @@ -179,18 +185,21 @@ private val mimeTypeToIconMap = mapOf( "application/vnd.sun.xml.draw.template" to MimeTypeIcon.IMAGE, "application/vnd.visio" to MimeTypeIcon.IMAGE, "application/pdf" to MimeTypeIcon.PDF, + "application/vnd.kde.kpresenter" to MimeTypeIcon.PRESENTATION, "application/vnd.oasis.opendocument.presentation" to MimeTypeIcon.PRESENTATION, "application/vnd.oasis.opendocument.presentation-template" to MimeTypeIcon.PRESENTATION, "application/vnd.stardivision.impress" to MimeTypeIcon.PRESENTATION, "application/vnd.sun.xml.impress" to MimeTypeIcon.PRESENTATION, "application/vnd.sun.xml.impress.template" to MimeTypeIcon.PRESENTATION, "application/x-kpresenter" to MimeTypeIcon.PRESENTATION, + "application/vnd.kde.kspread" to MimeTypeIcon.SPREADSHEET, "application/vnd.oasis.opendocument.spreadsheet" to MimeTypeIcon.SPREADSHEET, "application/vnd.oasis.opendocument.spreadsheet-template" to MimeTypeIcon.SPREADSHEET, "application/vnd.stardivision.calc" to MimeTypeIcon.SPREADSHEET, "application/vnd.sun.xml.calc" to MimeTypeIcon.SPREADSHEET, "application/vnd.sun.xml.calc.template" to MimeTypeIcon.SPREADSHEET, "application/x-kspread" to MimeTypeIcon.SPREADSHEET, + "application/vnd.adobe.flash.movie" to MimeTypeIcon.VIDEO, "application/x-quicktimeplayer" to MimeTypeIcon.VIDEO, "application/x-shockwave-flash" to MimeTypeIcon.VIDEO, "application/msword" to MimeTypeIcon.WORD, diff --git a/app/src/main/java/me/zhanghai/android/files/file/MimeTypeTypeExtensions.kt b/app/src/main/java/me/zhanghai/android/files/file/MimeTypeTypeExtensions.kt index 1a89c462b..90e95f247 100644 --- a/app/src/main/java/me/zhanghai/android/files/file/MimeTypeTypeExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/file/MimeTypeTypeExtensions.kt @@ -5,8 +5,6 @@ package me.zhanghai.android.files.file -import android.os.Build - val MimeType.isApk: Boolean get() = this == MimeType.APK @@ -18,26 +16,29 @@ private val supportedArchiveMimeTypes = mutableListOf( "application/java-archive", "application/rar", "application/zip", + "application/zstd", "application/vnd.android.package-archive", "application/vnd.debian.binary-package", + "application/vnd.ms-cab-compressed", + "application/vnd.rar", + "application/x-7z-compressed", "application/x-bzip2", + "application/x-cab", "application/x-compress", "application/x-cpio", "application/x-deb", "application/x-debian-package", "application/x-gtar", "application/x-gtar-compressed", + "application/x-iso9660-image", "application/x-java-archive", + "application/x-lha", "application/x-lzma", + "application/x-redhat-package-manager", "application/x-tar", + "application/x-ustar", "application/x-xz" -) - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - this += "application/x-7z-compressed" - } - } - .map { it.asMimeType() }.toSet() +).map { it.asMimeType() }.toSet() val MimeType.isImage: Boolean get() = icon == MimeTypeIcon.IMAGE diff --git a/app/src/main/java/me/zhanghai/android/files/fileaction/ArchivePasswordDialogActivity.kt b/app/src/main/java/me/zhanghai/android/files/fileaction/ArchivePasswordDialogActivity.kt new file mode 100644 index 000000000..9cd8aa1a7 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileaction/ArchivePasswordDialogActivity.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileaction + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.commit +import me.zhanghai.android.files.app.AppActivity +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.putArgs + +class ArchivePasswordDialogActivity : AppActivity() { + private val args by args() + + private lateinit var fragment: ArchivePasswordDialogFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Calls ensureSubDecor(). + findViewById(android.R.id.content) + if (savedInstanceState == null) { + fragment = ArchivePasswordDialogFragment().putArgs(args) + supportFragmentManager.commit { + add(fragment, ArchivePasswordDialogFragment::class.java.name) + } + } else { + fragment = supportFragmentManager.findFragmentByTag( + ArchivePasswordDialogFragment::class.java.name + ) as ArchivePasswordDialogFragment + } + } + + override fun onDestroy() { + super.onDestroy() + + if (isFinishing) { + fragment.onFinish() + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/fileaction/ArchivePasswordDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileaction/ArchivePasswordDialogFragment.kt new file mode 100644 index 000000000..7acfda892 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileaction/ArchivePasswordDialogFragment.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileaction + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.util.SparseArray +import android.view.WindowManager +import android.widget.LinearLayout +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.core.widget.NestedScrollView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java8.nio.file.Path +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith +import me.zhanghai.android.files.R +import me.zhanghai.android.files.compat.requireViewByIdCompat +import me.zhanghai.android.files.databinding.ArchivePasswordDialogBinding +import me.zhanghai.android.files.provider.archive.archiveAddPassword +import me.zhanghai.android.files.provider.archive.archiveFile +import me.zhanghai.android.files.ui.AllowSoftInputHackAlertDialogCustomView +import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.ParcelableParceler +import me.zhanghai.android.files.util.ParcelableState +import me.zhanghai.android.files.util.RemoteCallback +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.finish +import me.zhanghai.android.files.util.getArgs +import me.zhanghai.android.files.util.getState +import me.zhanghai.android.files.util.hideTextInputLayoutErrorOnTextChange +import me.zhanghai.android.files.util.layoutInflater +import me.zhanghai.android.files.util.putArgs +import me.zhanghai.android.files.util.putState +import me.zhanghai.android.files.util.readParcelable +import me.zhanghai.android.files.util.setOnEditorConfirmActionListener + +class ArchivePasswordDialogFragment : AppCompatDialogFragment() { + private val args by args() + + private lateinit var binding: ArchivePasswordDialogBinding + + private var isListenerNotified = false + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + val hierarchyState = SparseArray() + .apply { binding.root.saveHierarchyState(this) } + outState.putState(State(hierarchyState)) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + return MaterialAlertDialogBuilder(requireContext(), theme) + .setTitle(getTitle(context)) + .setMessage(getMessage(args.path.archiveFile.fileName, context)) + .apply { + binding = ArchivePasswordDialogBinding.inflate(context.layoutInflater) + binding.passwordEdit.hideTextInputLayoutErrorOnTextChange(binding.passwordLayout) + binding.passwordEdit.setOnEditorConfirmActionListener { onOk() } + if (savedInstanceState != null) { + val state = savedInstanceState.getState() + binding.root.restoreHierarchyState(state.hierarchyState) + } + setView(AllowSoftInputHackAlertDialogCustomView(context)) + } + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel) { _, _ -> finish() } + .create() + .apply { + setCanceledOnTouchOutside(false) + // Override the listener here so that we have control over when to close the dialog. + setOnShowListener { + getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { onOk() } + } + window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + } + + override fun onStart() { + super.onStart() + + val dialog = requireDialog() as AlertDialog + if (binding.root.parent == null) { + val scrollView = dialog.requireViewByIdCompat(R.id.scrollView) + val linearLayout = scrollView.getChildAt(0) as LinearLayout + linearLayout.addView(binding.root) + binding.passwordEdit.requestFocus() + } + } + + private fun onOk() { + val password = binding.passwordEdit.text!!.toString() + if (password.isEmpty()) { + binding.passwordLayout.error = + getString(R.string.file_action_archive_password_error_empty) + return + } + args.path.archiveAddPassword(password) + notifyListenerOnce(true) + finish() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + notifyListenerOnce(false) + finish() + } + + fun onFinish() { + notifyListenerOnce(false) + } + + private fun notifyListenerOnce(successful: Boolean) { + if (isListenerNotified) { + return + } + args.listener(successful) + isListenerNotified = true + } + + companion object { + fun getTitle(context: Context): String = + context.getString(R.string.file_action_archive_password_title) + + fun getMessage(archiveFile: Path, context: Context): String = + context.getString( + R.string.file_action_archive_password_message_format, archiveFile.fileName + ) + } + + @Parcelize + class Args( + val path: @WriteWith Path, + val listener: @WriteWith() + (Boolean) -> Unit + ) : ParcelableArgs { + object ListenerParceler : Parceler<(Boolean) -> Unit> { + override fun create(parcel: Parcel): (Boolean) -> Unit = + parcel.readParcelable()!!.let { + { successful -> + it.sendResult(Bundle().putArgs(ListenerArgs(successful))) + } + } + + override fun ((Boolean) -> Unit).write(parcel: Parcel, flags: Int) { + parcel.writeParcelable( + RemoteCallback { + val args = it.getArgs() + this(args.successful) + }, flags + ) + } + + @Parcelize + private class ListenerArgs( + val successful: Boolean + ) : ParcelableArgs + } + } + + @Parcelize + private class State( + val hierarchyState: SparseArray + ) : ParcelableState +} diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogActivity.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogActivity.kt index 8ddf342fe..334aa36c0 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogActivity.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogActivity.kt @@ -5,12 +5,10 @@ package me.zhanghai.android.files.filejob -import android.content.Context import android.os.Bundle import android.view.View import androidx.fragment.app.commit import me.zhanghai.android.files.app.AppActivity -import me.zhanghai.android.files.file.FileItem import me.zhanghai.android.files.util.args import me.zhanghai.android.files.util.putArgs @@ -43,16 +41,4 @@ class FileJobConflictDialogActivity : AppActivity() { fragment.onFinish() } } - - companion object { - fun getTitle(sourceFile: FileItem, targetFile: FileItem, context: Context): String = - FileJobConflictDialogFragment.getTitle(sourceFile, targetFile, context) - - fun getMessage( - sourceFile: FileItem, - targetFile: FileItem, - type: CopyMoveType, - context: Context - ): String = FileJobConflictDialogFragment.getMessage(sourceFile, targetFile, type, context) - } } diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogFragment.kt index 2d648c4f5..76fbe2285 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobConflictDialogFragment.kt @@ -21,8 +21,8 @@ import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doAfterTextChanged import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import coil.clear -import coil.loadAny +import coil.dispose +import coil.load import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parcelize @@ -38,6 +38,7 @@ import me.zhanghai.android.files.file.iconRes import me.zhanghai.android.files.file.lastModifiedInstant import me.zhanghai.android.files.filelist.appDirectoryPackageName import me.zhanghai.android.files.filelist.supportsThumbnail +import me.zhanghai.android.files.provider.common.isEncrypted import me.zhanghai.android.files.util.ParcelableArgs import me.zhanghai.android.files.util.ParcelableState import me.zhanghai.android.files.util.RemoteCallback @@ -138,7 +139,10 @@ class FileJobConflictDialogFragment : AppCompatDialogFragment() { .setNegativeButton(R.string.skip, ::onDialogButtonClick) .setNeutralButton(android.R.string.cancel, ::onDialogButtonClick) .create() - .apply { setCanceledOnTouchOutside(false) } + .apply { + setCanceledOnTouchOutside(false) + window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } } /** @see me.zhanghai.android.files.filelist.FileListAdapter.onBindViewHolder */ @@ -151,37 +155,51 @@ class FileJobConflictDialogFragment : AppCompatDialogFragment() { descriptionText: TextView ) { val path = file.path - iconImage.setImageResource(file.mimeType.iconRes) - iconImage.isVisible = true - thumbnailImage.clear() - thumbnailImage.setImageDrawable(null) + iconImage.apply { + isVisible = true + setImageResource(file.mimeType.iconRes) + } val attributes = file.attributes - if (file.supportsThumbnail) { - thumbnailImage.loadAny(path to attributes) { - listener { _, _ -> iconImage.isVisible = false } + thumbnailImage.apply { + dispose() + setImageDrawable(null) + val supportsThumbnail = file.supportsThumbnail + isVisible = supportsThumbnail + if (supportsThumbnail) { + load(path to attributes) { + listener { _, _ -> iconImage.isVisible = false } + } } } - appIconBadgeImage.clear() - appIconBadgeImage.setImageDrawable(null) - val appDirectoryPackageName = file.appDirectoryPackageName - val hasAppIconBadge = appDirectoryPackageName != null - appIconBadgeImage.isVisible = hasAppIconBadge - if (hasAppIconBadge) { - appIconBadgeImage.loadAny(AppIconPackageName(appDirectoryPackageName!!)) + appIconBadgeImage.apply { + dispose() + setImageDrawable(null) + val appDirectoryPackageName = file.appDirectoryPackageName + val hasAppIconBadge = appDirectoryPackageName != null + isVisible = hasAppIconBadge + if (hasAppIconBadge) { + load(AppIconPackageName(appDirectoryPackageName!!)) + } } - val badgeIconRes = if (file.attributesNoFollowLinks.isSymbolicLink) { - if (file.isSymbolicLinkBroken) { - R.drawable.error_badge_icon_18dp + badgeImage.apply { + val badgeIconRes = if (file.attributesNoFollowLinks.isSymbolicLink) { + if (file.isSymbolicLinkBroken) { + R.drawable.error_badge_icon_18dp + } else { + R.drawable.symbolic_link_badge_icon_18dp + } + } else if (file.attributesNoFollowLinks.isEncrypted()) { + R.drawable.encrypted_badge_icon_18dp } else { - R.drawable.symbolic_link_badge_icon_18dp + null + } + val hasBadge = badgeIconRes != null + isVisible = hasBadge + if (hasBadge) { + setImageResource(badgeIconRes!!) + } else { + setImageDrawable(null) } - } else { - null - } - val hasBadge = badgeIconRes != null - badgeImage.isVisible = hasBadge - if (hasBadge) { - badgeImage.setImageResource(badgeIconRes!!) } val lastModificationTime = attributes.lastModifiedInstant .formatShort(descriptionText.context) @@ -235,10 +253,10 @@ class FileJobConflictDialogFragment : AppCompatDialogFragment() { if (binding.root.parent == null) { val dialog = requireDialog() as AlertDialog + dialog.window!!.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) val scrollView = dialog.requireViewByIdCompat(R.id.scrollView) val linearLayout = scrollView.getChildAt(0) as LinearLayout linearLayout.addView(binding.root) - dialog.window!!.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) } } @@ -334,5 +352,7 @@ class FileJobConflictDialogFragment : AppCompatDialogFragment() { } @Parcelize - private class State(val isAllChecked: Boolean) : ParcelableState + private class State( + val isAllChecked: Boolean + ) : ParcelableState } diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobAction.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorAction.kt similarity index 85% rename from app/src/main/java/me/zhanghai/android/files/filejob/FileJobAction.kt rename to app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorAction.kt index 117d5a6a2..e8b9a1a36 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobAction.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorAction.kt @@ -5,7 +5,7 @@ package me.zhanghai.android.files.filejob -enum class FileJobAction { +enum class FileJobErrorAction { POSITIVE, NEGATIVE, NEUTRAL, diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionDialogActivity.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorDialogActivity.kt similarity index 67% rename from app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionDialogActivity.kt rename to app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorDialogActivity.kt index 2c1c6a589..942393184 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionDialogActivity.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorDialogActivity.kt @@ -12,10 +12,10 @@ import me.zhanghai.android.files.app.AppActivity import me.zhanghai.android.files.util.args import me.zhanghai.android.files.util.putArgs -class FileJobActionDialogActivity : AppActivity() { - private val args by args() +class FileJobErrorDialogActivity : AppActivity() { + private val args by args() - private lateinit var fragment: FileJobActionDialogFragment + private lateinit var fragment: FileJobErrorDialogFragment override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -23,14 +23,14 @@ class FileJobActionDialogActivity : AppActivity() { // Calls ensureSubDecor(). findViewById(android.R.id.content) if (savedInstanceState == null) { - fragment = FileJobActionDialogFragment().putArgs(args) + fragment = FileJobErrorDialogFragment().putArgs(args) supportFragmentManager.commit { - add(fragment, FileJobActionDialogFragment::class.java.name) + add(fragment, FileJobErrorDialogFragment::class.java.name) } } else { fragment = supportFragmentManager.findFragmentByTag( - FileJobActionDialogFragment::class.java.name - ) as FileJobActionDialogFragment + FileJobErrorDialogFragment::class.java.name + ) as FileJobErrorDialogFragment } } diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorDialogFragment.kt similarity index 83% rename from app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionDialogFragment.kt rename to app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorDialogFragment.kt index efcb910d7..18e30481b 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorDialogFragment.kt @@ -22,7 +22,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.requireViewByIdCompat -import me.zhanghai.android.files.databinding.FileJobActionDialogViewBinding +import me.zhanghai.android.files.databinding.FileJobErrorDialogViewBinding import me.zhanghai.android.files.provider.common.PosixFileStore import me.zhanghai.android.files.util.ActionState import me.zhanghai.android.files.util.ParcelableArgs @@ -42,12 +42,12 @@ import me.zhanghai.android.files.util.readParcelable import me.zhanghai.android.files.util.showToast import me.zhanghai.android.files.util.viewModels -class FileJobActionDialogFragment : AppCompatDialogFragment() { +class FileJobErrorDialogFragment : AppCompatDialogFragment() { private val args by args() - private val viewModel by viewModels { { FileJobActionViewModel() } } + private val viewModel by viewModels { { FileJobErrorViewModel() } } - private lateinit var binding: FileJobActionDialogViewBinding + private lateinit var binding: FileJobErrorDialogViewBinding private var isListenerNotified = false @@ -62,7 +62,7 @@ class FileJobActionDialogFragment : AppCompatDialogFragment() { .setTitle(args.title) .setMessage(args.message) .apply { - binding = FileJobActionDialogViewBinding.inflate(context.layoutInflater) + binding = FileJobErrorDialogViewBinding.inflate(context.layoutInflater) val hasReadOnlyFileStore = args.readOnlyFileStore != null binding.remountButton.isVisible = hasReadOnlyFileStore if (hasReadOnlyFileStore) { @@ -118,9 +118,9 @@ class FileJobActionDialogFragment : AppCompatDialogFragment() { private fun onDialogButtonClick(dialog: DialogInterface, which: Int) { val action = when (which) { - DialogInterface.BUTTON_POSITIVE -> FileJobAction.POSITIVE - DialogInterface.BUTTON_NEGATIVE -> FileJobAction.NEGATIVE - DialogInterface.BUTTON_NEUTRAL -> FileJobAction.NEUTRAL + DialogInterface.BUTTON_POSITIVE -> FileJobErrorAction.POSITIVE + DialogInterface.BUTTON_NEGATIVE -> FileJobErrorAction.NEGATIVE + DialogInterface.BUTTON_NEUTRAL -> FileJobErrorAction.NEUTRAL else -> throw AssertionError(which) } notifyListenerOnce(action, args.showAll && binding.allCheck.isChecked) @@ -141,15 +141,15 @@ class FileJobActionDialogFragment : AppCompatDialogFragment() { override fun onCancel(dialog: DialogInterface) { super.onCancel(dialog) - notifyListenerOnce(FileJobAction.CANCELED, false) + notifyListenerOnce(FileJobErrorAction.CANCELED, false) finish() } fun onFinish() { - notifyListenerOnce(FileJobAction.CANCELED, false) + notifyListenerOnce(FileJobErrorAction.CANCELED, false) } - private fun notifyListenerOnce(action: FileJobAction, isAll: Boolean) { + private fun notifyListenerOnce(action: FileJobErrorAction, isAll: Boolean) { if (isListenerNotified) { return } @@ -166,17 +166,17 @@ class FileJobActionDialogFragment : AppCompatDialogFragment() { val positiveButtonText: CharSequence?, val negativeButtonText: CharSequence?, val neutralButtonText: CharSequence?, - val listener: @WriteWith() (FileJobAction, Boolean) -> Unit + val listener: @WriteWith() (FileJobErrorAction, Boolean) -> Unit ) : ParcelableArgs { - object ListenerParceler : Parceler<(FileJobAction, Boolean) -> Unit> { - override fun create(parcel: Parcel): (FileJobAction, Boolean) -> Unit = + object ListenerParceler : Parceler<(FileJobErrorAction, Boolean) -> Unit> { + override fun create(parcel: Parcel): (FileJobErrorAction, Boolean) -> Unit = parcel.readParcelable()!!.let { { action, isAll -> it.sendResult(Bundle().putArgs(ListenerArgs(action, isAll))) } } - override fun ((FileJobAction, Boolean) -> Unit).write(parcel: Parcel, flags: Int) { + override fun ((FileJobErrorAction, Boolean) -> Unit).write(parcel: Parcel, flags: Int) { parcel.writeParcelable(RemoteCallback { val args = it.getArgs() this(args.action, args.isAll) @@ -185,12 +185,14 @@ class FileJobActionDialogFragment : AppCompatDialogFragment() { @Parcelize private class ListenerArgs( - val action: FileJobAction, + val action: FileJobErrorAction, val isAll: Boolean ) : ParcelableArgs } } @Parcelize - private class State(val isAllChecked: Boolean) : ParcelableState + private class State( + val isAllChecked: Boolean + ) : ParcelableState } diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionViewModel.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorViewModel.kt similarity index 97% rename from app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionViewModel.kt rename to app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorViewModel.kt index 1a835403d..aca3296ac 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobActionViewModel.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobErrorViewModel.kt @@ -17,7 +17,7 @@ import me.zhanghai.android.files.util.ActionState import me.zhanghai.android.files.util.isFinished import me.zhanghai.android.files.util.isReady -class FileJobActionViewModel : ViewModel() { +class FileJobErrorViewModel : ViewModel() { private val _remountState = MutableStateFlow>(ActionState.Ready()) val remountState = _remountState.asStateFlow() diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobNotificationTemplate.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobNotificationTemplate.kt index 897e40b91..165b0f992 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobNotificationTemplate.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobNotificationTemplate.kt @@ -11,7 +11,7 @@ import me.zhanghai.android.files.R import me.zhanghai.android.files.util.NotificationChannelTemplate import me.zhanghai.android.files.util.NotificationTemplate -val fileJobNotificationTemplate: NotificationTemplate = +val fileJobNotificationTemplate = NotificationTemplate( NotificationChannelTemplate( "file_job", @@ -23,6 +23,7 @@ val fileJobNotificationTemplate: NotificationTemplate = colorRes = R.color.color_primary, smallIcon = R.drawable.notification_icon, ongoing = true, + onlyAlertOnce = true, category = NotificationCompat.CATEGORY_PROGRESS, priority = NotificationCompat.PRIORITY_LOW ) diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobService.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobService.kt index c5b7b2e7e..faa013ac4 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobService.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobService.kt @@ -16,11 +16,15 @@ import me.zhanghai.android.files.provider.common.PosixFileModeBit import me.zhanghai.android.files.provider.common.PosixGroup import me.zhanghai.android.files.provider.common.PosixUser import me.zhanghai.android.files.util.ForegroundNotificationManager +import me.zhanghai.android.files.util.WakeWifiLock import me.zhanghai.android.files.util.removeFirst import java.util.concurrent.Executors import java.util.concurrent.Future +import me.zhanghai.android.files.compat.removeFirstCompat class FileJobService : Service() { + private lateinit var wakeWifiLock: WakeWifiLock + internal lateinit var notificationManager: ForegroundNotificationManager private set @@ -31,35 +35,41 @@ class FileJobService : Service() { override fun onCreate() { super.onCreate() + wakeWifiLock = WakeWifiLock(FileJobService::class.java.simpleName) notificationManager = ForegroundNotificationManager(this) instance = this while (pendingJobs.isNotEmpty()) { - startJob(pendingJobs.removeFirst()) + startJob(pendingJobs.removeFirstCompat()) } } + override fun onBind(intent: Intent): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + + private val jobCount: Int + get() = synchronized(runningJobs) { runningJobs.size } + private fun startJob(job: FileJob) { // Synchronize on runningJobs to prevent a job from removing itself before being added. synchronized(runningJobs) { val future = executorService.submit { job.runOn(this) - synchronized(runningJobs) { runningJobs.remove(job) } + synchronized(runningJobs) { + runningJobs.remove(job) + updateWakeWifiLockLocked() + } } runningJobs[job] = future + updateWakeWifiLockLocked() } } - override fun onBind(intent: Intent): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY - - private val jobCount: Int - get() = synchronized(runningJobs) { runningJobs.size } - private fun cancelJob(id: Int) { synchronized(runningJobs) { runningJobs.removeFirst { it.key.id == id }?.value?.cancel(true) + updateWakeWifiLockLocked() } } @@ -72,9 +82,16 @@ class FileJobService : Service() { while (runningJobs.isNotEmpty()) { runningJobs.removeFirst().value.cancel(true) } + updateWakeWifiLockLocked() } } + // Synchronize on runningJobs to avoid the potential race condition that the lock is + // acquired after all jobs are finished in a very short time. + private fun updateWakeWifiLockLocked() { + wakeWifiLock.isAcquired = jobCount > 0 + } + companion object { private var instance: FileJobService? = null @@ -98,11 +115,12 @@ class FileJobService : Service() { fun archive( sources: List, archiveFile: Path, - archiveType: String, - compressorType: String?, + format: Int, + filter: Int, + password: String?, context: Context ) { - startJob(ArchiveFileJob(sources, archiveFile, archiveType, compressorType), context) + startJob(ArchiveFileJob(sources, archiveFile, format, filter, password), context) } fun copy(sources: List, targetDirectory: Path, context: Context) { @@ -137,6 +155,10 @@ class FileJobService : Service() { startJob(RestoreFileSeLinuxContextJob(path, recursive), context) } + fun save(source: Path, target: Path, context: Context) { + startJob(SaveFileJob(source, target), context) + } + fun setGroup(path: Path, group: PosixGroup, recursive: Boolean, context: Context) { startJob(SetFileGroupJob(path, group, recursive), context) } diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt index 72435bacb..4213b2374 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt @@ -3,13 +3,14 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.filejob; +package me.zhanghai.android.files.filejob import android.app.PendingIntent import android.content.Intent import android.net.Uri import android.os.Build import android.os.Environment +import android.widget.Toast import androidx.annotation.AnyRes import androidx.annotation.PluralsRes import androidx.annotation.StringRes @@ -30,6 +31,7 @@ import kotlinx.coroutines.runBlocking import me.zhanghai.android.files.R import me.zhanghai.android.files.app.BackgroundActivityStarter import me.zhanghai.android.files.app.mainExecutor +import me.zhanghai.android.files.compat.mainExecutorCompat import me.zhanghai.android.files.file.FileItem import me.zhanghai.android.files.file.MimeType import me.zhanghai.android.files.file.asFileSize @@ -51,6 +53,7 @@ import me.zhanghai.android.files.provider.common.PosixPrincipal import me.zhanghai.android.files.provider.common.PosixUser import me.zhanghai.android.files.provider.common.ProgressCopyOption import me.zhanghai.android.files.provider.common.ReadOnlyFileSystemException +import me.zhanghai.android.files.provider.common.UserActionRequiredException import me.zhanghai.android.files.provider.common.asByteStringListPath import me.zhanghai.android.files.provider.common.copyTo import me.zhanghai.android.files.provider.common.createDirectories @@ -84,6 +87,7 @@ import me.zhanghai.android.files.util.createViewIntent import me.zhanghai.android.files.util.extraPath import me.zhanghai.android.files.util.getQuantityString import me.zhanghai.android.files.util.putArgs +import me.zhanghai.android.files.util.showToast import me.zhanghai.android.files.util.toEnumSet import me.zhanghai.android.files.util.withChooser import java.io.ByteArrayInputStream @@ -152,6 +156,18 @@ private const val PROGRESS_INTERVAL_MILLIS = 200L private const val NOTIFICATION_INTERVAL_MILLIS = 500L +private fun FileJob.showToast(textRes: Int, duration: Int = Toast.LENGTH_SHORT) { + service.mainExecutorCompat.execute { + service.showToast(textRes, duration) + } +} + +private fun FileJob.showToast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) { + service.mainExecutorCompat.execute { + service.showToast(text, duration) + } +} + private fun FileJob.getFileName(path: Path): String = if (path.isAbsolute && path.nameCount == 0) { path.fileSystem.separator @@ -467,8 +483,24 @@ private class TransferInfo(scanInfo: ScanInfo, val target: Path?) { } } +// TODO: Make invalid file name, remount etc user actions as well. @Throws(InterruptedIOException::class) -private fun FileJob.showActionDialog( +private fun FileJob.showUserAction(exception: UserActionRequiredException): Boolean = + try { + runBlocking { + suspendCoroutine { continuation -> + val userAction = exception.getUserAction(continuation, service) + BackgroundActivityStarter.startActivity( + userAction.intent, userAction.title, userAction.message, service + ) + } + } + } catch (e: InterruptedException) { + throw InterruptedIOException().apply { initCause(e) } + } + +@Throws(InterruptedIOException::class) +private fun FileJob.showErrorDialog( title: CharSequence, message: CharSequence, readOnlyFileStore: PosixFileStore?, @@ -476,17 +508,17 @@ private fun FileJob.showActionDialog( positiveButtonText: CharSequence?, negativeButtonText: CharSequence?, neutralButtonText: CharSequence? -): ActionResult = +): ErrorResult = try { - runBlocking { + runBlocking { suspendCoroutine { continuation -> BackgroundActivityStarter.startActivity( - FileJobActionDialogActivity::class.createIntent().putArgs( - FileJobActionDialogFragment.Args( + FileJobErrorDialogActivity::class.createIntent().putArgs( + FileJobErrorDialogFragment.Args( title, message, readOnlyFileStore, showAll, positiveButtonText, negativeButtonText, neutralButtonText ) { action, isAll -> - continuation.resume(ActionResult(action, isAll)) + continuation.resume(ErrorResult(action, isAll)) } ), title, message, service ) @@ -509,8 +541,8 @@ private fun FileJob.getReadOnlyFileStore(path: Path, exception: IOException): Po return if (fileStore.isReadOnly) fileStore else null } -private class ActionResult( - val action: FileJobAction, +private class ErrorResult( + val action: FileJobErrorAction, val isAll: Boolean ) @@ -521,7 +553,7 @@ private fun FileJob.showConflictDialog( type: CopyMoveType ): ConflictResult = try { - runBlocking { + runBlocking { suspendCoroutine { continuation -> BackgroundActivityStarter.startActivity( FileJobConflictDialogActivity::class.createIntent().putArgs( @@ -530,8 +562,8 @@ private fun FileJob.showConflictDialog( ) { action, name, all -> continuation.resume(ConflictResult(action, name, all)) } - ), FileJobConflictDialogActivity.getTitle(sourceFile, targetFile, service), - FileJobConflictDialogActivity.getMessage(sourceFile, targetFile, type, service), + ), FileJobConflictDialogFragment.getTitle(sourceFile, targetFile, service), + FileJobConflictDialogFragment.getMessage(sourceFile, targetFile, type, service), service ) } @@ -582,8 +614,9 @@ private class ActionAllInfo( class ArchiveFileJob( private val sources: List, private val archiveFile: Path, - private val archiveType: String, - private val compressorType: String? + private val format: Int, + private val filter: Int, + private val password: String? ) : FileJob() { @Throws(IOException::class) override fun run() { @@ -594,16 +627,16 @@ class ArchiveFileJob( var successful = false try { channel.use { - ArchiveWriter(archiveType, compressorType, channel).use { writer -> + ArchiveWriter(channel, format, filter, password).use { writer -> val transferInfo = TransferInfo(scanInfo, archiveFile) for (source in sources) { val target = getTargetFileName(source) archiveRecursively(source, writer, target, transferInfo) throwIfInterrupted() } - successful = true } } + successful = true } finally { if (!successful) { try { @@ -673,7 +706,7 @@ private fun FileJob.archive( throw e } catch (e: IOException) { e.printStackTrace() - val result = showActionDialog( + val result = showErrorDialog( getString(R.string.file_job_archive_error_title_format, getFileName(file)), getString( R.string.file_job_archive_error_message_format, getFileName(archiveFile), @@ -686,7 +719,8 @@ private fun FileJob.archive( null ) when (result.action) { - FileJobAction.NEGATIVE, FileJobAction.CANCELED -> throw InterruptedIOException() + FileJobErrorAction.NEGATIVE, FileJobErrorAction.CANCELED -> + throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -866,7 +900,7 @@ class CreateFileJob(private val path: Path, private val createDirectory: Boolean @Throws(IOException::class) private fun FileJob.create(path: Path, createDirectory: Boolean) { var retry: Boolean - loop@ do { + do { retry = false try { if (createDirectory) { @@ -878,7 +912,14 @@ private fun FileJob.create(path: Path, createDirectory: Boolean) { throw e } catch (e: IOException) { e.printStackTrace() - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_create_error_title), getString( R.string.file_job_create_error_message_format, getFileName(path), e.toString() @@ -890,11 +931,12 @@ private fun FileJob.create(path: Path, createDirectory: Boolean) { null ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE, FileJobAction.CANCELED -> throw InterruptedIOException() + FileJobErrorAction.NEGATIVE, FileJobErrorAction.CANCELED -> + throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -953,7 +995,7 @@ class DeleteFileJob(private val paths: List) : FileJob() { @Throws(IOException::class) private fun FileJob.delete(path: Path, transferInfo: TransferInfo?, actionAllInfo: ActionAllInfo) { var retry: Boolean - loop@ do { + do { retry = false try { path.delete() @@ -972,7 +1014,14 @@ private fun FileJob.delete(path: Path, transferInfo: TransferInfo?, actionAllInf } return } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_delete_error_title), getString( R.string.file_job_delete_error_message_format, getFileName(path), e.toString() @@ -984,11 +1033,11 @@ private fun FileJob.delete(path: Path, transferInfo: TransferInfo?, actionAllInf getString(android.R.string.cancel) ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipDeleteError = true } @@ -998,14 +1047,14 @@ private fun FileJob.delete(path: Path, transferInfo: TransferInfo?, actionAllInf } return } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { if (transferInfo != null) { transferInfo.skipFileIgnoringSize() postDeleteNotification(transferInfo, path) } return } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -1152,7 +1201,7 @@ private fun FileJob.copyOrMove( postCopyMoveNotification(transferInfo, source, type) return false } - val result = showActionDialog( + val result = showErrorDialog( getString( type.getResourceId( R.string.file_job_cannot_copy_into_itself_title, @@ -1168,7 +1217,7 @@ private fun FileJob.copyOrMove( null ) return when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { if (result.isAll) { actionAllInfo.skipCopyMoveIntoItself = true } @@ -1176,12 +1225,12 @@ private fun FileJob.copyOrMove( postCopyMoveNotification(transferInfo, source, type) false } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFile(source) postCopyMoveNotification(transferInfo, source, type) false } - FileJobAction.NEGATIVE -> throw InterruptedIOException() + FileJobErrorAction.NEGATIVE -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -1192,7 +1241,7 @@ private fun FileJob.copyOrMove( postCopyMoveNotification(transferInfo, source, type) return false } - val result = showActionDialog( + val result = showErrorDialog( getString( type.getResourceId( R.string.file_job_cannot_copy_over_itself_title, @@ -1208,7 +1257,7 @@ private fun FileJob.copyOrMove( null ) return when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { if (result.isAll) { actionAllInfo.skipCopyMoveOverItself = true } @@ -1216,19 +1265,19 @@ private fun FileJob.copyOrMove( postCopyMoveNotification(transferInfo, source, type) false } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFile(source) postCopyMoveNotification(transferInfo, source, type) false } - FileJobAction.NEGATIVE -> throw InterruptedIOException() + FileJobErrorAction.NEGATIVE -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } var target = target var replaceExisting = false var retry: Boolean - loop@ do { + do { retry = false val options = mutableListOf().apply { this += LinkOption.NOFOLLOW_LINKS @@ -1293,13 +1342,13 @@ private fun FileJob.copyOrMove( } else { replaceExisting = true retry = true - continue@loop + continue } } FileJobConflictAction.RENAME -> { target = target.resolveSibling(result.name) retry = true - continue@loop + continue } FileJobConflictAction.SKIP -> { if (result.isAll) { @@ -1336,7 +1385,14 @@ private fun FileJob.copyOrMove( postCopyMoveNotification(transferInfo, source, type) return false } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString( type.getResourceId( R.string.file_job_copy_error_title_format, @@ -1358,11 +1414,11 @@ private fun FileJob.copyOrMove( getString(android.R.string.cancel) ) return when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipCopyMoveError = true } @@ -1370,12 +1426,12 @@ private fun FileJob.copyOrMove( postCopyMoveNotification(transferInfo, source, type) false } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFile(source) postCopyMoveNotification(transferInfo, source, type) false } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() } } } while (retry) @@ -1471,7 +1527,10 @@ private fun FileJob.open( val targetFile = cacheDirectory.resolveForeign(targetFileName) val transferInfo = TransferInfo(scanInfo, cacheDirectory) val actionAllInfo = ActionAllInfo(replace = true) - copy(file, targetFile, isExtract, transferInfo, actionAllInfo) + val copied = copy(file, targetFile, isExtract, transferInfo, actionAllInfo) + if (!copied) { + return + } BackgroundActivityStarter.startActivity( intentCreator(targetFile), getString(notificationTitleFormatRes, targetFileName), getString(notificationTextRes), service @@ -1489,7 +1548,7 @@ class RenameFileJob(private val path: Path, private val newName: String) : FileJ @Throws(IOException::class) private fun FileJob.rename(path: Path, newPath: Path) { var retry: Boolean - loop@ do { + do { retry = false try { moveAtomically(path, newPath) @@ -1497,7 +1556,14 @@ private fun FileJob.rename(path: Path, newPath: Path) { throw e } catch (e: IOException) { e.printStackTrace() - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_rename_error_title_format, getFileName(path)), getString( R.string.file_job_rename_error_message_format, getFileName(newPath), @@ -1510,11 +1576,12 @@ private fun FileJob.rename(path: Path, newPath: Path) { null ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE, FileJobAction.CANCELED -> throw InterruptedIOException() + FileJobErrorAction.NEGATIVE, FileJobErrorAction.CANCELED -> + throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -1573,7 +1640,7 @@ private fun FileJob.restoreSeLinuxContext( actionAllInfo: ActionAllInfo ) { var retry: Boolean - loop@ do { + do { retry = false try { val options = if (followLinks) arrayOf() else arrayOf(LinkOption.NOFOLLOW_LINKS) @@ -1589,7 +1656,14 @@ private fun FileJob.restoreSeLinuxContext( postRestoreSeLinuxContextNotification(transferInfo, path) return } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_restore_selinux_context_error_title), getString( R.string.file_job_restore_selinux_context_error_message_format, @@ -1602,11 +1676,11 @@ private fun FileJob.restoreSeLinuxContext( getString(android.R.string.cancel) ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipRestoreSeLinuxContextError = true } @@ -1614,12 +1688,12 @@ private fun FileJob.restoreSeLinuxContext( postRestoreSeLinuxContextNotification(transferInfo, path) return } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFileIgnoringSize() postRestoreSeLinuxContextNotification(transferInfo, path) return } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -1637,6 +1711,27 @@ private fun FileJob.postRestoreSeLinuxContextNotification( ) } +class SaveFileJob(private val source: Path, private val target: Path) : FileJob() { + override fun run() { + save(source, target) + } +} + +@Throws(IOException::class) +private fun FileJob.save(source: Path, target: Path) { + val scanInfo = scan(source, R.plurals.file_job_copy_scan_notification_title_format) + val targetParent = target.parent + val transferInfo = TransferInfo(scanInfo, targetParent) + val actionAllInfo = ActionAllInfo(replace = true) + val copied = copy(source, target, false, transferInfo, actionAllInfo) + if (!copied) { + return + } + showToast( + getString(R.string.save_as_success_format, getFileName(target), getFileName(targetParent)) + ) +} + class SetFileGroupJob( private val path: Path, private val group: PosixGroup, @@ -1690,7 +1785,7 @@ private fun FileJob.setGroup( actionAllInfo: ActionAllInfo ) { var retry: Boolean - loop@ do { + do { retry = false try { val options = if (followLinks) arrayOf() else arrayOf(LinkOption.NOFOLLOW_LINKS) @@ -1706,7 +1801,14 @@ private fun FileJob.setGroup( postSetGroupNotification(transferInfo, path) return } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_set_group_error_title_format, getFileName(path)), getString( R.string.file_job_set_group_error_message_format, getPrincipalName(group), @@ -1719,11 +1821,11 @@ private fun FileJob.setGroup( getString(android.R.string.cancel) ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipSetGroupError = true } @@ -1731,12 +1833,12 @@ private fun FileJob.setGroup( postSetGroupNotification(transferInfo, path) return } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFileIgnoringSize() postSetGroupNotification(transferInfo, path) return } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -1829,7 +1931,7 @@ private fun FileJob.setMode( actionAllInfo: ActionAllInfo ) { var retry: Boolean - loop@ do { + do { retry = false try { // This will always follow symbolic links. @@ -1845,7 +1947,14 @@ private fun FileJob.setMode( postSetModeNotification(transferInfo, path) return } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_set_mode_error_title_format, getFileName(path)), getString( R.string.file_job_set_mode_error_message_format, mode.toModeString(), @@ -1858,11 +1967,11 @@ private fun FileJob.setMode( getString(android.R.string.cancel) ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipSetModeError = true } @@ -1870,12 +1979,12 @@ private fun FileJob.setMode( postSetModeNotification(transferInfo, path) return } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFileIgnoringSize() postSetModeNotification(transferInfo, path) return } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -1942,7 +2051,7 @@ private fun FileJob.setOwner( actionAllInfo: ActionAllInfo ) { var retry: Boolean - loop@ do { + do { retry = false try { val options = if (followLinks) arrayOf() else arrayOf(LinkOption.NOFOLLOW_LINKS) @@ -1958,7 +2067,14 @@ private fun FileJob.setOwner( postSetOwnerNotification(transferInfo, path) return } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_set_owner_error_title_format, getFileName(path)), getString( R.string.file_job_set_owner_error_message_format, getPrincipalName(owner), @@ -1971,11 +2087,11 @@ private fun FileJob.setOwner( getString(android.R.string.cancel) ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipSetOwnerError = true } @@ -1983,12 +2099,12 @@ private fun FileJob.setOwner( postSetOwnerNotification(transferInfo, path) return } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFileIgnoringSize() postSetOwnerNotification(transferInfo, path) return } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -2063,7 +2179,7 @@ private fun FileJob.setSeLinuxContext( actionAllInfo: ActionAllInfo ) { var retry: Boolean - loop@ do { + do { retry = false try { val options = if (followLinks) arrayOf() else arrayOf(LinkOption.NOFOLLOW_LINKS) @@ -2079,7 +2195,14 @@ private fun FileJob.setSeLinuxContext( postSetSeLinuxContextNotification(transferInfo, path) return } - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString( R.string.file_job_set_selinux_context_error_title_format, getFileName(path) ), @@ -2094,11 +2217,11 @@ private fun FileJob.setSeLinuxContext( getString(android.R.string.cancel) ) when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE -> { + FileJobErrorAction.NEGATIVE -> { if (result.isAll) { actionAllInfo.skipSetSeLinuxContextError = true } @@ -2106,12 +2229,12 @@ private fun FileJob.setSeLinuxContext( postSetSeLinuxContextNotification(transferInfo, path) return } - FileJobAction.CANCELED -> { + FileJobErrorAction.CANCELED -> { transferInfo.skipFileIgnoringSize() postSetSeLinuxContextNotification(transferInfo, path) return } - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() else -> throw AssertionError(result.action) } } @@ -2148,7 +2271,7 @@ private fun FileJob.write(file: Path, content: ByteArray): Boolean { addToSize(content.size.toLong()) } var retry: Boolean - loop@ do { + do { retry = false val transferInfo = TransferInfo(scanInfo, file) try { @@ -2163,7 +2286,14 @@ private fun FileJob.write(file: Path, content: ByteArray): Boolean { throw e } catch (e: IOException) { e.printStackTrace() - val result = showActionDialog( + if (e is UserActionRequiredException) { + val result = showUserAction(e) + if (result) { + retry = true + continue + } + } + val result = showErrorDialog( getString(R.string.file_job_write_error_title, getFileName(file)), getString( R.string.file_job_write_error_message_format, getFileName(file), e.toString() @@ -2175,12 +2305,12 @@ private fun FileJob.write(file: Path, content: ByteArray): Boolean { null ) return when (result.action) { - FileJobAction.POSITIVE -> { + FileJobErrorAction.POSITIVE -> { retry = true - continue@loop + continue } - FileJobAction.NEGATIVE, FileJobAction.CANCELED -> false - FileJobAction.NEUTRAL -> throw InterruptedIOException() + FileJobErrorAction.NEGATIVE, FileJobErrorAction.CANCELED -> false + FileJobErrorAction.NEUTRAL -> throw InterruptedIOException() } } } while (retry) diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/BreadcrumbLayout.kt b/app/src/main/java/me/zhanghai/android/files/filelist/BreadcrumbLayout.kt index e793f1dd8..34bbd6c9e 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/BreadcrumbLayout.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/BreadcrumbLayout.kt @@ -36,8 +36,9 @@ class BreadcrumbLayout : HorizontalScrollView { context.getColorByAttr(android.R.attr.textColorSecondary) ) ) - private val popupContext = - context.withTheme(context.getResourceIdByAttr(R.attr.actionBarPopupTheme)) + private val popupContext = context.withTheme( + context.getResourceIdByAttr(androidx.appcompat.R.attr.actionBarPopupTheme) + ) private val itemsLayout: LinearLayout @@ -87,15 +88,18 @@ class BreadcrumbLayout : HorizontalScrollView { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val heightMode = MeasureSpec.getMode(heightMeasureSpec) - var heightMeasureSpec = heightMeasureSpec - if (heightMode == MeasureSpec.UNSPECIFIED || heightMode == MeasureSpec.AT_MOST) { - var height = tabLayoutHeight - if (heightMode == MeasureSpec.AT_MOST) { - height = height.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec)) + val newHeightMeasureSpec = if (heightMode != MeasureSpec.EXACTLY) { + val maximumHeight = if (heightMode == MeasureSpec.AT_MOST) { + MeasureSpec.getSize(heightMeasureSpec) + } else { + Int.MAX_VALUE } - heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + val height = tabLayoutHeight.coerceAtMost(maximumHeight) + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + } else { + heightMeasureSpec } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) + super.onMeasure(widthMeasureSpec, newHeightMeasureSpec) } override fun requestLayout() { @@ -150,10 +154,10 @@ class BreadcrumbLayout : HorizontalScrollView { private fun inflateItemViews() { // HACK: Remove/add views at the front so that ripple remains correct, as we are potentially // collapsing/expanding breadcrumbs at the front. - for (index in data.paths.size until itemsLayout.childCount) { + for (index in data.paths.size.. + * All Rights Reserved. + */ + +package me.zhanghai.android.files.filelist + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.R +import me.zhanghai.android.files.file.FileItem +import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.putArgs +import me.zhanghai.android.files.util.show + +class ConfirmReplaceFileDialogFragment : AppCompatDialogFragment() { + private val args by args() + + private val listener: Listener + get() = requireParentFragment() as Listener + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val file = args.file + return MaterialAlertDialogBuilder(requireContext(), theme) + .setMessage(getString(R.string.file_replace_message_format, file.name)) + .setPositiveButton(android.R.string.ok) { _, _ -> listener.replaceFile(file) } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + companion object { + fun show(file: FileItem, fragment: Fragment) { + ConfirmReplaceFileDialogFragment().putArgs(Args(file)).show(fragment) + } + } + + @Parcelize + class Args(val file: FileItem) : ParcelableArgs + + interface Listener { + fun replaceFile(file: FileItem) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/CreateArchiveDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/CreateArchiveDialogFragment.kt index a3a0f6224..08e10d092 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/CreateArchiveDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/CreateArchiveDialogFragment.kt @@ -12,7 +12,9 @@ import android.view.View import android.widget.EditText import android.widget.RadioGroup import androidx.annotation.StringRes +import androidx.core.view.isGone import androidx.fragment.app.Fragment +import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import kotlinx.parcelize.Parcelize import me.zhanghai.android.files.R @@ -23,8 +25,8 @@ import me.zhanghai.android.files.util.args import me.zhanghai.android.files.util.putArgs import me.zhanghai.android.files.util.setTextWithSelection import me.zhanghai.android.files.util.show -import org.apache.commons.compress.archivers.ArchiveStreamFactory -import org.apache.commons.compress.compressors.CompressorStreamFactory +import me.zhanghai.android.files.util.takeIfNotEmpty +import me.zhanghai.android.libarchive.Archive class CreateArchiveDialogFragment : FileNameDialogFragment() { private val args by args() @@ -51,6 +53,8 @@ class CreateArchiveDialogFragment : FileNameDialogFragment() { } name?.let { binding.nameEdit.setTextWithSelection(it) } } + binding.typeGroup.setOnCheckedChangeListener { _, _ -> updatePasswordLayoutVisibility() } + updatePasswordLayoutVisibility() return dialog } @@ -62,34 +66,39 @@ class CreateArchiveDialogFragment : FileNameDialogFragment() { override val name: String get() { - val extension = when (val typeId = binding.typeGroup.checkedRadioButtonId) { + val extension = when (val checkedId = binding.typeGroup.checkedRadioButtonId) { R.id.zipRadio -> "zip" R.id.tarXzRadio -> "tar.xz" R.id.sevenZRadio -> "7z" - else -> throw AssertionError(typeId) + else -> throw AssertionError(checkedId) } return "${super.name}.$extension" } + private val isPasswordSupported: Boolean + get() = when (val checkedId = binding.typeGroup.checkedRadioButtonId) { + R.id.zipRadio -> true + R.id.tarXzRadio, R.id.sevenZRadio -> false + else -> throw AssertionError(checkedId) + } + + private fun updatePasswordLayoutVisibility() { + binding.passwordLayout.isGone = !isPasswordSupported + } + override fun onOk(name: String) { - val archiveType: String - val compressorType: String? - when (val typeId = binding.typeGroup.checkedRadioButtonId) { - R.id.zipRadio -> { - archiveType = ArchiveStreamFactory.ZIP - compressorType = null - } - R.id.tarXzRadio -> { - archiveType = ArchiveStreamFactory.TAR - compressorType = CompressorStreamFactory.XZ - } - R.id.sevenZRadio -> { - archiveType = ArchiveStreamFactory.SEVEN_Z - compressorType = null - } - else -> throw AssertionError(typeId) + val (format, filter) = when (val checkedId = binding.typeGroup.checkedRadioButtonId) { + R.id.zipRadio -> Archive.FORMAT_ZIP to Archive.FILTER_NONE + R.id.tarXzRadio -> Archive.FORMAT_TAR to Archive.FILTER_XZ + R.id.sevenZRadio -> Archive.FORMAT_7ZIP to Archive.FILTER_NONE + else -> throw AssertionError(checkedId) + } + val password = if (isPasswordSupported) { + binding.passwordEdit.text!!.toString().takeIfNotEmpty() + } else { + null } - listener.archive(args.files, name, archiveType, compressorType) + listener.archive(args.files, name, format, filter, password) } companion object { @@ -105,7 +114,9 @@ class CreateArchiveDialogFragment : FileNameDialogFragment() { root: View, nameLayout: TextInputLayout, nameEdit: EditText, - val typeGroup: RadioGroup + val typeGroup: RadioGroup, + val passwordLayout: TextInputLayout, + val passwordEdit: TextInputEditText ) : NameDialogFragment.Binding(root, nameLayout, nameEdit) { companion object { fun inflate(inflater: LayoutInflater): Binding { @@ -113,13 +124,14 @@ class CreateArchiveDialogFragment : FileNameDialogFragment() { val bindingRoot = binding.root val nameBinding = NameDialogNameIncludeBinding.bind(bindingRoot) return Binding( - bindingRoot, nameBinding.nameLayout, nameBinding.nameEdit, binding.typeGroup + bindingRoot, nameBinding.nameLayout, nameBinding.nameEdit, binding.typeGroup, + binding.passwordLayout, binding.passwordEdit ) } } } interface Listener : FileNameDialogFragment.Listener { - fun archive(files: FileItemSet, name: String, archiveType: String, compressorType: String?) + fun archive(files: FileItemSet, name: String, format: Int, filter: Int, password: String?) } } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileItemExtensions.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileItemExtensions.kt index 76b94dfba..1be1a109e 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileItemExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileItemExtensions.kt @@ -21,12 +21,12 @@ import me.zhanghai.android.files.file.isPdf import me.zhanghai.android.files.provider.archive.createArchiveRootPath import me.zhanghai.android.files.provider.document.documentSupportsThumbnail import me.zhanghai.android.files.provider.document.isDocumentPath -import me.zhanghai.android.files.provider.document.resolver.DocumentResolver import me.zhanghai.android.files.provider.ftp.isFtpPath import me.zhanghai.android.files.provider.linux.isLinuxPath import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.util.asFileName import me.zhanghai.android.files.util.isGetPackageArchiveInfoCompatible +import me.zhanghai.android.files.util.isMediaMetadataRetrieverCompatible import me.zhanghai.android.files.util.valueCompat import java.text.CollationKey @@ -61,17 +61,17 @@ val FileItem.supportsThumbnail: Boolean if (path.isDocumentPath && attributes.documentSupportsThumbnail) { return true } - val isLocalPath = path.isLinuxPath - || (path.isDocumentPath && DocumentResolver.isLocal(path as DocumentResolver.Path)) - val shouldReadRemotePath = !path.isFtpPath - && Settings.READ_REMOTE_FILES_FOR_THUMBNAIL.valueCompat - if (!(isLocalPath || shouldReadRemotePath)) { - return false + if (path.isRemotePath) { + val shouldReadRemotePath = !path.isFtpPath + && Settings.READ_REMOTE_FILES_FOR_THUMBNAIL.valueCompat + if (!shouldReadRemotePath) { + return false + } } return when { mimeType.isApk && path.isGetPackageArchiveInfoCompatible -> true mimeType.isImage -> true - mimeType.isMedia && (path.isLinuxPath || path.isDocumentPath) -> true + mimeType.isMedia && path.isMediaMetadataRetrieverCompatible -> true mimeType.isPdf && (path.isLinuxPath || path.isDocumentPath) -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.P || Settings.SHOW_PDF_THUMBNAIL_PRE_28.valueCompat diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileListActivity.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileListActivity.kt index 82315a029..982342861 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileListActivity.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileListActivity.kt @@ -8,6 +8,7 @@ package me.zhanghai.android.files.filelist import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.KeyEvent import android.view.View import androidx.activity.result.contract.ActivityResultContract import androidx.fragment.app.commit @@ -35,11 +36,11 @@ class FileListActivity : AppActivity() { } } - override fun onBackPressed() { - if (fragment.onBackPressed()) { - return + override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { + if (fragment.onKeyShortcut(keyCode, event)) { + return true } - super.onBackPressed() + return super.onKeyUp(keyCode, event) } companion object { @@ -49,22 +50,41 @@ class FileListActivity : AppActivity() { .apply { extraPath = path } } - class PickDirectoryContract : ActivityResultContract() { - override fun createIntent(context: Context, input: Path?): Intent = + class OpenFileContract : ActivityResultContract, Path?>() { + override fun createIntent(context: Context, input: List): Intent = FileListActivity::class.createIntent() - .setAction(Intent.ACTION_OPEN_DOCUMENT_TREE) - .apply { input?.let { extraPath = it } } + .setAction(Intent.ACTION_OPEN_DOCUMENT) + .setType(MimeType.ANY.value) + .addCategory(Intent.CATEGORY_OPENABLE) + .putExtra(Intent.EXTRA_MIME_TYPES, input.map { it.value }.toTypedArray()) override fun parseResult(resultCode: Int, intent: Intent?): Path? = if (resultCode == RESULT_OK) intent?.extraPath else null } - class PickFileContract : ActivityResultContract, Path?>() { - override fun createIntent(context: Context, input: List): Intent = + class CreateFileContract : ActivityResultContract, Path?>() { + override fun createIntent( + context: Context, + input: Triple + ): Intent = FileListActivity::class.createIntent() - .setAction(Intent.ACTION_OPEN_DOCUMENT) - .setType(MimeType.ANY.value) - .putExtra(Intent.EXTRA_MIME_TYPES, input.map { it.value }.toTypedArray()) + .setAction(Intent.ACTION_CREATE_DOCUMENT) + .setType(input.first.value) + .addCategory(Intent.CATEGORY_OPENABLE) + .apply { + input.second?.let { putExtra(Intent.EXTRA_TITLE, it) } + input.third?.let { extraPath = it } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Path? = + if (resultCode == RESULT_OK) intent?.extraPath else null + } + + class OpenDirectoryContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Path?): Intent = + FileListActivity::class.createIntent() + .setAction(Intent.ACTION_OPEN_DOCUMENT_TREE) + .apply { input?.let { extraPath = it } } override fun parseResult(resultCode: Int, intent: Intent?): Path? = if (resultCode == RESULT_OK) intent?.extraPath else null diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileListAdapter.kt index 3f98f6d3c..0d300aed3 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileListAdapter.kt @@ -6,26 +6,38 @@ package me.zhanghai.android.files.filelist import android.text.TextUtils +import android.view.View import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView import androidx.appcompat.widget.PopupMenu import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil.clear -import coil.loadAny +import coil.dispose +import coil.load import java8.nio.file.Path import me.zhanghai.android.fastscroll.PopupTextProvider import me.zhanghai.android.files.R import me.zhanghai.android.files.coil.AppIconPackageName -import me.zhanghai.android.files.databinding.FileItemBinding +import me.zhanghai.android.files.compat.foregroundCompat +import me.zhanghai.android.files.compat.getDrawableCompat +import me.zhanghai.android.files.compat.isSingleLineCompat +import me.zhanghai.android.files.databinding.FileItemGridBinding +import me.zhanghai.android.files.databinding.FileItemListBinding import me.zhanghai.android.files.file.FileItem import me.zhanghai.android.files.file.fileSize import me.zhanghai.android.files.file.formatShort import me.zhanghai.android.files.file.iconRes +import me.zhanghai.android.files.file.isApk import me.zhanghai.android.files.provider.archive.isArchivePath +import me.zhanghai.android.files.provider.common.isEncrypted import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.ui.AnimatedListAdapter +import me.zhanghai.android.files.ui.CheckableForegroundLinearLayout import me.zhanghai.android.files.ui.CheckableItemBackground +import me.zhanghai.android.files.util.isMaterial3Theme import me.zhanghai.android.files.util.layoutInflater import me.zhanghai.android.files.util.valueCompat import java.util.Locale @@ -35,13 +47,24 @@ class FileListAdapter( ) : AnimatedListAdapter(CALLBACK), PopupTextProvider { private var isSearching = false - private lateinit var _comparator: Comparator - var comparator: Comparator - get() = _comparator + private lateinit var _viewType: FileViewType + var viewType: FileViewType + get() = _viewType set(value) { - _comparator = value + _viewType = value if (!isSearching) { - super.replace(list.sortedWith(value), true) + super.replace(list, true) + } + } + + private lateinit var _sortOptions: FileSortOptions + var sortOptions: FileSortOptions + get() = _sortOptions + set(value) { + _sortOptions = value + if (!isSearching) { + val sortedList = list.sortedWith(value.createComparator()) + super.replace(sortedList, true) rebuildFilePositionMap() } } @@ -100,7 +123,7 @@ class FileListAdapter( fun selectAllFiles() { val files = fileItemSetOf() - for (index in 0 until itemCount) { + for (index in 0.. + !file.attributes.isDirectory && + pickOptions.mimeTypes.any { it.match(file.mimeType) } + PickOptions.Mode.OPEN_DIRECTORY -> file.attributes.isDirectory } } @@ -132,28 +156,55 @@ class FileListAdapter( fun replaceListAndIsSearching(list: List, isSearching: Boolean) { val clear = this.isSearching != isSearching this.isSearching = isSearching - super.replace(if (!isSearching) list.sortedWith(comparator) else list, clear) + val sortedList = if (!isSearching) list.sortedWith(sortOptions.createComparator()) else list + super.replace(sortedList, clear) rebuildFilePositionMap() } private fun rebuildFilePositionMap() { filePositionMap.clear() - for (index in 0 until itemCount) { + for (index in 0.. ViewHolder(FileItemListBinding.inflate(inflater, parent, false)) + FileViewType.GRID -> ViewHolder(FileItemGridBinding.inflate(inflater, parent, false)) + } + return holder.apply { + itemLayout.apply { + val context = context + val isMaterial3Theme = context.isMaterial3Theme + if (viewType == FileViewType.GRID && isMaterial3Theme) { + foregroundCompat = + context.getDrawableCompat(R.drawable.file_item_grid_foreground_material3) + } + background = if (viewType == FileViewType.GRID && isMaterial3Theme) { + CheckableItemBackground.create(4f, 12f, context) + } else { + CheckableItemBackground.create(0f, 0f, context) + } + } + thumbnailOutlineView?.apply { + val context = context + if (context.isMaterial3Theme) { + background = context.getDrawableCompat( + R.drawable.file_item_grid_thumbnail_outline_material3 + ) + } + } + popupMenu = PopupMenu(menuButton.context, menuButton) .apply { inflate(R.menu.file_item) } - binding.menuButton.setOnClickListener { popupMenu.show() } + menuButton.setOnClickListener { popupMenu.show() } } + } override fun onBindViewHolder(holder: ViewHolder, position: Int) { throw UnsupportedOperationException() @@ -161,11 +212,10 @@ class FileListAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List) { val file = getItem(position) - val binding = holder.binding val isDirectory = file.attributes.isDirectory - val enabled = isFileSelectable(file) || isDirectory - binding.itemLayout.isEnabled = enabled - binding.menuButton.isEnabled = enabled + val isEnabled = isFileSelectable(file) || isDirectory + holder.itemLayout.isEnabled = isEnabled + holder.menuButton.isEnabled = isEnabled val menu = holder.popupMenu.menu val path = file.path val hasPickOptions = pickOptions != null @@ -173,69 +223,104 @@ class FileListAdapter( menu.findItem(R.id.action_cut).isVisible = !hasPickOptions && !isReadOnly menu.findItem(R.id.action_copy).isVisible = !hasPickOptions val checked = file in selectedFiles - binding.itemLayout.isChecked = checked - val nameEllipsize = nameEllipsize - binding.nameText.ellipsize = nameEllipsize - binding.nameText.isSelected = nameEllipsize == TextUtils.TruncateAt.MARQUEE + holder.itemLayout.isChecked = checked + holder.nameText.apply { + if (isSingleLineCompat) { + val nameEllipsize = nameEllipsize + ellipsize = nameEllipsize + isSelected = nameEllipsize == TextUtils.TruncateAt.MARQUEE + } + } if (payloads.isNotEmpty()) { return } bindViewHolderAnimation(holder) - binding.itemLayout.setOnClickListener { - if (selectedFiles.isEmpty()) { - listener.openFile(file) - } else { - selectFile(file) + holder.itemLayout.apply { + setOnClickListener { + if (selectedFiles.isEmpty()) { + listener.openFile(file) + } else { + selectFile(file) + } } - } - binding.itemLayout.setOnLongClickListener { - if (selectedFiles.isEmpty()) { - selectFile(file) - } else { - listener.openFile(file) + setOnLongClickListener { + if (selectedFiles.isEmpty()) { + selectFile(file) + } else { + listener.openFile(file) + } + true } - true } - binding.iconLayout.setOnClickListener { selectFile(file) } - binding.iconImage.setImageResource(file.mimeType.iconRes) - binding.iconImage.isVisible = true - binding.thumbnailImage.clear() - binding.thumbnailImage.setImageDrawable(null) + holder.iconLayout.setOnClickListener { selectFile(file) } + val iconRes = file.mimeType.iconRes + holder.iconImage.apply { + isVisible = true + setImageResource(iconRes) + } + holder.directoryThumbnailImage?.isVisible = isDirectory + holder.thumbnailOutlineView?.isVisible = !isDirectory val supportsThumbnail = file.supportsThumbnail - binding.thumbnailImage.isVisible = supportsThumbnail + val shouldLoadThumbnailIcon = supportsThumbnail && holder.thumbnailIconImage != null && + file.mimeType.isApk val attributes = file.attributes - if (supportsThumbnail) { - binding.thumbnailImage.loadAny(path to attributes) { - listener { _, _ -> binding.iconImage.isVisible = false } + holder.thumbnailIconImage?.apply { + dispose() + isVisible = !isDirectory + setImageResource(iconRes) + if (shouldLoadThumbnailIcon) { + load(path to attributes) } } - binding.appIconBadgeImage.clear() - binding.appIconBadgeImage.setImageDrawable(null) - val appDirectoryPackageName = file.appDirectoryPackageName - val hasAppIconBadge = appDirectoryPackageName != null - binding.appIconBadgeImage.isVisible = hasAppIconBadge - if (hasAppIconBadge) { - binding.appIconBadgeImage.loadAny(AppIconPackageName(appDirectoryPackageName!!)) + holder.thumbnailImage.apply { + dispose() + setImageDrawable(null) + val shouldLoadThumbnail = supportsThumbnail && !shouldLoadThumbnailIcon + isVisible = shouldLoadThumbnail + if (shouldLoadThumbnail) { + load(path to attributes) { + listener { _, _ -> + val iconImage = holder.thumbnailIconImage ?: holder.iconImage + iconImage.isVisible = false + } + } + } } - val badgeIconRes = if (file.attributesNoFollowLinks.isSymbolicLink) { - if (file.isSymbolicLinkBroken) { - R.drawable.error_badge_icon_18dp - } else { - R.drawable.symbolic_link_badge_icon_18dp + holder.appIconBadgeImage.apply { + dispose() + setImageDrawable(null) + val appDirectoryPackageName = file.appDirectoryPackageName + val hasAppIconBadge = appDirectoryPackageName != null + isVisible = hasAppIconBadge + if (hasAppIconBadge) { + load(AppIconPackageName(appDirectoryPackageName!!)) } - } else { - null } - val hasBadge = badgeIconRes != null - binding.badgeImage.isVisible = hasBadge - if (hasBadge) { - binding.badgeImage.setImageResource(badgeIconRes!!) + holder.badgeImage.apply { + val badgeIconRes = if (file.attributesNoFollowLinks.isSymbolicLink) { + if (file.isSymbolicLinkBroken) { + R.drawable.error_badge_icon_18dp + } else { + R.drawable.symbolic_link_badge_icon_18dp + } + } else if (file.attributesNoFollowLinks.isEncrypted()) { + R.drawable.encrypted_badge_icon_18dp + } else { + null + } + val hasBadge = badgeIconRes != null + isVisible = hasBadge + if (hasBadge) { + setImageResource(badgeIconRes!!) + } else { + setImageDrawable(null) + } } - binding.nameText.text = file.name - binding.descriptionText.text = if (isDirectory) { + holder.nameText.text = file.name + holder.descriptionText?.text = if (isDirectory) { null } else { - val context = binding.descriptionText.context + val context = holder.descriptionText!!.context val lastModificationTime = attributes.lastModifiedTime().toInstant() .formatShort(context) val size = attributes.fileSize.formatHumanReadable(context) @@ -305,9 +390,15 @@ class FileListAdapter( } } - override fun getPopupText(position: Int): String { + override fun getPopupText(view: View, position: Int): CharSequence { val file = getItem(position) - return file.name.take(1).uppercase(Locale.getDefault()) + return when (sortOptions.by) { + FileSortOptions.By.NAME -> file.name.take(1).uppercase(Locale.getDefault()) + FileSortOptions.By.TYPE -> file.extension.uppercase(Locale.getDefault()) + FileSortOptions.By.SIZE -> file.attributes.fileSize.formatHumanReadable(view.context) + FileSortOptions.By.LAST_MODIFIED -> + file.attributes.lastModifiedTime().toInstant().formatShort(view.context) + } } override val isAnimationEnabled: Boolean @@ -325,7 +416,53 @@ class FileListAdapter( } } - class ViewHolder(val binding: FileItemBinding) : RecyclerView.ViewHolder(binding.root) { + class ViewHolder private constructor( + root: View, + val itemLayout: CheckableForegroundLinearLayout, + val iconLayout: View, + val iconImage: ImageView, + val directoryThumbnailImage: ImageView?, + val thumbnailOutlineView: View?, + val thumbnailIconImage: ImageView?, + val thumbnailImage: ImageView, + val appIconBadgeImage: ImageView, + val badgeImage: ImageView, + val nameText: TextView, + val descriptionText: TextView?, + val menuButton: ImageButton + ) : RecyclerView.ViewHolder(root) { + constructor(binding: FileItemListBinding) : this( + binding.root, + binding.itemLayout, + binding.iconLayout, + binding.iconImage, + null, + null, + null, + binding.thumbnailImage, + binding.appIconBadgeImage, + binding.badgeImage, + binding.nameText, + binding.descriptionText, + binding.menuButton + ) + + constructor(binding: FileItemGridBinding) : this( + binding.root, + binding.itemLayout, + binding.iconLayout, + binding.iconImage, + binding.directoryThumbnailImage, + binding.thumbnailOutlineView, + binding.thumbnailIconImage, + binding.thumbnailImage, + binding.appIconBadgeImage, + binding.badgeImage, + binding.nameText, + null, + binding.menuButton + ) + lateinit var popupMenu: PopupMenu } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt index 72c7b06ac..5c02a1c79 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileListFragment.kt @@ -17,14 +17,18 @@ import android.os.Environment import android.os.Handler import android.os.Looper import android.text.TextUtils +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.EditText import android.widget.ProgressBar import android.widget.TextView +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi @@ -35,6 +39,7 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.view.GravityCompat +import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment @@ -43,7 +48,6 @@ import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener import com.leinardi.android.speeddial.SpeedDialView import java8.nio.file.Path import java8.nio.file.Paths @@ -52,6 +56,7 @@ import me.zhanghai.android.files.R import me.zhanghai.android.files.app.application import me.zhanghai.android.files.app.clipboardManager import me.zhanghai.android.files.compat.checkSelfPermissionCompat +import me.zhanghai.android.files.compat.setGroupDividerEnabledCompat import me.zhanghai.android.files.databinding.FileListFragmentAppBarIncludeBinding import me.zhanghai.android.files.databinding.FileListFragmentBinding import me.zhanghai.android.files.databinding.FileListFragmentBottomBarIncludeBinding @@ -61,6 +66,7 @@ import me.zhanghai.android.files.databinding.FileListFragmentSpeedDialIncludeBin import me.zhanghai.android.files.file.FileItem import me.zhanghai.android.files.file.MimeType import me.zhanghai.android.files.file.asMimeTypeOrNull +import me.zhanghai.android.files.file.extension import me.zhanghai.android.files.file.fileProviderUri import me.zhanghai.android.files.file.isApk import me.zhanghai.android.files.file.isImage @@ -79,12 +85,14 @@ import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.terminal.Terminal import me.zhanghai.android.files.ui.AppBarLayoutExpandHackListener import me.zhanghai.android.files.ui.CoordinatorAppBarLayout +import me.zhanghai.android.files.ui.DrawerLayoutOnBackPressedCallback import me.zhanghai.android.files.ui.FixQueryChangeSearchView import me.zhanghai.android.files.ui.OverlayToolbarActionMode import me.zhanghai.android.files.ui.PersistentBarLayout import me.zhanghai.android.files.ui.PersistentBarLayoutToolbarActionMode import me.zhanghai.android.files.ui.PersistentDrawerLayout import me.zhanghai.android.files.ui.ScrollingViewOnApplyWindowInsetsListener +import me.zhanghai.android.files.ui.SpeedDialViewOnBackPressedCallback import me.zhanghai.android.files.ui.ThemedFastScroller import me.zhanghai.android.files.ui.ToolbarActionMode import me.zhanghai.android.files.util.DebouncedRunnable @@ -93,35 +101,45 @@ import me.zhanghai.android.files.util.Loading import me.zhanghai.android.files.util.ParcelableArgs import me.zhanghai.android.files.util.Stateful import me.zhanghai.android.files.util.Success +import me.zhanghai.android.files.util.addOnBackPressedCallback import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.asFileName +import me.zhanghai.android.files.util.asFileNameOrNull import me.zhanghai.android.files.util.checkSelfPermission import me.zhanghai.android.files.util.copyText import me.zhanghai.android.files.util.create import me.zhanghai.android.files.util.createInstallPackageIntent import me.zhanghai.android.files.util.createIntent +import me.zhanghai.android.files.util.createManageAppAllFilesAccessPermissionIntent import me.zhanghai.android.files.util.createSendStreamIntent import me.zhanghai.android.files.util.createViewIntent import me.zhanghai.android.files.util.extraPath import me.zhanghai.android.files.util.extraPathList import me.zhanghai.android.files.util.fadeToVisibilityUnsafe +import me.zhanghai.android.files.util.getDimensionDp import me.zhanghai.android.files.util.getQuantityString import me.zhanghai.android.files.util.hasSw600Dp import me.zhanghai.android.files.util.isOrientationLandscape import me.zhanghai.android.files.util.putArgs +import me.zhanghai.android.files.util.setOnEditorConfirmActionListener import me.zhanghai.android.files.util.showToast import me.zhanghai.android.files.util.startActivitySafe +import me.zhanghai.android.files.util.supportsExternalStorageManager import me.zhanghai.android.files.util.takeIfNotEmpty import me.zhanghai.android.files.util.valueCompat import me.zhanghai.android.files.util.viewModels import me.zhanghai.android.files.util.withChooser import me.zhanghai.android.files.viewer.image.ImageViewerActivity +import kotlin.math.roundToInt class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter.Listener, - OpenApkDialogFragment.Listener, ConfirmDeleteFilesDialogFragment.Listener, - CreateArchiveDialogFragment.Listener, RenameFileDialogFragment.Listener, - CreateFileDialogFragment.Listener, CreateDirectoryDialogFragment.Listener, - NavigateToPathDialogFragment.Listener, NavigationFragment.Listener, - ShowRequestAllFilesAccessRationaleDialogFragment.Listener, + ConfirmReplaceFileDialogFragment.Listener, OpenApkDialogFragment.Listener, + ConfirmDeleteFilesDialogFragment.Listener, CreateArchiveDialogFragment.Listener, + RenameFileDialogFragment.Listener, CreateFileDialogFragment.Listener, + CreateDirectoryDialogFragment.Listener, NavigateToPathDialogFragment.Listener, + NavigationFragment.Listener, ShowRequestAllFilesAccessRationaleDialogFragment.Listener, + ShowRequestNotificationPermissionRationaleDialogFragment.Listener, + ShowRequestNotificationPermissionInSettingsRationaleDialogFragment.Listener, ShowRequestStoragePermissionRationaleDialogFragment.Listener, ShowRequestStoragePermissionInSettingsRationaleDialogFragment.Listener { private val requestAllFilesAccessLauncher = registerForActivityResult( @@ -131,9 +149,18 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. ActivityResultContracts.RequestPermission(), this::onRequestStoragePermissionResult ) private val requestStoragePermissionInSettingsLauncher = registerForActivityResult( - RequestStoragePermissionInSettingsContract(), + RequestPermissionInSettingsContract(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), this::onRequestStoragePermissionInSettingsResult ) + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val requestNotificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), this::onRequestNotificationPermissionResult + ) + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val requestNotificationPermissionInSettingsLauncher = registerForActivityResult( + RequestPermissionInSettingsContract(android.Manifest.permission.POST_NOTIFICATIONS), + this::onRequestNotificationPermissionInSettingsResult + ) private val args by args() private val argsPath by lazy { args.intent.extraPath } @@ -150,6 +177,8 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. private lateinit var bottomActionMode: ToolbarActionMode + private lateinit var layoutManager: GridLayoutManager + private lateinit var adapter: FileListAdapter private val debouncedSearchRunnable = DebouncedRunnable(Handler(Looper.getMainLooper()), 1000) { @@ -197,15 +226,13 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. binding.persistentBarLayout, binding.bottomBarLayout, binding.bottomToolbar ) val contentLayoutInitialPaddingBottom = binding.contentLayout.paddingBottom - binding.appBarLayout.addOnOffsetChangedListener( - OnOffsetChangedListener { _, verticalOffset -> - binding.contentLayout.updatePaddingRelative( - bottom = contentLayoutInitialPaddingBottom + - binding.appBarLayout.totalScrollRange + verticalOffset - ) - } - ) - binding.appBarLayout.syncBackgroundElevationTo(binding.overlayToolbar) + binding.appBarLayout.addOnOffsetChangedListener { _, verticalOffset -> + binding.contentLayout.updatePaddingRelative( + bottom = contentLayoutInitialPaddingBottom + + binding.appBarLayout.totalScrollRange + verticalOffset + ) + } + binding.appBarLayout.syncBackgroundColorTo(binding.overlayToolbar) binding.breadcrumbLayout.setListener(this) if (!(activity.hasSw600Dp && activity.isOrientationLandscape)) { binding.swipeRefreshLayout.setProgressViewEndTarget( @@ -213,7 +240,8 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. ) } binding.swipeRefreshLayout.setOnRefreshListener { this.refresh() } - binding.recyclerView.layoutManager = GridLayoutManager(activity, /* TODO */ 1) + layoutManager = GridLayoutManager(activity, 1) + binding.recyclerView.layoutManager = layoutManager adapter = FileListAdapter(this) binding.recyclerView.adapter = adapter val fastScroller = ThemedFastScroller.create(binding.recyclerView) @@ -227,46 +255,75 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. R.id.action_create_directory -> showCreateDirectoryDialog() } // Returning false causes the speed dial to close without animation. - //return false; + //return false binding.speedDialView.close() true } + val viewLifecycleOwner = viewLifecycleOwner + addOnBackPressedCallback( + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + viewModel.navigateUp() + } + } + .also { callback -> + viewModel.breadcrumbLiveData.observe(viewLifecycleOwner) { + callback.isEnabled = viewModel.canNavigateUpBreadcrumb + } + } + ) + addOnBackPressedCallback(overlayActionMode.onBackPressedCallback) + addOnBackPressedCallback(SpeedDialViewOnBackPressedCallback(binding.speedDialView)) + binding.drawerLayout?.let { + addOnBackPressedCallback(DrawerLayoutOnBackPressedCallback(it)) + } + if (!viewModel.hasTrail) { var path = argsPath val intent = args.intent var pickOptions: PickOptions? = null - when (val action = intent.action ?: Intent.ACTION_VIEW) { + when (val action = intent.action) { Intent.ACTION_GET_CONTENT, Intent.ACTION_OPEN_DOCUMENT, Intent.ACTION_CREATE_DOCUMENT -> { - val readOnly = action == Intent.ACTION_GET_CONTENT + val mode = if (action == Intent.ACTION_CREATE_DOCUMENT) { + PickOptions.Mode.CREATE_FILE + } else { + PickOptions.Mode.OPEN_FILE + } val mimeType = intent.type?.asMimeTypeOrNull() ?: MimeType.ANY - val extraMimeTypes = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES) - ?.mapNotNull { it.asMimeTypeOrNull() }?.takeIfNotEmpty() + val fileName = if (mode == PickOptions.Mode.CREATE_FILE) { + intent.getStringExtra(Intent.EXTRA_TITLE)?.asFileNameOrNull()?.value + ?: mimeType.extension?.let { "file.$it" } ?: "file" + } else { + null + } + val readOnly = action == Intent.ACTION_GET_CONTENT + val extraMimeTypes = if (mode == PickOptions.Mode.OPEN_FILE) { + intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES) + ?.mapNotNull { it.asMimeTypeOrNull() }?.takeIfNotEmpty() + } else { + null + } val mimeTypes = extraMimeTypes ?: listOf(mimeType) val localOnly = intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false) - val allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) - // TODO: Actually support ACTION_CREATE_DOCUMENT. - pickOptions = PickOptions(readOnly, false, mimeTypes, localOnly, allowMultiple) + val allowMultiple = mode != PickOptions.Mode.CREATE_FILE && + intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) + pickOptions = + PickOptions(mode, fileName, readOnly, mimeTypes, localOnly, allowMultiple) } Intent.ACTION_OPEN_DOCUMENT_TREE -> { val localOnly = intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false) - pickOptions = PickOptions(false, true, emptyList(), localOnly, false) + pickOptions = PickOptions( + PickOptions.Mode.OPEN_DIRECTORY, null, false, emptyList(), localOnly, false + ) } ACTION_VIEW_DOWNLOADS -> path = Paths.get( - @Suppress("DEPRECATION") Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS ).path ) - Intent.ACTION_VIEW -> - if (path != null) { - val mimeType = intent.type?.asMimeTypeOrNull() - if (mimeType != null && path.isArchiveFile(mimeType)) { - path = path.createArchiveRootPath() - } - } else -> if (path != null) { val mimeType = intent.type?.asMimeTypeOrNull() @@ -283,12 +340,6 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. viewModel.pickOptions = pickOptions } } - val viewLifecycleOwner = viewLifecycleOwner - if (binding.persistentDrawerLayout != null) { - Settings.FILE_LIST_PERSISTENT_DRAWER_OPEN.observe(viewLifecycleOwner) { - onPersistentDrawerOpenChanged(it) - } - } viewModel.currentPathLiveData.observe(viewLifecycleOwner) { onCurrentPathChanged(it) } viewModel.searchViewExpandedLiveData.observe(viewLifecycleOwner) { onSearchViewExpandedChanged(it) @@ -296,9 +347,18 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. viewModel.breadcrumbLiveData.observe(viewLifecycleOwner) { binding.breadcrumbLayout.setData(it) } + viewModel.viewTypeLiveData.observe(viewLifecycleOwner) { onViewTypeChanged(it) } + // Live data only calls observeForever() on its sources when it is active, so we have to + // make view type live data active first (so that it can load its initial value) before we + // register another observer that needs to get the view type. + if (binding.persistentDrawerLayout != null) { + Settings.FILE_LIST_PERSISTENT_DRAWER_OPEN.observe(viewLifecycleOwner) { + onPersistentDrawerOpenChanged(it) + } + } viewModel.sortOptionsLiveData.observe(viewLifecycleOwner) { onSortOptionsChanged(it) } - viewModel.sortPathSpecificLiveData.observe(viewLifecycleOwner) { - onSortPathSpecificChanged(it) + viewModel.viewSortPathSpecificLiveData.observe(viewLifecycleOwner) { + onViewSortPathSpecificChanged(it) } viewModel.pickOptionsLiveData.observe(viewLifecycleOwner) { onPickOptionsChanged(it) } viewModel.selectedFilesLiveData.observe(viewLifecycleOwner) { onSelectedFilesChanged(it) } @@ -313,13 +373,19 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. override fun onResume() { super.onResume() - ensureStorageAccess() + if (!viewModel.isNotificationPermissionRequested) { + ensureStorageAccess() + } + if (!viewModel.isStorageAccessRequested) { + ensureNotificationPermission() + } } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) menuBinding = MenuBinding.inflate(menu, inflater) + menuBinding.viewSortItem.subMenu!!.setGroupDividerEnabledCompat(true) setUpSearchView() } @@ -372,7 +438,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) - updateSortMenuItems() + updateViewSortMenuItems() updateSelectAllMenuItem() updateShowHiddenFilesMenuItem() } @@ -388,6 +454,14 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } true } + R.id.action_view_list -> { + viewModel.viewType = FileViewType.LIST + true + } + R.id.action_view_grid -> { + viewModel.viewType = FileViewType.GRID + true + } R.id.action_sort_by_name -> { viewModel.setSortBy(By.NAME) true @@ -418,8 +492,8 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. viewModel.setSortDirectoriesFirst(!menuBinding.sortDirectoriesFirstItem.isChecked) true } - R.id.action_sort_path_specific -> { - viewModel.isSortPathSpecific = !menuBinding.sortPathSpecificItem.isChecked + R.id.action_view_sort_path_specific -> { + viewModel.isViewSortPathSpecific = !menuBinding.viewSortPathSpecificItem.isChecked true } R.id.action_new_task -> { @@ -470,26 +544,24 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } } - fun onBackPressed(): Boolean { - val drawerLayout = binding.drawerLayout - if (drawerLayout != null && drawerLayout.isDrawerOpen(GravityCompat.START)) { - drawerLayout.closeDrawer(GravityCompat.START) - return true - } - if (binding.speedDialView.isOpen) { - binding.speedDialView.close() - return true + fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { + if (bottomActionMode.isActive) { + val menu = bottomActionMode.menu + menu.setQwertyMode( + KeyCharacterMap.load(event.deviceId).keyboardType != KeyCharacterMap.NUMERIC + ) + if (menu.performShortcut(keyCode, event, 0)) { + return true + } } if (overlayActionMode.isActive) { - overlayActionMode.finish() - return true - } - if (viewModel.navigateUp(false)) { - return true - } - // See also https://developer.android.com/about/versions/12/behavior-changes-all#back-press - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && requireActivity().isTaskRoot) { - viewModel.isStorageAccessRequested = false + val menu = overlayActionMode.menu + menu.setQwertyMode( + KeyCharacterMap.load(event.deviceId).keyboardType != KeyCharacterMap.NUMERIC + ) + if (menu.performShortcut(keyCode, event, 0)) { + return true + } } return false } @@ -502,6 +574,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. it.closeDrawer(GravityCompat.START) } } + updateSpanCount() } private fun onCurrentPathChanged(path: Path) { @@ -510,7 +583,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } private fun onSearchViewExpandedChanged(expanded: Boolean) { - updateSortMenuItems() + updateViewSortMenuItems() } private fun onFileListChanged(stateful: Stateful>) { @@ -543,8 +616,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. adapter.clear() } if (stateful is Success) { - viewModel.pendingState - ?.let { binding.recyclerView.layoutManager!!.onRestoreInstanceState(it) } + viewModel.pendingState?.let { layoutManager.onRestoreInstanceState(it) } } } @@ -575,24 +647,51 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } } + private fun onViewTypeChanged(viewType: FileViewType) { + updateSpanCount() + adapter.viewType = viewType + updateViewSortMenuItems() + } + + private fun updateSpanCount() { + layoutManager.spanCount = when (viewModel.viewType) { + FileViewType.LIST -> 1 + FileViewType.GRID -> { + var widthDp = resources.configuration.screenWidthDp + val persistentDrawerLayout = binding.persistentDrawerLayout + if (persistentDrawerLayout != null && + persistentDrawerLayout.isDrawerOpen(GravityCompat.START)) { + widthDp -= getDimensionDp(R.dimen.navigation_max_width).roundToInt() + } + (widthDp / 180).coerceAtLeast(2) + } + } + } + private fun onSortOptionsChanged(sortOptions: FileSortOptions) { - adapter.comparator = sortOptions.createComparator() - updateSortMenuItems() + adapter.sortOptions = sortOptions + updateViewSortMenuItems() } - private fun onSortPathSpecificChanged(pathSpecific: Boolean) { - updateSortMenuItems() + private fun onViewSortPathSpecificChanged(pathSpecific: Boolean) { + updateViewSortMenuItems() } - private fun updateSortMenuItems() { + private fun updateViewSortMenuItems() { if (!this::menuBinding.isInitialized) { return } val searchViewExpanded = viewModel.isSearchViewExpanded - menuBinding.sortItem.isVisible = !searchViewExpanded + menuBinding.viewSortItem.isVisible = !searchViewExpanded if (searchViewExpanded) { return } + val viewType = viewModel.viewType + val checkedViewTypeItem = when (viewType) { + FileViewType.LIST -> menuBinding.viewListItem + FileViewType.GRID -> menuBinding.viewGridItem + } + checkedViewTypeItem.isChecked = true val sortOptions = viewModel.sortOptions val checkedSortByItem = when (sortOptions.by) { By.NAME -> menuBinding.sortByNameItem @@ -603,12 +702,12 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. checkedSortByItem.isChecked = true menuBinding.sortOrderAscendingItem.isChecked = sortOptions.order == Order.ASCENDING menuBinding.sortDirectoriesFirstItem.isChecked = sortOptions.isDirectoriesFirst - menuBinding.sortPathSpecificItem.isChecked = viewModel.isSortPathSpecific + menuBinding.viewSortPathSpecificItem.isChecked = viewModel.isViewSortPathSpecific } private fun navigateUp() { collapseSearchView() - viewModel.navigateUp(true) + viewModel.navigateUp() } private fun showNavigateToPathDialog() { @@ -667,7 +766,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. override fun navigateTo(path: Path) { collapseSearchView() - val state = binding.recyclerView.layoutManager!!.onSaveInstanceState() + val state = layoutManager.onSaveInstanceState() viewModel.navigateTo(state!!, path) } @@ -685,13 +784,14 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. val title = if (pickOptions == null) { getString(R.string.file_list_title) } else { - val titleRes = if (pickOptions.pickDirectory) { - R.plurals.file_list_title_pick_directory - } else { - R.plurals.file_list_title_pick_file - } val count = if (pickOptions.allowMultiple) Int.MAX_VALUE else 1 - getQuantityString(titleRes, count) + when (pickOptions.mode) { + PickOptions.Mode.OPEN_FILE -> + getQuantityString(R.plurals.file_list_title_open_file, count) + PickOptions.Mode.CREATE_FILE -> getString(R.string.file_list_title_create_file) + PickOptions.Mode.OPEN_DIRECTORY -> + getQuantityString(R.plurals.file_list_title_open_directory, count) + } } requireActivity().title = title updateSelectAllMenuItem() @@ -730,7 +830,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. flags = flags or (Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) } - if (pickOptions.pickDirectory) { + if (pickOptions.mode == PickOptions.Mode.OPEN_DIRECTORY) { flags = flags or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION } addFlags(flags) @@ -759,6 +859,12 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. overlayActionMode.title = getString(R.string.file_list_select_title_format, files.size) overlayActionMode.setMenuResource(R.menu.file_list_pick) val menu = overlayActionMode.menu + val isOpen = when (pickOptions.mode) { + PickOptions.Mode.OPEN_FILE, PickOptions.Mode.OPEN_DIRECTORY -> true + PickOptions.Mode.CREATE_FILE -> false + } + menu.findItem(R.id.action_open).isVisible = isOpen + menu.findItem(R.id.action_create).isVisible = !isOpen menu.findItem(R.id.action_select_all).isVisible = pickOptions.allowMultiple } else { overlayActionMode.title = getString(R.string.file_list_select_title_format, files.size) @@ -776,9 +882,15 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } ) .setTitle( - if (areAllFilesArchivePaths) R.string.file_list_select_action_extract else R.string.copy + if (areAllFilesArchivePaths) { + R.string.file_list_select_action_extract + } else { + R.string.copy + } ) menu.findItem(R.id.action_delete).isVisible = !isAnyFileReadOnly + val areAllFilesArchiveFiles = files.all { it.isArchiveFile } + menu.findItem(R.id.action_extract).isVisible = areAllFilesArchiveFiles val isCurrentPathReadOnly = viewModel.currentPath.fileSystem.isReadOnly menu.findItem(R.id.action_archive).isVisible = !isCurrentPathReadOnly } @@ -788,29 +900,28 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. AppBarLayoutExpandHackListener(binding.recyclerView) ) overlayActionMode.start(object : ToolbarActionMode.Callback { - override fun onToolbarActionModeStarted(toolbarActionMode: ToolbarActionMode) {} - - override fun onToolbarActionModeItemClicked( + override fun onToolbarActionModeMenuItemClicked( toolbarActionMode: ToolbarActionMode, item: MenuItem - ): Boolean = onOverlayActionModeItemClicked(toolbarActionMode, item) + ): Boolean = onOverlayActionModeMenuItemClicked(item) override fun onToolbarActionModeFinished(toolbarActionMode: ToolbarActionMode) { - onOverlayActionModeFinished(toolbarActionMode) + onOverlayActionModeFinished() } }) } } - private fun onOverlayActionModeItemClicked( - toolbarActionMode: ToolbarActionMode, - item: MenuItem - ): Boolean = + private fun onOverlayActionModeMenuItemClicked(item: MenuItem): Boolean = when (item.itemId) { - R.id.action_pick -> { + R.id.action_open -> { pickFiles(viewModel.selectedFiles) true } + R.id.action_create -> { + confirmReplaceFile(viewModel.selectedFiles.single()) + true + } R.id.action_cut -> { cutFiles(viewModel.selectedFiles) true @@ -823,6 +934,10 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. confirmDeleteFiles(viewModel.selectedFiles) true } + R.id.action_extract -> { + extractFiles(viewModel.selectedFiles) + true + } R.id.action_archive -> { showCreateArchiveDialog(viewModel.selectedFiles) true @@ -838,10 +953,25 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. else -> false } - private fun onOverlayActionModeFinished(toolbarActionMode: ToolbarActionMode) { + private fun onOverlayActionModeFinished() { viewModel.clearSelectedFiles() } + private fun confirmReplaceFile(file: FileItem, setFileName: Boolean = true) { + if (setFileName) { + val fileName = file.name + binding.bottomCreateFileNameEdit.setText(fileName) + binding.bottomCreateFileNameEdit.setSelection( + 0, fileName.asFileName().baseName.length + ) + } + ConfirmReplaceFileDialogFragment.show(file, this) + } + + override fun replaceFile(file: FileItem) { + pickFiles(fileItemSetOf(file)) + } + private fun cutFiles(files: FileItemSet) { viewModel.addToPasteState(false, files) viewModel.selectFiles(files, false) @@ -861,6 +991,11 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. viewModel.selectFiles(files, false) } + private fun extractFiles(files: FileItemSet) { + copyFiles(files.mapTo(fileItemSetOf()) { it.createDummyArchiveRoot() }) + viewModel.selectFiles(files, false) + } + private fun showCreateArchiveDialog(files: FileItemSet) { CreateArchiveDialogFragment.show(files, this) } @@ -868,12 +1003,13 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. override fun archive( files: FileItemSet, name: String, - archiveType: String, - compressorType: String? + format: Int, + filter: Int, + password: String? ) { val archiveFile = viewModel.currentPath.resolve(name) FileJobService.archive( - makePathListForJob(files), archiveFile, archiveType, compressorType, requireContext() + makePathListForJob(files), archiveFile, format, filter, password, requireContext() ) viewModel.selectFiles(files, false) } @@ -894,18 +1030,45 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. private fun updateBottomToolbar() { val pickOptions = viewModel.pickOptions if (pickOptions != null) { - if (!pickOptions.pickDirectory) { - if (bottomActionMode.isActive) { - bottomActionMode.finish() + bottomActionMode.setMenuResource(R.menu.file_list_pick_bottom) + val menu = bottomActionMode.menu + when (pickOptions.mode) { + PickOptions.Mode.CREATE_FILE -> { + bottomActionMode.title = null + binding.bottomCreateFileNameEdit.isVisible = true + val createMenuItem = menu.findItem(R.id.action_create) + binding.bottomCreateFileNameEdit.setOnEditorConfirmActionListener { + onBottomActionModeMenuItemClicked(createMenuItem) + } + if (!viewModel.isCreateFileNameEditInitialized) { + val fileName = pickOptions.fileName!! + binding.bottomCreateFileNameEdit.setText(fileName) + binding.bottomCreateFileNameEdit.setSelection( + 0, fileName.asFileName().baseName.length + ) + binding.bottomCreateFileNameEdit.requestFocus() + viewModel.isCreateFileNameEditInitialized = true + } + menu.findItem(R.id.action_open).isVisible = false + createMenuItem.isVisible = true + } + PickOptions.Mode.OPEN_DIRECTORY -> { + val path = viewModel.currentPath + val navigationRoot = NavigationRootMapLiveData.valueCompat[path] + val name = navigationRoot?.getName(requireContext()) ?: path.name + bottomActionMode.title = + getString(R.string.file_list_open_current_directory_format, name) + binding.bottomCreateFileNameEdit.isVisible = false + menu.findItem(R.id.action_open).isVisible = true + menu.findItem(R.id.action_create).isVisible = false + } + else -> { + if (bottomActionMode.isActive) { + bottomActionMode.finish() + } + return } - return } - bottomActionMode.setNavigationIcon(R.drawable.check_icon_control_normal_24dp) - val path = viewModel.currentPath - val navigationRoot = NavigationRootMapLiveData.valueCompat[path] - val name = navigationRoot?.getName(requireContext()) ?: path.name - bottomActionMode.title = - getString(R.string.file_list_select_current_directory_format, name) } else { val pasteState = viewModel.pasteState val files = pasteState.files @@ -915,7 +1078,6 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } return } - bottomActionMode.setNavigationIcon(R.drawable.close_icon_control_normal_24dp) val areAllFilesArchivePaths = files.all { it.path.isArchivePath } bottomActionMode.title = getString( if (pasteState.copy) { @@ -928,6 +1090,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. R.string.file_list_paste_move_title_format }, files.size ) + binding.bottomCreateFileNameEdit.isVisible = false bottomActionMode.setMenuResource(R.menu.file_list_paste) val isCurrentPathReadOnly = viewModel.currentPath.fileSystem.isReadOnly bottomActionMode.menu.findItem(R.id.action_paste) @@ -938,25 +1101,54 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } if (!bottomActionMode.isActive) { bottomActionMode.start(object : ToolbarActionMode.Callback { - override fun onToolbarActionModeStarted(toolbarActionMode: ToolbarActionMode) {} + override fun onToolbarNavigationIconClicked(toolbarActionMode: ToolbarActionMode) { + onBottomToolbarNavigationIconClicked() + } - override fun onToolbarActionModeItemClicked( + override fun onToolbarActionModeMenuItemClicked( toolbarActionMode: ToolbarActionMode, item: MenuItem - ): Boolean = onBottomActionModeItemClicked(toolbarActionMode, item) + ): Boolean = onBottomActionModeMenuItemClicked(item) override fun onToolbarActionModeFinished(toolbarActionMode: ToolbarActionMode) { - onBottomActionModeFinished(toolbarActionMode) + onBottomActionModeFinished() } }) } } - private fun onBottomActionModeItemClicked( - toolbarActionMode: ToolbarActionMode, - item: MenuItem - ): Boolean = + private fun onBottomToolbarNavigationIconClicked() { + val pickOptions = viewModel.pickOptions + if (pickOptions != null) { + requireActivity().finish() + } else { + bottomActionMode.finish() + } + } + + private fun onBottomActionModeMenuItemClicked(item: MenuItem): Boolean = when (item.itemId) { + R.id.action_open -> { + pickPaths(linkedSetOf(viewModel.currentPath)) + true + } + R.id.action_create -> { + val fileName = binding.bottomCreateFileNameEdit.text.toString() + if (fileName.isEmpty()) { + showToast(R.string.file_list_create_file_name_error_empty) + } else if (fileName.asFileNameOrNull() == null) { + showToast(R.string.file_list_create_file_name_error_invalid) + } else { + val file = getFileWithName(fileName) + if (file != null) { + confirmReplaceFile(file, false) + } else { + val path = viewModel.currentPath.resolve(fileName) + pickPaths(linkedSetOf(path)) + } + } + true + } R.id.action_paste -> { pasteFiles(currentPath) true @@ -964,13 +1156,9 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. else -> false } - private fun onBottomActionModeFinished(toolbarActionMode: ToolbarActionMode) { + private fun onBottomActionModeFinished() { val pickOptions = viewModel.pickOptions - if (pickOptions != null) { - if (pickOptions.pickDirectory) { - pickPaths(linkedSetOf(viewModel.currentPath)) - } - } else { + if (pickOptions == null) { viewModel.clearPasteState() } } @@ -990,7 +1178,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } private fun makePathListForJob(files: FileItemSet): List = - files.map { it.path }.sorted() + files.map { it.path }.sortedBy { it.toUri() } private fun onFileNameEllipsizeChanged(fileNameEllipsize: TextUtils.TruncateAt) { adapter.nameEllipsize = fileNameEllipsize @@ -1013,8 +1201,12 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. if (pickOptions != null) { if (file.attributes.isDirectory) { navigateTo(file.path) - } else if (!pickOptions.pickDirectory) { - pickFiles(fileItemSetOf(file)) + } else { + when (pickOptions.mode) { + PickOptions.Mode.OPEN_FILE -> pickFiles(fileItemSetOf(file)) + PickOptions.Mode.CREATE_FILE -> confirmReplaceFile(file) + PickOptions.Mode.OPEN_DIRECTORY -> {} + } } return } @@ -1098,7 +1290,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } var paths = mutableListOf() // We need the ordered list from our adapter instead of the list from FileListLiveData. - for (index in 0 until adapter.itemCount) { + for (index in 0..= Build.VERSION_CODES.R) { + if (Environment::class.supportsExternalStorageManager()) { if (!Environment.isExternalStorageManager()) { ShowRequestAllFilesAccessRationaleDialogFragment.show(this) viewModel.isStorageAccessRequested = true } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { + if (checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) != + PackageManager.PERMISSION_GRANTED + ) { if (shouldShowRequestPermissionRationale( android.Manifest.permission.WRITE_EXTERNAL_STORAGE )) { @@ -1288,18 +1486,37 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. } } - override fun requestAllFilesAccess() { + override fun onShowRequestAllFilesAccessRationaleResult(shouldRequest: Boolean) { + if (shouldRequest) { + requestAllFilesAccess() + } else { + viewModel.isStorageAccessRequested = false + // This isn't an onActivityResult() callback so it's not delivered before calling + // onResume(), and we need to do this manually. + ensureNotificationPermission() + } + } + + private fun requestAllFilesAccess() { requestAllFilesAccessLauncher.launch(Unit) } private fun onRequestAllFilesAccessResult(isGranted: Boolean) { + viewModel.isStorageAccessRequested = false if (isGranted) { - viewModel.isStorageAccessRequested = false refresh() } } - override fun requestStoragePermission() { + override fun onShowRequestStoragePermissionRationaleResult(shouldRequest: Boolean) { + if (shouldRequest) { + requestStoragePermission() + } else { + viewModel.isStorageAccessRequested = false + } + } + + private fun requestStoragePermission() { requestStoragePermissionLauncher.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) } @@ -1307,24 +1524,103 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. if (isGranted) { viewModel.isStorageAccessRequested = false refresh() - } else if (!shouldShowRequestPermissionRationale( - android.Manifest.permission.WRITE_EXTERNAL_STORAGE - )) { + } else if (shouldShowRequestPermissionRationale( + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + )) { + ShowRequestStoragePermissionRationaleDialogFragment.show(this) + } else { ShowRequestStoragePermissionInSettingsRationaleDialogFragment.show(this) } } - override fun requestStoragePermissionInSettings() { + override fun onShowRequestStoragePermissionInSettingsRationaleResult(shouldRequest: Boolean) { + if (shouldRequest) { + requestStoragePermissionInSettings() + } else { + viewModel.isStorageAccessRequested = false + } + } + + private fun requestStoragePermissionInSettings() { requestStoragePermissionInSettingsLauncher.launch(Unit) } private fun onRequestStoragePermissionInSettingsResult(isGranted: Boolean) { + viewModel.isStorageAccessRequested = false if (isGranted) { - viewModel.isStorageAccessRequested = false refresh() } } + private fun ensureNotificationPermission() { + if (viewModel.isNotificationPermissionRequested) { + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED) { + if (shouldShowRequestPermissionRationale( + android.Manifest.permission.POST_NOTIFICATIONS + )) { + ShowRequestNotificationPermissionRationaleDialogFragment.show(this) + } else { + requestNotificationPermission() + } + viewModel.isNotificationPermissionRequested = true + } + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onShowRequestNotificationPermissionRationaleResult(shouldRequest: Boolean) { + if (shouldRequest) { + requestNotificationPermission() + } else { + viewModel.isNotificationPermissionRequested = false + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun requestNotificationPermission() { + requestNotificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun onRequestNotificationPermissionResult(isGranted: Boolean) { + if (isGranted) { + viewModel.isNotificationPermissionRequested = false + } else if (shouldShowRequestPermissionRationale( + android.Manifest.permission.POST_NOTIFICATIONS + )) { + ShowRequestNotificationPermissionRationaleDialogFragment.show(this) + } else { + ShowRequestNotificationPermissionInSettingsRationaleDialogFragment.show(this) + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onShowRequestNotificationPermissionInSettingsRationaleResult( + shouldRequest: Boolean + ) { + if (shouldRequest) { + requestNotificationPermissionInSettings() + } else { + viewModel.isNotificationPermissionRequested = false + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun requestNotificationPermissionInSettings() { + requestNotificationPermissionInSettingsLauncher.launch(Unit) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun onRequestNotificationPermissionInSettingsResult(isGranted: Boolean) { + if (isGranted) { + viewModel.isNotificationPermissionRequested = false + } + } + companion object { private const val ACTION_VIEW_DOWNLOADS = "me.zhanghai.android.files.intent.action.VIEW_DOWNLOADS" @@ -1335,17 +1631,14 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. private class RequestAllFilesAccessContract : ActivityResultContract() { @RequiresApi(Build.VERSION_CODES.R) override fun createIntent(context: Context, input: Unit): Intent = - Intent( - android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - Uri.fromParts("package", context.packageName, null) - ) + Environment::class.createManageAppAllFilesAccessPermissionIntent(context.packageName) @RequiresApi(Build.VERSION_CODES.R) override fun parseResult(resultCode: Int, intent: Intent?): Boolean = Environment.isExternalStorageManager() } - private class RequestStoragePermissionInSettingsContract + private class RequestPermissionInSettingsContract(private val permissionName: String) : ActivityResultContract() { override fun createIntent(context: Context, input: Unit): Intent = Intent( @@ -1354,9 +1647,8 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. ) override fun parseResult(resultCode: Int, intent: Intent?): Boolean = - application.checkSelfPermissionCompat( - android.Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED + application.checkSelfPermissionCompat(permissionName) == + PackageManager.PERMISSION_GRANTED } @Parcelize @@ -1379,6 +1671,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. val recyclerView: RecyclerView, val bottomBarLayout: ViewGroup, val bottomToolbar: Toolbar, + val bottomCreateFileNameEdit: EditText, val speedDialView: SpeedDialView ) { companion object { @@ -1402,7 +1695,7 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. contentBinding.progress, contentBinding.errorText, contentBinding.emptyView, contentBinding.swipeRefreshLayout, contentBinding.recyclerView, bottomBarBinding.bottomBarLayout, bottomBarBinding.bottomToolbar, - speedDialBinding.speedDialView + bottomBarBinding.bottomCreateFileNameEdit, speedDialBinding.speedDialView ) } } @@ -1411,14 +1704,16 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. private class MenuBinding private constructor( val menu: Menu, val searchItem: MenuItem, - val sortItem: MenuItem, + val viewSortItem: MenuItem, + val viewListItem: MenuItem, + val viewGridItem: MenuItem, val sortByNameItem: MenuItem, val sortByTypeItem: MenuItem, val sortBySizeItem: MenuItem, val sortByLastModifiedItem: MenuItem, val sortOrderAscendingItem: MenuItem, val sortDirectoriesFirstItem: MenuItem, - val sortPathSpecificItem: MenuItem, + val viewSortPathSpecificItem: MenuItem, val selectAllItem: MenuItem, val showHiddenFilesItem: MenuItem ) { @@ -1426,14 +1721,15 @@ class FileListFragment : Fragment(), BreadcrumbLayout.Listener, FileListAdapter. fun inflate(menu: Menu, inflater: MenuInflater): MenuBinding { inflater.inflate(R.menu.file_list, menu) return MenuBinding( - menu, menu.findItem(R.id.action_search), menu.findItem(R.id.action_sort), + menu, menu.findItem(R.id.action_search), menu.findItem(R.id.action_view_sort), + menu.findItem(R.id.action_view_list), menu.findItem(R.id.action_view_grid), menu.findItem(R.id.action_sort_by_name), menu.findItem(R.id.action_sort_by_type), menu.findItem(R.id.action_sort_by_size), menu.findItem(R.id.action_sort_by_last_modified), menu.findItem(R.id.action_sort_order_ascending), menu.findItem(R.id.action_sort_directories_first), - menu.findItem(R.id.action_sort_path_specific), + menu.findItem(R.id.action_view_sort_path_specific), menu.findItem(R.id.action_select_all), menu.findItem(R.id.action_show_hidden_files) ) diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileListViewModel.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileListViewModel.kt index cc0c8be9b..bafa4c659 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileListViewModel.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileListViewModel.kt @@ -34,12 +34,7 @@ class FileListViewModel : ViewModel() { fun resetTo(path: Path) = trailLiveData.resetTo(path) - fun navigateUp(overrideBreadcrumb: Boolean): Boolean = - if (!overrideBreadcrumb && breadcrumbLiveData.valueCompat.selectedIndex == 0) { - false - } else { - trailLiveData.navigateUp() - } + fun navigateUp(): Boolean = trailLiveData.navigateUp() val currentPathLiveData = trailLiveData.map { it.currentPath } val currentPath: Path @@ -102,6 +97,16 @@ class FileListViewModel : ViewModel() { } val breadcrumbLiveData: LiveData = BreadcrumbLiveData(trailLiveData) + val canNavigateUpBreadcrumb: Boolean + get() = breadcrumbLiveData.valueCompat.selectedIndex > 0 + + private val _viewTypeLiveData = FileViewTypeLiveData(currentPathLiveData) + val viewTypeLiveData: LiveData = _viewTypeLiveData + var viewType: FileViewType + get() = _viewTypeLiveData.valueCompat + set(value) { + _viewTypeLiveData.putValue(value) + } private val _sortOptionsLiveData = FileSortOptionsLiveData(currentPathLiveData) val sortOptionsLiveData: LiveData = _sortOptionsLiveData @@ -115,13 +120,14 @@ class FileListViewModel : ViewModel() { fun setSortDirectoriesFirst(isDirectoriesFirst: Boolean) = _sortOptionsLiveData.putIsDirectoriesFirst(isDirectoriesFirst) - private val _sortPathSpecificLiveData = FileSortPathSpecificLiveData(currentPathLiveData) - val sortPathSpecificLiveData: LiveData - get() = _sortPathSpecificLiveData - var isSortPathSpecific: Boolean - get() = _sortPathSpecificLiveData.valueCompat + private val _viewSortPathSpecificLiveData = + FileViewSortPathSpecificLiveData(currentPathLiveData) + val viewSortPathSpecificLiveData: LiveData + get() = _viewSortPathSpecificLiveData + var isViewSortPathSpecific: Boolean + get() = _viewSortPathSpecificLiveData.valueCompat set(value) { - _sortPathSpecificLiveData.putValue(value) + _viewSortPathSpecificLiveData.putValue(value) } private val _pickOptionsLiveData = MutableLiveData() @@ -133,6 +139,8 @@ class FileListViewModel : ViewModel() { _pickOptionsLiveData.value = value } + var isCreateFileNameEditInitialized: Boolean = false + private val _selectedFilesLiveData = MutableLiveData(fileItemSetOf()) val selectedFilesLiveData: LiveData get() = _selectedFilesLiveData @@ -218,6 +226,13 @@ class FileListViewModel : ViewModel() { _isRequestingStorageAccessLiveData.value = value } + private val _isRequestingNotificationPermissionLiveData = MutableLiveData(false) + var isNotificationPermissionRequested: Boolean + get() = _isRequestingNotificationPermissionLiveData.valueCompat + set(value) { + _isRequestingNotificationPermissionLiveData.value = value + } + override fun onCleared() { _fileListLiveData.close() } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileSortPathSpecificLiveData.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileViewSortPathSpecificLiveData.kt similarity index 63% rename from app/src/main/java/me/zhanghai/android/files/filelist/FileSortPathSpecificLiveData.kt rename to app/src/main/java/me/zhanghai/android/files/filelist/FileViewSortPathSpecificLiveData.kt index 2791a1b16..3db90ff38 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/FileSortPathSpecificLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileViewSortPathSpecificLiveData.kt @@ -13,11 +13,12 @@ import me.zhanghai.android.files.settings.SettingLiveData import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.util.valueCompat -class FileSortPathSpecificLiveData(pathLiveData: LiveData) : MediatorLiveData() { +class FileViewSortPathSpecificLiveData(pathLiveData: LiveData) : MediatorLiveData() { + private lateinit var pathViewTypeLiveData: SettingLiveData private lateinit var pathSortOptionsLiveData: SettingLiveData private fun loadValue() { - val value = pathSortOptionsLiveData.value != null + val value = pathViewTypeLiveData.value != null || pathSortOptionsLiveData.value != null if (this.value != value) { this.value = value } @@ -25,10 +26,16 @@ class FileSortPathSpecificLiveData(pathLiveData: LiveData) : MediatorLiveD fun putValue(value: Boolean) { if (value) { + if (pathViewTypeLiveData.value == null) { + pathViewTypeLiveData.putValue(Settings.FILE_LIST_VIEW_TYPE.valueCompat) + } if (pathSortOptionsLiveData.value == null) { pathSortOptionsLiveData.putValue(Settings.FILE_LIST_SORT_OPTIONS.valueCompat) } } else { + if (pathViewTypeLiveData.value != null) { + pathViewTypeLiveData.putValue(null) + } if (pathSortOptionsLiveData.value != null) { pathSortOptionsLiveData.putValue(null) } @@ -37,10 +44,15 @@ class FileSortPathSpecificLiveData(pathLiveData: LiveData) : MediatorLiveD init { addSource(pathLiveData) { path: Path -> + if (this::pathViewTypeLiveData.isInitialized) { + removeSource(pathViewTypeLiveData) + } if (this::pathSortOptionsLiveData.isInitialized) { removeSource(pathSortOptionsLiveData) } + pathViewTypeLiveData = PathSettings.getFileListViewType(path) pathSortOptionsLiveData = PathSettings.getFileListSortOptions(path) + addSource(pathViewTypeLiveData) { loadValue() } addSource(pathSortOptionsLiveData) { loadValue() } } } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileViewType.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileViewType.kt new file mode 100644 index 000000000..ba7bad764 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileViewType.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.filelist + +enum class FileViewType { + LIST, + GRID +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/FileViewTypeLiveData.kt b/app/src/main/java/me/zhanghai/android/files/filelist/FileViewTypeLiveData.kt new file mode 100644 index 000000000..ab0e35705 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/filelist/FileViewTypeLiveData.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.filelist + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import java8.nio.file.Path +import me.zhanghai.android.files.settings.PathSettings +import me.zhanghai.android.files.settings.SettingLiveData +import me.zhanghai.android.files.settings.Settings +import me.zhanghai.android.files.util.valueCompat + +class FileViewTypeLiveData(pathLiveData: LiveData) : MediatorLiveData() { + private lateinit var pathViewTypeLiveData: SettingLiveData + + private fun loadValue() { + if (!this::pathViewTypeLiveData.isInitialized) { + // Not yet initialized. + return + } + val value = pathViewTypeLiveData.value ?: Settings.FILE_LIST_VIEW_TYPE.valueCompat + if (this.value != value) { + this.value = value + } + } + + fun putValue(value: FileViewType) { + if (pathViewTypeLiveData.value != null) { + pathViewTypeLiveData.putValue(value) + } else { + Settings.FILE_LIST_VIEW_TYPE.putValue(value) + } + } + + init { + addSource(Settings.FILE_LIST_VIEW_TYPE) { loadValue() } + addSource(pathLiveData) { path: Path -> + if (this::pathViewTypeLiveData.isInitialized) { + removeSource(pathViewTypeLiveData) + } + pathViewTypeLiveData = PathSettings.getFileListViewType(path) + addSource(pathViewTypeLiveData) { loadValue() } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/PathExtensions.kt b/app/src/main/java/me/zhanghai/android/files/filelist/PathExtensions.kt index f249f8536..44b03acad 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/PathExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/PathExtensions.kt @@ -10,6 +10,8 @@ import me.zhanghai.android.files.file.MimeType import me.zhanghai.android.files.file.isSupportedArchive import me.zhanghai.android.files.provider.archive.archiveFile import me.zhanghai.android.files.provider.archive.isArchivePath +import me.zhanghai.android.files.provider.document.isDocumentPath +import me.zhanghai.android.files.provider.document.resolver.DocumentResolver import me.zhanghai.android.files.provider.linux.isLinuxPath val Path.name: String @@ -18,3 +20,10 @@ val Path.name: String fun Path.toUserFriendlyString(): String = if (isLinuxPath) toFile().path else toUri().toString() fun Path.isArchiveFile(mimeType: MimeType): Boolean = !isArchivePath && mimeType.isSupportedArchive + +val Path.isLocalPath: Boolean + get() = + isLinuxPath || (isDocumentPath && DocumentResolver.isLocal(this as DocumentResolver.Path)) + +val Path.isRemotePath: Boolean + get() = !isLocalPath diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/PickOptions.kt b/app/src/main/java/me/zhanghai/android/files/filelist/PickOptions.kt index ab4f72f9a..1082d6fe1 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/PickOptions.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/PickOptions.kt @@ -8,9 +8,16 @@ package me.zhanghai.android.files.filelist import me.zhanghai.android.files.file.MimeType class PickOptions( + val mode: Mode, + val fileName: String?, val readOnly: Boolean, - val pickDirectory: Boolean, val mimeTypes: List, val localOnly: Boolean, val allowMultiple: Boolean -) +) { + enum class Mode { + OPEN_FILE, + CREATE_FILE, + OPEN_DIRECTORY + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestAllFilesAccessRationaleDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestAllFilesAccessRationaleDialogFragment.kt index 5de8d49a1..07c502327 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestAllFilesAccessRationaleDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestAllFilesAccessRationaleDialogFragment.kt @@ -6,6 +6,7 @@ package me.zhanghai.android.files.filelist import android.app.Dialog +import android.content.DialogInterface import android.os.Bundle import androidx.appcompat.app.AppCompatDialogFragment import androidx.fragment.app.Fragment @@ -20,11 +21,21 @@ class ShowRequestAllFilesAccessRationaleDialogFragment : AppCompatDialogFragment override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return MaterialAlertDialogBuilder(requireContext(), theme) .setMessage(R.string.all_files_access_rationale_message) - .setPositiveButton(android.R.string.ok) { _, _ -> listener.requestAllFilesAccess() } - .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener.onShowRequestAllFilesAccessRationaleResult(true) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + listener.onShowRequestAllFilesAccessRationaleResult(false) + } .create() } + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + listener.onShowRequestAllFilesAccessRationaleResult(false) + } + companion object { fun show(fragment: Fragment) { ShowRequestAllFilesAccessRationaleDialogFragment().show(fragment) @@ -32,6 +43,6 @@ class ShowRequestAllFilesAccessRationaleDialogFragment : AppCompatDialogFragment } interface Listener { - fun requestAllFilesAccess() + fun onShowRequestAllFilesAccessRationaleResult(shouldRequest: Boolean) } } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestNotificationPermissionInSettingsRationaleDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestNotificationPermissionInSettingsRationaleDialogFragment.kt new file mode 100644 index 000000000..a240c0bfe --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestNotificationPermissionInSettingsRationaleDialogFragment.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.filelist + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import me.zhanghai.android.files.R +import me.zhanghai.android.files.util.show + +class ShowRequestNotificationPermissionInSettingsRationaleDialogFragment : AppCompatDialogFragment() { + private val listener: Listener + get() = requireParentFragment() as Listener + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext(), theme) + .setMessage(R.string.notification_permission_permanently_denied_message) + .setPositiveButton(R.string.open_settings) { _, _ -> + listener.onShowRequestNotificationPermissionInSettingsRationaleResult(true) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + listener.onShowRequestNotificationPermissionInSettingsRationaleResult(false) + } + .create() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + listener.onShowRequestNotificationPermissionInSettingsRationaleResult(false) + } + + companion object { + fun show(fragment: Fragment) { + ShowRequestNotificationPermissionInSettingsRationaleDialogFragment().show(fragment) + } + } + + interface Listener { + fun onShowRequestNotificationPermissionInSettingsRationaleResult(shouldRequest: Boolean) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestNotificationPermissionRationaleDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestNotificationPermissionRationaleDialogFragment.kt new file mode 100644 index 000000000..1f589ad84 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestNotificationPermissionRationaleDialogFragment.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.filelist + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import me.zhanghai.android.files.R +import me.zhanghai.android.files.util.show + +class ShowRequestNotificationPermissionRationaleDialogFragment : AppCompatDialogFragment() { + private val listener: Listener + get() = requireParentFragment() as Listener + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext(), theme) + .setMessage(R.string.notification_permission_rationale_message) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener.onShowRequestNotificationPermissionRationaleResult(true) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + listener.onShowRequestNotificationPermissionRationaleResult(false) + } + .create() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + listener.onShowRequestNotificationPermissionRationaleResult(false) + } + + companion object { + fun show(fragment: Fragment) { + ShowRequestNotificationPermissionRationaleDialogFragment().show(fragment) + } + } + + interface Listener { + fun onShowRequestNotificationPermissionRationaleResult(shouldRequest: Boolean) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionInSettingsRationaleDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionInSettingsRationaleDialogFragment.kt index 64409c647..68ceb6828 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionInSettingsRationaleDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionInSettingsRationaleDialogFragment.kt @@ -6,6 +6,7 @@ package me.zhanghai.android.files.filelist import android.app.Dialog +import android.content.DialogInterface import android.os.Bundle import androidx.appcompat.app.AppCompatDialogFragment import androidx.fragment.app.Fragment @@ -19,14 +20,22 @@ class ShowRequestStoragePermissionInSettingsRationaleDialogFragment : AppCompatD override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return MaterialAlertDialogBuilder(requireContext(), theme) - .setMessage(R.string.storage_permission_rationale_message) + .setMessage(R.string.storage_permission_permanently_denied_message) .setPositiveButton(R.string.open_settings) { _, _ -> - listener.requestStoragePermissionInSettings() + listener.onShowRequestStoragePermissionInSettingsRationaleResult(true) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + listener.onShowRequestStoragePermissionInSettingsRationaleResult(false) } - .setNegativeButton(android.R.string.cancel, null) .create() } + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + listener.onShowRequestStoragePermissionInSettingsRationaleResult(false) + } + companion object { fun show(fragment: Fragment) { ShowRequestStoragePermissionInSettingsRationaleDialogFragment().show(fragment) @@ -34,6 +43,6 @@ class ShowRequestStoragePermissionInSettingsRationaleDialogFragment : AppCompatD } interface Listener { - fun requestStoragePermissionInSettings() + fun onShowRequestStoragePermissionInSettingsRationaleResult(shouldRequest: Boolean) } } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionRationaleDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionRationaleDialogFragment.kt index 1fe9e932c..afa589800 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionRationaleDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/ShowRequestStoragePermissionRationaleDialogFragment.kt @@ -6,6 +6,7 @@ package me.zhanghai.android.files.filelist import android.app.Dialog +import android.content.DialogInterface import android.os.Bundle import androidx.appcompat.app.AppCompatDialogFragment import androidx.fragment.app.Fragment @@ -20,11 +21,21 @@ class ShowRequestStoragePermissionRationaleDialogFragment : AppCompatDialogFragm override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return MaterialAlertDialogBuilder(requireContext(), theme) .setMessage(R.string.storage_permission_rationale_message) - .setPositiveButton(android.R.string.ok) { _, _ -> listener.requestStoragePermission() } - .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener.onShowRequestStoragePermissionRationaleResult(true) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + listener.onShowRequestStoragePermissionRationaleResult(false) + } .create() } + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + listener.onShowRequestStoragePermissionRationaleResult(false) + } + companion object { fun show(fragment: Fragment) { ShowRequestStoragePermissionRationaleDialogFragment().show(fragment) @@ -32,6 +43,6 @@ class ShowRequestStoragePermissionRationaleDialogFragment : AppCompatDialogFragm } interface Listener { - fun requestStoragePermission() + fun onShowRequestStoragePermissionRationaleResult(shouldRequest: Boolean) } } diff --git a/app/src/main/java/me/zhanghai/android/files/filelist/TrailData.kt b/app/src/main/java/me/zhanghai/android/files/filelist/TrailData.kt index 58d3afd74..f0638efd7 100644 --- a/app/src/main/java/me/zhanghai/android/files/filelist/TrailData.kt +++ b/app/src/main/java/me/zhanghai/android/files/filelist/TrailData.kt @@ -33,7 +33,7 @@ class TrailData private constructor( } } if (isPrefix) { - for (index in newTrail.size until trail.size) { + for (index in newTrail.size.. Fragment>>() .apply { add(R.string.file_properties_basic to { FilePropertiesBasicTabFragment() }) - if (FilePropertiesPermissionsTabFragment.isAvailable(args.file)) { + if (FilePropertiesPermissionTabFragment.isAvailable(args.file)) { add( - R.string.file_properties_permissions - to { FilePropertiesPermissionsTabFragment() } + R.string.file_properties_permission + to { FilePropertiesPermissionTabFragment() } ) } if (FilePropertiesImageTabFragment.isAvailable(args.file)) { @@ -108,6 +110,15 @@ class FilePropertiesDialogFragment : AppCompatDialogFragment() { } ) } + if (FilePropertiesChecksumTabFragment.isAvailable(args.file)) { + add( + R.string.file_properties_checksum to { + FilePropertiesChecksumTabFragment().putArgs( + FilePropertiesChecksumTabFragment.Args(args.file.path) + ) + } + ) + } } .map { getString(it.first) to it.second } .toTypedArray() @@ -117,6 +128,14 @@ class FilePropertiesDialogFragment : AppCompatDialogFragment() { binding.tabLayout.setupWithViewPager(binding.viewPager) } + override fun onStart() { + super.onStart() + + // AlertDialog (its AlertController) adds FLAG_ALT_FOCUSABLE_IM when the initial custom + // view doesn't have any view that returns true for onCheckIsTextEditor(). + requireDialog().window!!.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + } + companion object { fun show(file: FileItem, fragment: Fragment) { FilePropertiesDialogFragment().putArgs(Args(file)).show(fragment) diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/FilePropertiesTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/FilePropertiesTabFragment.kt index 4fee51170..e577262ef 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/FilePropertiesTabFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/FilePropertiesTabFragment.kt @@ -12,9 +12,9 @@ import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.StringRes -import androidx.core.view.get -import androidx.core.view.size +import androidx.core.view.forEach import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding import me.zhanghai.android.files.databinding.FilePropertiesTabFragmentBinding import me.zhanghai.android.files.databinding.FilePropertiesTabItemBinding import me.zhanghai.android.files.util.Failure @@ -68,29 +68,41 @@ abstract class FilePropertiesTabFragment : Fragment() { } } - protected class ViewBuilder(private val linearLayout: LinearLayout) { - private var itemCount = 0 + protected class ViewBuilder(val linearLayout: LinearLayout) { + private val scrapViews = mutableMapOf, MutableList>() + + init { + linearLayout.forEach { view -> + val binding = view.tag as ViewBinding + scrapViews.getOrPut(binding.javaClass) { mutableListOf() } += binding + } + linearLayout.removeAllViews() + } + + @Suppress("UNCHECKED_CAST") + fun getScrapItemBinding(bindingClass: Class): T? = + scrapViews[bindingClass]?.removeLastOrNull() as T? + + fun addView(binding: ViewBinding) { + linearLayout.addView(binding.root) + } fun addItemView( hint: String, text: String, onClickListener: ((View) -> Unit)? = null ): TextView { - val itemBinding = if (itemCount < linearLayout.size) { - linearLayout[itemCount].tag as FilePropertiesTabItemBinding - } else { - FilePropertiesTabItemBinding.inflate( - linearLayout.context.layoutInflater, linearLayout, true - ).also { it.root.tag = it } - } + val itemBinding = + getScrapItemBinding(FilePropertiesTabItemBinding::class.java)?.also { addView(it) } + ?: FilePropertiesTabItemBinding.inflate( + linearLayout.context.layoutInflater, linearLayout, true + ) + .also { it.root.tag = it } itemBinding.textInputLayout.hint = hint itemBinding.textInputLayout.setDropDown(onClickListener != null) itemBinding.text.setText(text) itemBinding.text.setTextIsSelectable(onClickListener == null) - itemBinding.text.setOnClickListener( - onClickListener?.let { View.OnClickListener(it) } - ) - ++itemCount + itemBinding.text.setOnClickListener(onClickListener?.let { View.OnClickListener(it) }) return itemBinding.text } @@ -101,9 +113,7 @@ abstract class FilePropertiesTabFragment : Fragment() { ): TextView = addItemView(linearLayout.context.getString(hintRes), text, onClickListener) fun build() { - for (index in linearLayout.size - 1 downTo itemCount) { - linearLayout.removeViewAt(index) - } + scrapViews.clear() } } } diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/MediaMetadataRetrieverExtensions.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/MediaMetadataRetrieverExtensions.kt index 6a6ad5a14..c6ff89d3f 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/MediaMetadataRetrieverExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/MediaMetadataRetrieverExtensions.kt @@ -6,13 +6,13 @@ package me.zhanghai.android.files.fileproperties import android.media.MediaMetadataRetriever -import me.zhanghai.android.files.util.takeIfNotBlank -import org.threeten.bp.Instant import java.text.ParsePosition import java.text.SimpleDateFormat +import java.time.Instant import java.util.Locale import java.util.TimeZone import kotlin.math.max +import me.zhanghai.android.files.util.takeIfNotBlank fun MediaMetadataRetriever.extractMetadataNotBlank(keyCode: Int): String? = extractMetadata(keyCode)?.takeIfNotBlank() diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/ApkInfoLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/ApkInfoLiveData.kt index 21fb87951..85c2c4df0 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/ApkInfoLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/ApkInfoLiveData.kt @@ -49,11 +49,11 @@ class ApkInfoLiveData(path: Path) : PathObserverLiveData>(path val signingCertificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // PackageInfo.signatures returns only the oldest certificate if there are // past certificates on P and above for compatibility. - packageInfo.signingInfo.apkContentsSigners ?: emptyArray() + packageInfo.signingInfo?.apkContentsSigners } else { @Suppress("DEPRECATION") packageInfo.signatures - } + } ?: emptyArray() val signingCertificateDigests = signingCertificates .map { it.toByteArray().sha1Digest().toHexString() } val pastSigningCertificates = @@ -61,7 +61,7 @@ class ApkInfoLiveData(path: Path) : PathObserverLiveData>(path val signingInfo = packageInfo.signingInfo // SigningInfo.getSigningCertificateHistory() may return the current // certificate if there are no past certificates. - if (signingInfo.hasPastSigningCertificates()) { + if (signingInfo?.hasPastSigningCertificates() == true) { // SigningInfo.getSigningCertificateHistory() also returns the // current certificate. signingInfo.signingCertificateHistory?.toMutableList() diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/FilePropertiesApkTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/FilePropertiesApkTabFragment.kt index 949d67a6c..50f29ce8b 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/FilePropertiesApkTabFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/apk/FilePropertiesApkTabFragment.kt @@ -6,7 +6,6 @@ package me.zhanghai.android.files.fileproperties.apk import android.os.Build -import android.os.Bundle import java8.nio.file.Path import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith @@ -29,8 +28,8 @@ class FilePropertiesApkTabFragment : FilePropertiesTabFragment() { private val viewModel by viewModels { { FilePropertiesApkTabViewModel(args.path) } } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) + override fun onResume() { + super.onResume() viewModel.apkInfoLiveData.observe(viewLifecycleOwner) { onApkInfoChanged(it) } } @@ -50,7 +49,7 @@ class FilePropertiesApkTabFragment : FilePropertiesTabFragment() { packageInfo.longVersionCodeCompat ) ) - val applicationInfo = packageInfo.applicationInfo + val applicationInfo = packageInfo.applicationInfo!! // PackageParser didn't return minSdkVersion before N, so it's hard to implement a // compat version. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -78,7 +77,7 @@ class FilePropertiesApkTabFragment : FilePropertiesTabFragment() { } else { { PermissionListDialogFragment.show( - packageInfo.requestedPermissions, this@FilePropertiesApkTabFragment + packageInfo.requestedPermissions!!, this@FilePropertiesApkTabFragment ) } } diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfo.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfo.kt index 1e93db795..5d6e6e0d2 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfo.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfo.kt @@ -5,7 +5,7 @@ package me.zhanghai.android.files.fileproperties.audio -import org.threeten.bp.Duration +import java.time.Duration // @see com.android.providers.media.scan.ModernMediaScanner.scanItemAudio // @see com.android.documentsui.inspector.MediaView.showAudioData diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfoLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfoLiveData.kt index b277e3912..5e9ae098e 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfoLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/AudioInfoLiveData.kt @@ -8,6 +8,7 @@ package me.zhanghai.android.files.fileproperties.audio import android.media.MediaMetadataRetriever import android.os.AsyncTask import android.os.Build +import java.time.Duration import java8.nio.file.Path import me.zhanghai.android.files.compat.METADATA_KEY_SAMPLERATE import me.zhanghai.android.files.compat.use @@ -19,7 +20,6 @@ import me.zhanghai.android.files.util.Stateful import me.zhanghai.android.files.util.Success import me.zhanghai.android.files.util.setDataSource import me.zhanghai.android.files.util.valueCompat -import org.threeten.bp.Duration class AudioInfoLiveData(path: Path) : PathObserverLiveData>(path) { init { diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/FilePropertiesAudioTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/FilePropertiesAudioTabFragment.kt index 42f3dc34e..99f1c4fc0 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/FilePropertiesAudioTabFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/audio/FilePropertiesAudioTabFragment.kt @@ -5,7 +5,6 @@ package me.zhanghai.android.files.fileproperties.audio -import android.os.Bundle import java8.nio.file.Path import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith @@ -26,8 +25,8 @@ class FilePropertiesAudioTabFragment : FilePropertiesTabFragment() { private val viewModel by viewModels { { FilePropertiesAudioTabViewModel(args.path) } } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) + override fun onResume() { + super.onResume() viewModel.audioInfoLiveData.observe(viewLifecycleOwner) { onAudioInfoChanged(it) } } diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/ChecksumInfo.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/ChecksumInfo.kt new file mode 100644 index 000000000..4ad96e148 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/ChecksumInfo.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileproperties.checksum + +import androidx.annotation.StringRes +import me.zhanghai.android.files.R +import java.security.MessageDigest + +class ChecksumInfo(val checksums: Map) { + enum class Algorithm(@StringRes val nameRes: Int) { + CRC32(R.string.file_properties_checksum_crc32), + MD5(R.string.file_properties_checksum_md5), + SHA1(R.string.file_properties_checksum_sha_1), + SHA256(R.string.file_properties_checksum_sha_256), + SHA512(R.string.file_properties_checksum_sha_512); + + fun createMessageDigest(): MessageDigest = + when (this) { + CRC32 -> Crc32MessageDigest() + MD5 -> MessageDigest.getInstance("MD5") + SHA1 -> MessageDigest.getInstance("SHA-1") + SHA256 -> MessageDigest.getInstance("SHA-256") + SHA512 -> MessageDigest.getInstance("SHA-512") + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/ChecksumInfoLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/ChecksumInfoLiveData.kt new file mode 100644 index 000000000..9426db68d --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/ChecksumInfoLiveData.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileproperties.checksum + +import android.os.AsyncTask +import java8.nio.file.Path +import me.zhanghai.android.files.fileproperties.PathObserverLiveData +import me.zhanghai.android.files.provider.common.newInputStream +import me.zhanghai.android.files.util.Failure +import me.zhanghai.android.files.util.Loading +import me.zhanghai.android.files.util.Stateful +import me.zhanghai.android.files.util.Success +import me.zhanghai.android.files.util.toHexString +import me.zhanghai.android.files.util.valueCompat +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future + +class ChecksumInfoLiveData(path: Path) : PathObserverLiveData>(path) { + private var future: Future? = null + + init { + loadValue() + observe() + } + + override fun loadValue() { + future?.cancel(true) + value = Loading(value?.value) + future = (AsyncTask.THREAD_POOL_EXECUTOR as ExecutorService).submit { + val value = try { + val messageDigests = + ChecksumInfo.Algorithm.entries.associateWith { it.createMessageDigest() } + path.newInputStream().use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val readSize = inputStream.read(buffer) + if (readSize == -1) { + break + } + messageDigests.values.forEach { it.update(buffer, 0, readSize) } + } + } + val checksumInfo = ChecksumInfo( + messageDigests.mapValues { it.value.digest().toHexString() } + ) + Success(checksumInfo) + } catch (e: Exception) { + Failure(valueCompat.value, e) + } + postValue(value) + } + } + + override fun close() { + super.close() + + future?.cancel(true) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/Crc32MessageDigest.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/Crc32MessageDigest.kt new file mode 100644 index 000000000..919facc7d --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/Crc32MessageDigest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileproperties.checksum + +import java.security.MessageDigest +import java.util.zip.CRC32 + +class Crc32MessageDigest : MessageDigest("CRC32") { + private val crc32 = CRC32() + + override fun engineUpdate(input: Byte) { + crc32.update(input.toInt()) + } + + override fun engineUpdate(input: ByteArray, offset: Int, length: Int) { + crc32.update(input, offset, length) + } + + override fun engineDigest(): ByteArray { + val value = crc32.value + crc32.reset() + return ByteArray(4).apply { + this[0] = (value ushr 24).toByte() + this[1] = (value ushr 16).toByte() + this[2] = (value ushr 8).toByte() + this[3] = value.toByte() + } + } + + override fun engineReset() { + crc32.reset() + } +} \ No newline at end of file diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/FilePropertiesChecksumTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/FilePropertiesChecksumTabFragment.kt new file mode 100644 index 000000000..33d2de721 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/FilePropertiesChecksumTabFragment.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileproperties.checksum + +import androidx.core.widget.doAfterTextChanged +import java8.nio.file.Path +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith +import me.zhanghai.android.files.R +import me.zhanghai.android.files.databinding.FilePropertiesChecksumCompareItemBinding +import me.zhanghai.android.files.file.FileItem +import me.zhanghai.android.files.fileproperties.FilePropertiesTabFragment +import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.ParcelableParceler +import me.zhanghai.android.files.util.Stateful +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.layoutInflater +import me.zhanghai.android.files.util.viewModels + +class FilePropertiesChecksumTabFragment : FilePropertiesTabFragment() { + private val args by args() + + private val viewModel by viewModels { { FilePropertiesChecksumTabViewModel(args.path) } } + + override fun onResume() { + super.onResume() + + viewModel.checksumInfoLiveData.observe(viewLifecycleOwner) { onChecksumInfoChanged(it) } + } + + override fun refresh() { + viewModel.reload() + } + + private fun onChecksumInfoChanged(stateful: Stateful) { + bindView(stateful) { checksumInfo -> + checksumInfo.checksums.forEach { addItemView(it.key.nameRes, it.value) } + addCompareEdit(checksumInfo) + } + } + + private fun ViewBuilder.addCompareEdit(checksumInfo: ChecksumInfo) { + val binding = getScrapItemBinding(FilePropertiesChecksumCompareItemBinding::class.java) + ?.also { addView(it) } + ?: FilePropertiesChecksumCompareItemBinding.inflate( + linearLayout.context.layoutInflater, linearLayout, true + ) + .also { it.root.tag = it } + binding.compareEdit.doAfterTextChanged { editable -> + val text = editable!!.toString().trim() + if (text.isEmpty()) { + binding.compareLayout.helperText = null + binding.compareLayout.error = null + return@doAfterTextChanged + } + val matchingAlgorithm = checksumInfo.checksums.firstNotNullOfOrNull { + if (it.value.equals(text, true)) it.key else null + } + if (matchingAlgorithm != null) { + binding.compareLayout.helperText = + getString( + R.string.file_properties_checksum_compare_match_format, + getString(matchingAlgorithm.nameRes) + ) + return@doAfterTextChanged + } + val prefixMatchingAlgorithm = checksumInfo.checksums.firstNotNullOfOrNull { + if (it.value.startsWith(text, true)) it.key else null + } + if (prefixMatchingAlgorithm != null) { + binding.compareLayout.helperText = + getString( + R.string.file_properties_checksum_compare_prefix_match_format, + getString(prefixMatchingAlgorithm.nameRes) + ) + return@doAfterTextChanged + } + binding.compareLayout.error = + getString(R.string.file_properties_checksum_compare_no_match) + } + } + + companion object { + fun isAvailable(file: FileItem): Boolean = file.attributes.isRegularFile + } + + @Parcelize + class Args(val path: @WriteWith Path) : ParcelableArgs +} diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/FilePropertiesChecksumTabViewModel.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/FilePropertiesChecksumTabViewModel.kt new file mode 100644 index 000000000..98844fcb3 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/checksum/FilePropertiesChecksumTabViewModel.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.fileproperties.checksum + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import java8.nio.file.Path +import me.zhanghai.android.files.util.Stateful + +class FilePropertiesChecksumTabViewModel(path: Path) : ViewModel() { + private val _checksumInfoLiveData = ChecksumInfoLiveData(path) + val checksumInfoLiveData: LiveData> + get() = _checksumInfoLiveData + + fun reload() { + _checksumInfoLiveData.loadValue() + } + + override fun onCleared() { + _checksumInfoLiveData.close() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ExifInterfaceExtensions.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ExifInterfaceExtensions.kt index 7310a1f2c..fc235f083 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ExifInterfaceExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ExifInterfaceExtensions.kt @@ -7,12 +7,12 @@ package me.zhanghai.android.files.fileproperties.image import android.annotation.SuppressLint import androidx.exifinterface.media.ExifInterface -import me.zhanghai.android.files.util.takeIfNotBlank -import org.threeten.bp.Duration -import org.threeten.bp.Instant -import org.threeten.bp.ZoneId -import org.threeten.bp.ZoneOffset +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset import kotlin.math.roundToLong +import me.zhanghai.android.files.util.takeIfNotBlank fun ExifInterface.getAttributeNotBlank(tag: String): String? = getAttribute(tag)?.takeIfNotBlank() diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/image/FilePropertiesImageTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/image/FilePropertiesImageTabFragment.kt index a3275ffcd..78bcc00e9 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/image/FilePropertiesImageTabFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/image/FilePropertiesImageTabFragment.kt @@ -7,7 +7,6 @@ package me.zhanghai.android.files.fileproperties.image import android.content.Intent import android.location.Geocoder -import android.os.Bundle import androidx.lifecycle.lifecycleScope import java8.nio.file.Path import kotlinx.coroutines.Job @@ -44,8 +43,8 @@ class FilePropertiesImageTabFragment : FilePropertiesTabFragment() { private var addressJob: Job? = null - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) + override fun onResume() { + super.onResume() viewModel.imageInfoLiveData.observe(viewLifecycleOwner) { onImageInfoChanged(it) } } diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ImageInfo.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ImageInfo.kt index 6905fe27b..bb01a8983 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ImageInfo.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/image/ImageInfo.kt @@ -6,7 +6,7 @@ package me.zhanghai.android.files.fileproperties.image import android.util.Size -import org.threeten.bp.Instant +import java.time.Instant class ImageInfo( val dimensions: Size?, diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/FilePropertiesPermissionsTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/FilePropertiesPermissionTabFragment.kt similarity index 79% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/FilePropertiesPermissionsTabFragment.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/FilePropertiesPermissionTabFragment.kt index f91c158a0..ab35c0d60 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/FilePropertiesPermissionsTabFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/FilePropertiesPermissionTabFragment.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.os.Bundle import me.zhanghai.android.files.R @@ -17,7 +17,7 @@ import me.zhanghai.android.files.provider.common.toModeString import me.zhanghai.android.files.util.Stateful import me.zhanghai.android.files.util.viewModels -class FilePropertiesPermissionsTabFragment : FilePropertiesTabFragment() { +class FilePropertiesPermissionTabFragment : FilePropertiesTabFragment() { private val viewModel by viewModels({ requireParentFragment() }) override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -35,27 +35,27 @@ class FilePropertiesPermissionsTabFragment : FilePropertiesTabFragment() { val attributes = file.attributes as PosixFileAttributes val owner = attributes.owner() addItemView( - R.string.file_properties_permissions_owner, getPrincipalText(owner), owner?.let { - { SetOwnerDialogFragment.show(file, this@FilePropertiesPermissionsTabFragment) } + R.string.file_properties_permission_owner, getPrincipalText(owner), owner?.let { + { SetOwnerDialogFragment.show(file, this@FilePropertiesPermissionTabFragment) } } ) val group = attributes.group() addItemView( - R.string.file_properties_permissions_group, getPrincipalText(group), group?.let { - { SetGroupDialogFragment.show(file, this@FilePropertiesPermissionsTabFragment) } + R.string.file_properties_permission_group, getPrincipalText(group), group?.let { + { SetGroupDialogFragment.show(file, this@FilePropertiesPermissionTabFragment) } } ) val mode = attributes.mode() addItemView( - R.string.file_properties_permissions_mode, if (mode != null) { + R.string.file_properties_permission_mode, if (mode != null) { getString( - R.string.file_properties_permissions_mode_format, mode.toModeString(), + R.string.file_properties_permission_mode_format, mode.toModeString(), mode.toInt() ) } else { getString(R.string.unknown) }, if (mode != null && !attributes.isSymbolicLink) { - { SetModeDialogFragment.show(file, this@FilePropertiesPermissionsTabFragment) } + { SetModeDialogFragment.show(file, this@FilePropertiesPermissionTabFragment) } } else { null } @@ -63,7 +63,7 @@ class FilePropertiesPermissionsTabFragment : FilePropertiesTabFragment() { val seLinuxContext = attributes.seLinuxContext() if (seLinuxContext != null) { addItemView( - R.string.file_properties_permissions_selinux_context, + R.string.file_properties_permission_selinux_context, if (seLinuxContext.isNotEmpty()) { seLinuxContext.toString() } else { @@ -71,7 +71,7 @@ class FilePropertiesPermissionsTabFragment : FilePropertiesTabFragment() { } ) { SetSeLinuxContextDialogFragment.show( - file, this@FilePropertiesPermissionsTabFragment + file, this@FilePropertiesPermissionTabFragment ) } } @@ -82,7 +82,7 @@ class FilePropertiesPermissionsTabFragment : FilePropertiesTabFragment() { if (principal != null) { if (principal.name != null) { getString( - R.string.file_properties_permissions_principal_format, principal.name, + R.string.file_properties_permission_principal_format, principal.name, principal.id ) } else { diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/GroupListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/GroupListAdapter.kt similarity index 87% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/GroupListAdapter.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/GroupListAdapter.kt index 46cd2cc00..e649dd8aa 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/GroupListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/GroupListAdapter.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import androidx.annotation.DrawableRes import me.zhanghai.android.files.R diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/GroupListLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/GroupListLiveData.kt similarity index 84% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/GroupListLiveData.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/GroupListLiveData.kt index c4398620b..b054e0b0e 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/GroupListLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/GroupListLiveData.kt @@ -3,11 +3,11 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission -import me.zhanghai.android.files.provider.linux.syscall.Syscalls.endgrent -import me.zhanghai.android.files.provider.linux.syscall.Syscalls.getgrent -import me.zhanghai.android.files.provider.linux.syscall.Syscalls.setgrent +import me.zhanghai.android.files.provider.linux.syscall.Syscall.endgrent +import me.zhanghai.android.files.provider.linux.syscall.Syscall.getgrent +import me.zhanghai.android.files.provider.linux.syscall.Syscall.setgrent class GroupListLiveData : PrincipalListLiveData() { override val androidPrincipals: MutableList diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/ModeBitListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/ModeBitListAdapter.kt similarity index 95% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/ModeBitListAdapter.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/ModeBitListAdapter.kt index 042e5eecd..d2951c660 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/ModeBitListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/ModeBitListAdapter.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalItem.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalItem.kt similarity index 82% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalItem.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalItem.kt index b838a663c..7aa67d8dc 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalItem.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalItem.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.content.pm.ApplicationInfo diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalListAdapter.kt similarity index 87% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalListAdapter.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalListAdapter.kt index c4d2da179..69c037fe2 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalListAdapter.kt @@ -3,13 +3,13 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.view.ViewGroup import androidx.annotation.DrawableRes import androidx.recyclerview.widget.RecyclerView -import coil.clear -import coil.loadAny +import coil.dispose +import coil.load import me.zhanghai.android.files.R import me.zhanghai.android.files.coil.ignoreError import me.zhanghai.android.files.compat.getDrawableCompat @@ -46,24 +46,24 @@ abstract class PrincipalListAdapter( val icon = binding.iconImage.context.getDrawableCompat(principalIconRes) val applicationInfo = principal.applicationInfos.firstOrNull() if (applicationInfo != null) { - binding.iconImage.loadAny(applicationInfo) { + binding.iconImage.load(applicationInfo) { placeholder(icon) ignoreError() } } else { - binding.iconImage.clear() + binding.iconImage.dispose() binding.iconImage.setImageDrawable(icon) } binding.principalText.text = if (principal.name != null) { binding.principalText.context.getString( - R.string.file_properties_permissions_principal_format, principal.name, principal.id + R.string.file_properties_permission_principal_format, principal.name, principal.id ) } else { principal.id.toString() } binding.labelText.text = principal.applicationLabels.firstOrNull() ?: binding.labelText.resources.getString( - R.string.file_properties_permissions_set_principal_system + R.string.file_properties_permission_set_principal_system ) } diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalListLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalListLiveData.kt similarity index 97% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalListLiveData.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalListLiveData.kt index d5573f303..3ea336bc4 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/PrincipalListLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/PrincipalListLiveData.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.content.pm.ApplicationInfo import android.os.AsyncTask diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetGroupDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetGroupDialogFragment.kt similarity index 95% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetGroupDialogFragment.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetGroupDialogFragment.kt index 30b126178..61c3a170a 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetGroupDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetGroupDialogFragment.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import androidx.annotation.StringRes import androidx.fragment.app.Fragment @@ -23,7 +23,7 @@ class SetGroupDialogFragment : SetPrincipalDialogFragment() { override val viewModel: SetPrincipalViewModel by viewModels { { SetGroupViewModel() } } @StringRes - override val titleRes: Int = R.string.file_properties_permissions_set_group_title + override val titleRes: Int = R.string.file_properties_permission_set_group_title override fun createAdapter(selectionLiveData: SelectionLiveData): PrincipalListAdapter = GroupListAdapter(selectionLiveData) diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetGroupViewModel.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetGroupViewModel.kt similarity index 73% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetGroupViewModel.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetGroupViewModel.kt index 8c5dd0034..bb5d420c2 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetGroupViewModel.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetGroupViewModel.kt @@ -3,6 +3,6 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission class SetGroupViewModel : SetPrincipalViewModel(GroupListLiveData()) diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetModeDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetModeDialogFragment.kt similarity index 94% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetModeDialogFragment.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetModeDialogFragment.kt index ea04d7497..8b77472ac 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetModeDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetModeDialogFragment.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.app.Dialog import android.os.Bundle @@ -43,16 +43,16 @@ class SetModeDialogFragment : AppCompatDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = MaterialAlertDialogBuilder(requireContext(), theme) - .setTitle(R.string.file_properties_permissions_set_mode_title) + .setTitle(R.string.file_properties_permission_set_mode_title) .apply { binding = SetModeDialogBinding.inflate(context.layoutInflater) binding.ownerText.setOnClickListener { binding.ownerDropDown.show() } val isDirectory = args.file.attributes.isDirectory normalModeBitNames = getStringArray( if (isDirectory) { - R.array.file_properties_permissions_set_mode_normal_mode_bits_directory + R.array.file_properties_permission_set_mode_normal_mode_bits_directory } else { - R.array.file_properties_permissions_set_mode_normal_mode_bits_file + R.array.file_properties_permission_set_mode_normal_mode_bits_file } ) ownerAdapter = ModeBitListAdapter(OWNER_MODE_BITS, normalModeBitNames) @@ -74,7 +74,7 @@ class SetModeDialogFragment : AppCompatDialogFragment() { } binding.specialText.setOnClickListener { binding.specialDropDown.show() } specialModeBitNames = getStringArray( - R.array.file_properties_permissions_set_mode_special_mode_bits + R.array.file_properties_permission_set_mode_special_mode_bits ) specialAdapter = ModeBitListAdapter(SPECIAL_MODE_BITS, specialModeBitNames) binding.specialDropDown.setAdapter(specialAdapter) diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetModeViewModel.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetModeViewModel.kt similarity index 94% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetModeViewModel.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetModeViewModel.kt index 2a5d30165..5d48b5652 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetModeViewModel.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetModeViewModel.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetOwnerDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetOwnerDialogFragment.kt similarity index 95% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetOwnerDialogFragment.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetOwnerDialogFragment.kt index 14aee0192..bed5d1c8b 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetOwnerDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetOwnerDialogFragment.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import androidx.annotation.StringRes import androidx.fragment.app.Fragment @@ -24,7 +24,7 @@ class SetOwnerDialogFragment : SetPrincipalDialogFragment() { override val viewModel: SetPrincipalViewModel by viewModels { { SetOwnerViewModel() } } @StringRes - override val titleRes: Int = R.string.file_properties_permissions_set_owner_title + override val titleRes: Int = R.string.file_properties_permission_set_owner_title override fun createAdapter(selectionLiveData: SelectionLiveData): PrincipalListAdapter = UserListAdapter(selectionLiveData) diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetOwnerViewModel.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetOwnerViewModel.kt similarity index 73% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetOwnerViewModel.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetOwnerViewModel.kt index c25d11ca5..1555d5efa 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetOwnerViewModel.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetOwnerViewModel.kt @@ -3,6 +3,6 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission class SetOwnerViewModel : SetPrincipalViewModel(UserListLiveData()) diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetPrincipalDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetPrincipalDialogFragment.kt similarity index 98% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetPrincipalDialogFragment.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetPrincipalDialogFragment.kt index 4d7c3be11..7ad3b6729 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetPrincipalDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetPrincipalDialogFragment.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.app.Dialog import android.os.Bundle @@ -56,7 +56,7 @@ abstract class SetPrincipalDialogFragment : AppCompatDialogFragment() { } binding = SetPrincipalDialogBinding.inflate(context.layoutInflater) - binding.filterEdit.doAfterTextChanged { viewModel.filter = it.toString() } + binding.filterEdit.doAfterTextChanged { viewModel.filter = it!!.toString() } binding.recyclerView.layoutManager = LinearLayoutManager(context) adapter = createAdapter(selectionLiveData) binding.recyclerView.adapter = adapter diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetPrincipalViewModel.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetPrincipalViewModel.kt similarity index 97% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetPrincipalViewModel.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetPrincipalViewModel.kt index b4718e79d..da891cf4d 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetPrincipalViewModel.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetPrincipalViewModel.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetSeLinuxContextDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetSeLinuxContextDialogFragment.kt similarity index 91% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetSeLinuxContextDialogFragment.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetSeLinuxContextDialogFragment.kt index 56f6ed170..086d1e4b4 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/SetSeLinuxContextDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/SetSeLinuxContextDialogFragment.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import android.app.Dialog import android.os.Bundle @@ -31,7 +31,7 @@ class SetSeLinuxContextDialogFragment : AppCompatDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = MaterialAlertDialogBuilder(requireContext(), theme) - .setTitle(R.string.file_properties_permissions_set_selinux_context_title) + .setTitle(R.string.file_properties_permission_set_selinux_context_title) .apply { binding = SetSelinuxContextDialogBinding.inflate(context.layoutInflater) if (savedInstanceState == null) { @@ -43,7 +43,7 @@ class SetSeLinuxContextDialogFragment : AppCompatDialogFragment() { .setPositiveButton(android.R.string.ok) { _, _ -> setSeLinuxContext() } .setNegativeButton(android.R.string.cancel, null) .setNeutralButton( - R.string.file_properties_permissions_set_selinux_context_restore + R.string.file_properties_permission_set_selinux_context_restore ) { _, _ -> restoreSeLinuxContext() } .create() .apply { @@ -66,7 +66,7 @@ class SetSeLinuxContextDialogFragment : AppCompatDialogFragment() { private val argsSeLinuxContext: String get() { val attributes = args.file.attributes as PosixFileAttributes - return attributes.seLinuxContext()?.toString() ?: "" + return attributes.seLinuxContext()?.toString().orEmpty() } private fun restoreSeLinuxContext() { diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/UserListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/UserListAdapter.kt similarity index 87% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/UserListAdapter.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/UserListAdapter.kt index a62dc58ea..825344378 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/UserListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/UserListAdapter.kt @@ -3,7 +3,7 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission import androidx.annotation.DrawableRes import me.zhanghai.android.files.R diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/UserListLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/UserListLiveData.kt similarity index 81% rename from app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/UserListLiveData.kt rename to app/src/main/java/me/zhanghai/android/files/fileproperties/permission/UserListLiveData.kt index 67a18f898..a201b3bb7 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/permissions/UserListLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/permission/UserListLiveData.kt @@ -3,11 +3,11 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.fileproperties.permissions +package me.zhanghai.android.files.fileproperties.permission -import me.zhanghai.android.files.provider.linux.syscall.Syscalls.endpwent -import me.zhanghai.android.files.provider.linux.syscall.Syscalls.getpwent -import me.zhanghai.android.files.provider.linux.syscall.Syscalls.setpwent +import me.zhanghai.android.files.provider.linux.syscall.Syscall.endpwent +import me.zhanghai.android.files.provider.linux.syscall.Syscall.getpwent +import me.zhanghai.android.files.provider.linux.syscall.Syscall.setpwent class UserListLiveData : PrincipalListLiveData() { override val androidPrincipals: MutableList diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/video/FilePropertiesVideoTabFragment.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/video/FilePropertiesVideoTabFragment.kt index bd46db520..6118f0cd2 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/video/FilePropertiesVideoTabFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/video/FilePropertiesVideoTabFragment.kt @@ -7,7 +7,6 @@ package me.zhanghai.android.files.fileproperties.video import android.content.Intent import android.location.Geocoder -import android.os.Bundle import androidx.lifecycle.lifecycleScope import java8.nio.file.Path import kotlinx.coroutines.Job @@ -41,8 +40,8 @@ class FilePropertiesVideoTabFragment : FilePropertiesTabFragment() { private var addressJob: Job? = null - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) + override fun onResume() { + super.onResume() viewModel.videoInfoLiveData.observe(viewLifecycleOwner) { onVideoInfoChanged(it) } } diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfo.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfo.kt index 662cec234..726661fbd 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfo.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfo.kt @@ -6,8 +6,8 @@ package me.zhanghai.android.files.fileproperties.video import android.util.Size -import org.threeten.bp.Duration -import org.threeten.bp.Instant +import java.time.Duration +import java.time.Instant // @see com.android.providers.media.scan.ModernMediaScanner.scanItemVideo // @see com.android.documentsui.inspector.MediaView.showVideoData diff --git a/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfoLiveData.kt b/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfoLiveData.kt index 1cae4f2eb..a62511e7e 100644 --- a/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfoLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/fileproperties/video/VideoInfoLiveData.kt @@ -8,6 +8,7 @@ package me.zhanghai.android.files.fileproperties.video import android.media.MediaMetadataRetriever import android.os.AsyncTask import android.util.Size +import java.time.Duration import java8.nio.file.Path import me.zhanghai.android.files.compat.use import me.zhanghai.android.files.fileproperties.PathObserverLiveData @@ -20,7 +21,6 @@ import me.zhanghai.android.files.util.Stateful import me.zhanghai.android.files.util.Success import me.zhanghai.android.files.util.setDataSource import me.zhanghai.android.files.util.valueCompat -import org.threeten.bp.Duration class VideoInfoLiveData(path: Path) : PathObserverLiveData>(path) { init { diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerAddTilePreference.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerAddTilePreference.kt new file mode 100644 index 000000000..f41a9be02 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerAddTilePreference.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ftpserver + +import android.app.StatusBarManager +import android.content.Context +import android.os.Build +import android.util.AttributeSet +import androidx.annotation.AttrRes +import androidx.annotation.RequiresApi +import androidx.annotation.StyleRes +import androidx.preference.Preference +import me.zhanghai.android.files.R +import me.zhanghai.android.files.compat.getSystemServiceCompat +import me.zhanghai.android.files.compat.mainExecutorCompat +import me.zhanghai.android.files.util.requestAddTileService +import me.zhanghai.android.files.util.showToast + +class FtpServerAddTilePreference : Preference { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int) : super( + context, attrs, defStyleAttr + ) + + constructor( + context: Context, + attrs: AttributeSet?, + @AttrRes defStyleAttr: Int, + @StyleRes defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + init { + isPersistent = false + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onClick() { + val statusBarManager = context.getSystemServiceCompat(StatusBarManager::class.java) + statusBarManager.requestAddTileService( + FtpServerTileService::class.java, context.mainExecutorCompat + ) { result -> + val resultRes = when (result) { + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_NOT_ADDED -> + return@requestAddTileService + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED -> + R.string.ftp_server_add_tile_result_already_added + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> + R.string.ftp_server_add_tile_result_added + else -> R.string.ftp_server_add_tile_result_error + } + context.showToast(resultRes) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerServiceNotification.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerNotification.kt similarity index 73% rename from app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerServiceNotification.kt rename to app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerNotification.kt index 11030c1de..b358ec580 100644 --- a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerServiceNotification.kt +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerNotification.kt @@ -10,13 +10,15 @@ import android.app.Service import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat import me.zhanghai.android.files.R import me.zhanghai.android.files.app.NotificationIds +import me.zhanghai.android.files.compat.stopForegroundCompat import me.zhanghai.android.files.util.NotificationChannelTemplate import me.zhanghai.android.files.util.NotificationTemplate import me.zhanghai.android.files.util.createIntent -val ftpServerServiceNotificationTemplate: NotificationTemplate = +val ftpServerServiceNotificationTemplate = NotificationTemplate( NotificationChannelTemplate( "ftp_server", @@ -28,14 +30,23 @@ val ftpServerServiceNotificationTemplate: NotificationTemplate = colorRes = R.color.color_primary, smallIcon = R.drawable.notification_icon, contentTitleRes = R.string.ftp_server_notification_title, - contentTextRes = R.string.ftp_server_notification_text, ongoing = true, + onlyAlertOnce = true, category = NotificationCompat.CATEGORY_SERVICE, priority = NotificationCompat.PRIORITY_LOW ) -object FtpServerServiceNotification { - fun startForeground(service: Service) { +class FtpServerNotification(private val service: Service) { + private val receiver = FtpServerUrl.createChangeReceiver(service) { doStartForeground() } + + fun startForeground() { + doStartForeground() + receiver.register() + } + + private fun doStartForeground() { + val contextText = FtpServerUrl.getUrl() + ?: service.getString(R.string.ftp_server_notification_text_no_local_inet_address) val contentIntent = FtpServerActivity::class.createIntent() var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -49,16 +60,17 @@ object FtpServerServiceNotification { service, FtpServerReceiver::class.hashCode(), stopIntent, pendingIntentFlags ) val notification = ftpServerServiceNotificationTemplate.createBuilder(service) + .setContentText(contextText) .setContentIntent(contentPendingIntent) .addAction( - R.drawable.stop_icon_white_24dp, service.getString(R.string.stop), - stopPendingIntent + R.drawable.stop_icon_white_24dp, service.getString(R.string.stop), stopPendingIntent ) .build() service.startForeground(NotificationIds.FTP_SERVER, notification) } - fun stopForeground(service: Service) { - service.stopForeground(true) + fun stopForeground() { + receiver.unregister() + service.stopForegroundCompat(ServiceCompat.STOP_FOREGROUND_REMOVE) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerService.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerService.kt index 62339129f..1e0c9aedd 100644 --- a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerService.kt +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerService.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import me.zhanghai.android.files.compat.mainExecutorCompat import me.zhanghai.android.files.settings.Settings +import me.zhanghai.android.files.util.WakeWifiLock import me.zhanghai.android.files.util.showToast import me.zhanghai.android.files.util.valueCompat import java.util.concurrent.Executors @@ -26,7 +27,9 @@ class FtpServerService : Service() { _stateLiveData.value = value } - private lateinit var wakeLock: FtpServerWakeLock + private lateinit var wakeWifiLock: WakeWifiLock + + private lateinit var notification: FtpServerNotification private val executorService = Executors.newSingleThreadExecutor() @@ -35,7 +38,8 @@ class FtpServerService : Service() { override fun onCreate() { super.onCreate() - wakeLock = FtpServerWakeLock() + wakeWifiLock = WakeWifiLock(FtpServerService::class.java.simpleName) + notification = FtpServerNotification(this) executeStart() } @@ -54,8 +58,8 @@ class FtpServerService : Service() { if (state == State.STARTING || state == State.RUNNING) { return } - wakeLock.acquire() - FtpServerServiceNotification.startForeground(this) + wakeWifiLock.isAcquired = true + notification.startForeground() state = State.STARTING executorService.execute { doStart() } } @@ -63,8 +67,8 @@ class FtpServerService : Service() { private fun onStartError(exception: Exception) { state = State.STOPPED showToast(exception.toString()) - FtpServerServiceNotification.stopForeground(this) - wakeLock.release() + notification.stopForeground() + wakeWifiLock.isAcquired = false stopSelf() } @@ -74,8 +78,8 @@ class FtpServerService : Service() { } state = State.STOPPING executorService.execute { doStop() } - FtpServerServiceNotification.stopForeground(this) - wakeLock.release() + notification.stopForeground() + wakeWifiLock.isAcquired = false } @WorkerThread diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerTileService.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerTileService.kt index 7ee30d78a..145f1d1c4 100644 --- a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerTileService.kt +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerTileService.kt @@ -10,6 +10,7 @@ import android.service.quicksettings.Tile import android.service.quicksettings.TileService import androidx.annotation.RequiresApi import androidx.lifecycle.Observer +import me.zhanghai.android.files.compat.doWithStartForegroundServiceAllowed @RequiresApi(Build.VERSION_CODES.N) class FtpServerTileService : TileService() { @@ -30,8 +31,8 @@ class FtpServerTileService : TileService() { private fun onFtpServerStateChanged(state: FtpServerService.State) { val tile = qsTile when (state) { - FtpServerService.State.STARTING, FtpServerService.State.RUNNING -> - tile.state = Tile.STATE_ACTIVE + FtpServerService.State.STARTING, + FtpServerService.State.RUNNING -> tile.state = Tile.STATE_ACTIVE FtpServerService.State.STOPPING -> tile.state = Tile.STATE_UNAVAILABLE FtpServerService.State.STOPPED -> tile.state = Tile.STATE_INACTIVE } @@ -49,6 +50,6 @@ class FtpServerTileService : TileService() { } private fun toggle() { - FtpServerService.toggle(this) + doWithStartForegroundServiceAllowed { FtpServerService.toggle(this) } } } diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrl.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrl.kt new file mode 100644 index 000000000..f465b9a9f --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrl.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ftpserver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import me.zhanghai.android.files.settings.Settings +import me.zhanghai.android.files.util.RuntimeBroadcastReceiver +import me.zhanghai.android.files.util.getLocalAddress +import me.zhanghai.android.files.util.valueCompat +import java.net.InetAddress + +object FtpServerUrl { + fun getUrl(): String? { + val localAddress = InetAddress::class.getLocalAddress() ?: return null + val username = if (!Settings.FTP_SERVER_ANONYMOUS_LOGIN.valueCompat) { + Settings.FTP_SERVER_USERNAME.valueCompat + } else { + null + } + val host = localAddress.hostAddress + val port = Settings.FTP_SERVER_PORT.valueCompat + return "ftp://${if (username != null) "$username@" else ""}$host:$port/" + } + + fun createChangeReceiver(context: Context, onChange: () -> Unit): RuntimeBroadcastReceiver = + RuntimeBroadcastReceiver( + IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION), object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + onChange() + } + }, context + ) +} diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrlPreference.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrlPreference.kt index 9fe80cdf3..71c41eaa9 100644 --- a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrlPreference.kt +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerUrlPreference.kt @@ -5,11 +5,7 @@ package me.zhanghai.android.files.ftpserver -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager import android.util.AttributeSet import android.view.ContextMenu import android.view.ContextMenu.ContextMenuInfo @@ -25,16 +21,13 @@ import me.zhanghai.android.files.R import me.zhanghai.android.files.app.clipboardManager import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.util.copyText -import me.zhanghai.android.files.util.getLocalAddress import me.zhanghai.android.files.util.valueCompat -import java.net.InetAddress class FtpServerUrlPreference : Preference { - private val observer = Observer { updateSummary() } - private val connectivityReceiver = ConnectivityReceiver() + private val observer = Observer { updateUrl() } + private val receiver = FtpServerUrl.createChangeReceiver(context) { updateUrl() } - private val contextMenuListener = ContextMenuListener() - private var hasUrl = false + private var url: String? = null constructor(context: Context) : super(context) @@ -53,7 +46,7 @@ class FtpServerUrlPreference : Preference { init { isPersistent = false - updateSummary() + updateUrl() } override fun onAttached() { @@ -62,9 +55,7 @@ class FtpServerUrlPreference : Preference { Settings.FTP_SERVER_ANONYMOUS_LOGIN.observeForever(observer) Settings.FTP_SERVER_USERNAME.observeForever(observer) Settings.FTP_SERVER_PORT.observeForever(observer) - context.registerReceiver( - connectivityReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) - ) + receiver.register() } override fun onDetached() { @@ -73,76 +64,45 @@ class FtpServerUrlPreference : Preference { Settings.FTP_SERVER_ANONYMOUS_LOGIN.removeObserver(observer) Settings.FTP_SERVER_USERNAME.removeObserver(observer) Settings.FTP_SERVER_PORT.removeObserver(observer) - context.unregisterReceiver(connectivityReceiver) + receiver.unregister() } - private fun updateSummary() { - val localAddress = InetAddress::class.getLocalAddress() - val summary: String - if (localAddress != null) { - val username = if (!Settings.FTP_SERVER_ANONYMOUS_LOGIN.valueCompat) { - Settings.FTP_SERVER_USERNAME.valueCompat - } else { - null - } - val host = localAddress.hostAddress - val port = Settings.FTP_SERVER_PORT.valueCompat - summary = "ftp://${if (username != null) "$username@" else ""}$host:$port/" - hasUrl = true - } else { - summary = context.getString(R.string.ftp_server_url_summary_no_local_inet_address) - hasUrl = false - } - setSummary(summary) + private fun updateUrl() { + url = FtpServerUrl.getUrl() + summary = url ?: context.getString(R.string.ftp_server_url_summary_no_local_inet_address) } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) - holder.itemView.setOnCreateContextMenuListener(contextMenuListener) - } - - private inner class ConnectivityReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (val action = intent.action) { - ConnectivityManager.CONNECTIVITY_ACTION -> updateSummary() - else -> throw IllegalArgumentException(action) - } - } - } - - private inner class ContextMenuListener : OnCreateContextMenuListener { - override fun onCreateContextMenu( - menu: ContextMenu, view: View, menuInfo: ContextMenuInfo? - ) { - if (!hasUrl) { - return - } - val url = summary!! - menu - .setHeaderTitle(url) - .apply { + holder.itemView.setOnCreateContextMenuListener(object : OnCreateContextMenuListener { + override fun onCreateContextMenu( + menu: ContextMenu, + view: View, + menuInfo: ContextMenuInfo? + ) { + val url = url ?: return + menu.apply { + setHeaderTitle(url) add(Menu.NONE, Menu.NONE, Menu.NONE, R.string.ftp_server_url_menu_copy_url) .setOnMenuItemClickListener { clipboardManager.copyText(url, context) true } - } - .apply { if (!Settings.FTP_SERVER_ANONYMOUS_LOGIN.valueCompat) { val password = Settings.FTP_SERVER_PASSWORD.valueCompat if (password.isNotEmpty()) { add( Menu.NONE, Menu.NONE, Menu.NONE, R.string.ftp_server_url_menu_copy_password - ) - .setOnMenuItemClickListener { - clipboardManager.copyText(password, context) - true - } + ).setOnMenuItemClickListener { + clipboardManager.copyText(password, context) + true + } } } } - } + } + }) } } diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/EditBookmarkDirectoryDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/navigation/EditBookmarkDirectoryDialogFragment.kt index d14929423..e91a93958 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/EditBookmarkDirectoryDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/EditBookmarkDirectoryDialogFragment.kt @@ -30,9 +30,8 @@ import me.zhanghai.android.files.util.putState import me.zhanghai.android.files.util.setTextWithSelection class EditBookmarkDirectoryDialogFragment : AppCompatDialogFragment() { - private val pickPathLauncher = registerForActivityResult( - FileListActivity.PickDirectoryContract(), this::onPickPathResult - ) + private val openPathLauncher = + registerForActivityResult(FileListActivity.OpenDirectoryContract(), ::onOpenPathResult) private val args by args() @@ -75,10 +74,10 @@ class EditBookmarkDirectoryDialogFragment : AppCompatDialogFragment() { } private fun onEditPath() { - pickPathLauncher.launchSafe(path, this) + openPathLauncher.launchSafe(path, this) } - private fun onPickPathResult(result: Path?) { + private fun onOpenPathResult(result: Path?) { result ?: return path = result updatePathText() diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationFragment.kt b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationFragment.kt index 4b2b78737..897189d23 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationFragment.kt @@ -5,6 +5,7 @@ package me.zhanghai.android.files.navigation +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -14,16 +15,6 @@ import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.LinearLayoutManager import java8.nio.file.Path import me.zhanghai.android.files.databinding.NavigationFragmentBinding -import me.zhanghai.android.files.file.DocumentTreeUri -import me.zhanghai.android.files.file.asDocumentTreeUri -import me.zhanghai.android.files.file.releasePersistablePermission -import me.zhanghai.android.files.provider.document.documentTreeUri -import me.zhanghai.android.files.provider.document.isDocumentPath -import me.zhanghai.android.files.settings.StandardDirectoryListActivity -import me.zhanghai.android.files.storage.AddStorageDialogActivity -import me.zhanghai.android.files.storage.Storage -import me.zhanghai.android.files.util.createIntent -import me.zhanghai.android.files.util.putArgs import me.zhanghai.android.files.util.startActivitySafe class NavigationFragment : Fragment(), NavigationItem.Listener { @@ -47,7 +38,7 @@ class NavigationFragment : Fragment(), NavigationItem.Listener { binding.recyclerView.setHasFixedSize(true) // TODO: Needed? - //binding.recyclerView.setItemAnimator(new NoChangeAnimationItemAnimator()); + //binding.recyclerView.setItemAnimator(new NoChangeAnimationItemAnimator()) val context = requireContext() binding.recyclerView.layoutManager = LinearLayoutManager(context) adapter = NavigationListAdapter(this, context) @@ -77,34 +68,8 @@ class NavigationFragment : Fragment(), NavigationItem.Listener { listener.navigateToRoot(path) } - override fun onAddStorage() { - startActivitySafe(AddStorageDialogActivity::class.createIntent()) - } - - override fun onEditStorage(storage: Storage) { - startActivitySafe(storage.createEditIntent()) - } - - // TODO - // FIXME: Navigate away on async storage removal - fun removeDocumentTree(treeUri: DocumentTreeUri) { - treeUri.releasePersistablePermission() - val currentPath = listener.currentPath - if (currentPath.isDocumentPath - && currentPath.documentTreeUri.asDocumentTreeUri() == treeUri) { - listener.navigateToDefaultRoot() - } - } - - override fun onEditStandardDirectory(standardDirectory: StandardDirectory) { - startActivitySafe(StandardDirectoryListActivity::class.createIntent()) - } - - override fun onEditBookmarkDirectory(bookmarkDirectory: BookmarkDirectory) { - startActivitySafe( - EditBookmarkDirectoryDialogActivity::class.createIntent() - .putArgs(EditBookmarkDirectoryDialogFragment.Args(bookmarkDirectory)) - ) + override fun launchIntent(intent: Intent) { + startActivitySafe(intent) } override fun closeNavigationDrawer() { diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItem.kt b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItem.kt index 1f88ae2a0..48def26ee 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItem.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItem.kt @@ -11,7 +11,6 @@ import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import java8.nio.file.Path import me.zhanghai.android.files.compat.getDrawableCompat -import me.zhanghai.android.files.storage.Storage abstract class NavigationItem { abstract val id: Long @@ -35,11 +34,7 @@ abstract class NavigationItem { val currentPath: Path fun navigateTo(path: Path) fun navigateToRoot(path: Path) - fun onAddStorage() - fun onEditStorage(storage: Storage) - fun onEditStandardDirectory(standardDirectory: StandardDirectory) - fun onEditBookmarkDirectory(bookmarkDirectory: BookmarkDirectory) + fun launchIntent(intent: Intent) fun closeNavigationDrawer() - fun startActivity(intent: Intent) } } diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt index 3d0995584..f9851b69c 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationItems.kt @@ -25,18 +25,22 @@ import me.zhanghai.android.files.file.asFileSize import me.zhanghai.android.files.ftpserver.FtpServerActivity import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.settings.SettingsActivity +import me.zhanghai.android.files.settings.StandardDirectoryListActivity +import me.zhanghai.android.files.storage.AddStorageDialogActivity import me.zhanghai.android.files.storage.FileSystemRoot import me.zhanghai.android.files.storage.Storage import me.zhanghai.android.files.storage.StorageVolumeListLiveData import me.zhanghai.android.files.util.createIntent import me.zhanghai.android.files.util.isMounted +import me.zhanghai.android.files.util.putArgs +import me.zhanghai.android.files.util.supportsExternalStorageManager import me.zhanghai.android.files.util.valueCompat val navigationItems: List get() = mutableListOf().apply { addAll(storageItems) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (Environment::class.supportsExternalStorageManager()) { // Starting with R, we can get read/write access to non-primary storage volumes with // MANAGE_EXTERNAL_STORAGE. However before R, we only have read-only access to them // and need to use the Storage Access Framework instead, so hide them in this case @@ -60,7 +64,10 @@ val navigationItems: List private val storageItems: List @Size(min = 0) - get() = Settings.STORAGES.valueCompat.filter { it.isVisible }.map { StorageItem(it) } + get() = + Settings.STORAGES.valueCompat.filter { it.isVisible }.map { + if (it.path != null) PathStorageItem(it) else IntentStorageItem(it) + } private abstract class PathItem(val path: Path) : NavigationItem() { override fun isChecked(listener: Listener): Boolean = listener.currentPath == path @@ -75,9 +82,9 @@ private abstract class PathItem(val path: Path) : NavigationItem() { } } -private class StorageItem( +private class PathStorageItem( private val storage: Storage -) : PathItem(storage.path), NavigationRoot { +) : PathItem(storage.path!!), NavigationRoot { init { require(storage.isVisible) } @@ -95,13 +102,40 @@ private class StorageItem( storage.linuxPath?.let { getStorageSubtitle(it, context) } override fun onLongClick(listener: Listener): Boolean { - listener.onEditStorage(storage) + listener.launchIntent(storage.createEditIntent()) return true } override fun getName(context: Context): String = getTitle(context) } +private class IntentStorageItem( + private val storage: Storage +) : NavigationItem() { + init { + require(storage.isVisible) + } + + override val id: Long + get() = storage.id + + override val iconRes: Int + @DrawableRes + get() = storage.iconRes + + override fun getTitle(context: Context): String = storage.getName(context) + + override fun onClick(listener: Listener) { + listener.launchIntent(storage.createIntent()!!) + listener.closeNavigationDrawer() + } + + override fun onLongClick(listener: Listener): Boolean { + listener.launchIntent(storage.createEditIntent()) + return true + } +} + private val storageVolumeItems: List @Size(min = 0) get() = @@ -162,7 +196,7 @@ private class AddStorageItem : NavigationItem() { context.getString(R.string.navigation_add_storage) override fun onClick(listener: Listener) { - listener.onAddStorage() + listener.launchIntent(AddStorageDialogActivity::class.createIntent()) } } @@ -190,7 +224,7 @@ private class StandardDirectoryItem( override fun getTitle(context: Context): String = standardDirectory.getTitle(context) override fun onLongClick(listener: Listener): Boolean { - listener.onEditStandardDirectory(standardDirectory) + listener.launchIntent(StandardDirectoryListActivity::class.createIntent()) return true } } @@ -311,7 +345,10 @@ private class BookmarkDirectoryItem( override fun getTitle(context: Context): String = bookmarkDirectory.name override fun onLongClick(listener: Listener): Boolean { - listener.onEditBookmarkDirectory(bookmarkDirectory) + listener.launchIntent( + EditBookmarkDirectoryDialogActivity::class.createIntent() + .putArgs(EditBookmarkDirectoryDialogFragment.Args(bookmarkDirectory)) + ) return true } } @@ -319,15 +356,15 @@ private class BookmarkDirectoryItem( private val menuItems: List @Size(3) get() = listOf( - ActivityMenuItem( + IntentMenuItem( R.drawable.shared_directory_icon_white_24dp, R.string.navigation_ftp_server, FtpServerActivity::class.createIntent() ), - ActivityMenuItem( + IntentMenuItem( R.drawable.settings_icon_white_24dp, R.string.navigation_settings, SettingsActivity::class.createIntent() ), - ActivityMenuItem( + IntentMenuItem( R.drawable.about_icon_white_24dp, R.string.navigation_about, AboutActivity::class.createIntent() ) @@ -340,7 +377,7 @@ private abstract class MenuItem( override fun getTitle(context: Context): String = context.getString(titleRes) } -private class ActivityMenuItem( +private class IntentMenuItem( @DrawableRes iconRes: Int, @StringRes titleRes: Int, private val intent: Intent @@ -349,8 +386,7 @@ private class ActivityMenuItem( get() = intent.component.hashCode().toLong() override fun onClick(listener: Listener) { - // TODO: startActivitySafe()? - listener.startActivity(intent) + listener.launchIntent(intent) listener.closeNavigationDrawer() } } diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationListAdapter.kt index 93aa34a1e..b6d45b3a2 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationListAdapter.kt @@ -40,27 +40,38 @@ class NavigationListAdapter( ) : SimpleAdapter() { @SuppressLint("PrivateResource", "RestrictedApi") private val viewAttributes = context.obtainStyledAttributesCompat( - null, R.styleable.NavigationView, R.attr.navigationViewStyle, - R.style.Widget_MaterialComponents_NavigationView + null, com.google.android.material.R.styleable.NavigationView, + com.google.android.material.R.attr.navigationViewStyle, + com.google.android.material.R.style.Widget_MaterialComponents_NavigationView ).use { a -> - val itemShapeAppearance = a.getResourceId(R.styleable.NavigationView_itemShapeAppearance, 0) + val itemShapeAppearance = a.getResourceId( + com.google.android.material.R.styleable.NavigationView_itemShapeAppearance, 0 + ) val itemShapeAppearanceOverlay = a.getResourceId( - R.styleable.NavigationView_itemShapeAppearanceOverlay, 0 + com.google.android.material.R.styleable.NavigationView_itemShapeAppearanceOverlay, 0 + ) + val itemShapeFillColor = a.getColorStateList( + com.google.android.material.R.styleable.NavigationView_itemShapeFillColor + ) + val itemShapeInsetStart = a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemShapeInsetStart, 0 + ) + val itemShapeInsetEnd = a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemShapeInsetEnd, 0 + ) + val itemShapeInsetTop = a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemShapeInsetTop, 0 + ) + val itemShapeInsetBottom = a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemShapeInsetBottom, 0 ) - val itemShapeFillColor = a.getColorStateList(R.styleable.NavigationView_itemShapeFillColor) - val itemShapeInsetStart = - a.getDimensionPixelSize(R.styleable.NavigationView_itemShapeInsetStart, 0) - val itemShapeInsetEnd = - a.getDimensionPixelSize(R.styleable.NavigationView_itemShapeInsetEnd, 0) - val itemShapeInsetTop = - a.getDimensionPixelSize(R.styleable.NavigationView_itemShapeInsetTop, 0) - val itemShapeInsetBottom = - a.getDimensionPixelSize(R.styleable.NavigationView_itemShapeInsetBottom, 0) val itemBackground = createItemShapeDrawable( itemShapeAppearance, itemShapeAppearanceOverlay, itemShapeFillColor, itemShapeInsetStart, itemShapeInsetEnd, itemShapeInsetTop, itemShapeInsetBottom, context ) - val controlHighlightColor = context.getColorStateListByAttr(R.attr.colorControlHighlight) + val controlHighlightColor = context.getColorStateListByAttr( + com.google.android.material.R.attr.colorControlHighlight + ) val itemForegroundMaskFillColor = ColorStateList.valueOf(Color.WHITE) val itemForegroundMask = createItemShapeDrawable( itemShapeAppearance, itemShapeAppearanceOverlay, itemForegroundMaskFillColor, @@ -68,28 +79,46 @@ class NavigationListAdapter( ) val itemForeground = RippleDrawable(controlHighlightColor, null, itemForegroundMask) context.obtainStyledAttributesCompat( - null, R.styleable.NavigationViewExtra, R.attr.navigationViewStyle, 0 + null, R.styleable.NavigationViewExtra, + com.google.android.material.R.attr.navigationViewStyle, 0 ).use { a2 -> ViewAttributes( - a.getDimensionPixelSize(R.styleable.NavigationView_itemHorizontalPadding, 0), - a.getDimensionPixelSize(R.styleable.NavigationView_itemVerticalPadding, 0), + a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemHorizontalPadding, 0 + ), + a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemVerticalPadding, 0 + ), itemBackground, itemForeground, - a.getDimensionPixelSize(R.styleable.NavigationView_itemIconSize, 0), - a.getColorStateList(R.styleable.NavigationView_itemIconTint), - a.getDimensionPixelSize(R.styleable.NavigationView_itemIconPadding, 0), + a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemIconSize, 0 + ), + a.getColorStateList( + com.google.android.material.R.styleable.NavigationView_itemIconTint + ), + a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_itemIconPadding, 0 + ), a.getResourceId( - R.styleable.NavigationView_itemTextAppearance, ResourcesCompat.ID_NULL + com.google.android.material.R.styleable.NavigationView_itemTextAppearance, + ResourcesCompat.ID_NULL + ), + a.getColorStateList( + com.google.android.material.R.styleable.NavigationView_itemTextColor ), - a.getColorStateList(R.styleable.NavigationView_itemTextColor), a2.getResourceId( R.styleable.NavigationViewExtra_itemSubtitleTextAppearance, ResourcesCompat.ID_NULL ), a2.getColorStateList(R.styleable.NavigationViewExtra_itemSubtitleTextColor), a2.getDimension(R.styleable.NavigationViewExtra_itemSubtitleTextSize, 0f), - a.getDimensionPixelSize(R.styleable.NavigationView_dividerInsetStart, 0), - a.getDimensionPixelSize(R.styleable.NavigationView_dividerInsetEnd, 0), + a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_dividerInsetStart, 0 + ), + a.getDimensionPixelSize( + com.google.android.material.R.styleable.NavigationView_dividerInsetEnd, 0 + ), a2.getDimensionPixelSize(R.styleable.NavigationViewExtra_dividerVerticalPadding, 0) ) } @@ -131,7 +160,7 @@ class NavigationListAdapter( } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (ViewType.values()[viewType]) { + return when (ViewType.entries[viewType]) { ViewType.ITEM -> ItemHolder( NavigationItemBinding.inflate(parent.context.layoutInflater, parent, false) @@ -193,7 +222,7 @@ class NavigationListAdapter( position: Int, payloads: List ) { - when (ViewType.values()[getItemViewType(position)]) { + when (ViewType.entries[getItemViewType(position)]) { ViewType.ITEM -> { val item = getItem(position)!! val binding = (holder as ItemHolder).binding diff --git a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationRecyclerView.kt b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationRecyclerView.kt index e793c4df5..3b5c59b67 100644 --- a/app/src/main/java/me/zhanghai/android/files/navigation/NavigationRecyclerView.kt +++ b/app/src/main/java/me/zhanghai/android/files/navigation/NavigationRecyclerView.kt @@ -23,9 +23,11 @@ import me.zhanghai.android.files.util.getDrawableByAttr import me.zhanghai.android.files.util.isLayoutDirectionRtl class NavigationRecyclerView : RecyclerView { - private val verticalPadding = - context.getDimensionPixelSize(R.dimen.design_navigation_padding_bottom) - private val actionBarSize = context.getDimensionPixelSizeByAttr(R.attr.actionBarSize) + private val verticalPadding = context.getDimensionPixelSize( + com.google.android.material.R.dimen.design_navigation_padding_bottom + ) + private val actionBarSize = + context.getDimensionPixelSizeByAttr(androidx.appcompat.R.attr.actionBarSize) private val maxWidth = context.getDimensionPixelSize(R.dimen.navigation_max_width) private var scrim = context.getDrawableByAttr(android.R.attr.statusBarColor) diff --git a/app/src/main/java/me/zhanghai/android/files/nonfree/CrashlyticsInitializer.kt b/app/src/main/java/me/zhanghai/android/files/nonfree/CrashlyticsInitializer.kt index e613f5eb6..d2dc3a4e0 100644 --- a/app/src/main/java/me/zhanghai/android/files/nonfree/CrashlyticsInitializer.kt +++ b/app/src/main/java/me/zhanghai/android/files/nonfree/CrashlyticsInitializer.kt @@ -9,12 +9,12 @@ import android.annotation.SuppressLint import android.content.pm.PackageManager import android.content.pm.Signature import com.google.firebase.crashlytics.FirebaseCrashlytics +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException import me.zhanghai.android.files.BuildConfig import me.zhanghai.android.files.app.application import me.zhanghai.android.files.app.packageManager import me.zhanghai.android.files.util.getPackageInfoOrNull -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException object CrashlyticsInitializer { private val HEX_CHARS = "0123456789ABCDEF".toCharArray() @@ -39,9 +39,10 @@ object CrashlyticsInitializer { val packageInfo = packageManager.getPackageInfoOrNull( application.packageName, PackageManager.GET_SIGNATURES ) ?: return false - return packageInfo.signatures.size == 1 - && computeCertificateFingerprint(packageInfo.signatures[0]) == ("87:3B:9B:60:C7:7C:F7" - + ":F3:CD:5F:AE:66:D0:FE:11:2C:4A:86:97:3E:11:8E:E8:A2:9C:34:6C:4C:67:3C:97:F0") + val signatures = packageInfo.signatures ?: return false + return signatures.size == 1 && + computeCertificateFingerprint(signatures[0]) == "87:3B:9B:60:C7:7C:F7:F3:CD:5F:AE:66" + + ":D0:FE:11:2C:4A:86:97:3E:11:8E:E8:A2:9C:34:6C:4C:67:3C:97:F0" } private fun computeCertificateFingerprint(certificate: Signature): String { diff --git a/app/src/main/java/me/zhanghai/android/files/nonfree/RarArchiveEntry.kt b/app/src/main/java/me/zhanghai/android/files/nonfree/RarArchiveEntry.kt deleted file mode 100644 index c2ffd4fa2..000000000 --- a/app/src/main/java/me/zhanghai/android/files/nonfree/RarArchiveEntry.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.nonfree - -import com.github.junrar.rarfile.FileHeader -import org.apache.commons.compress.archivers.ArchiveEntry -import org.apache.commons.compress.archivers.zip.ZipEncoding -import java.util.Date - -class RarArchiveEntry(val header: FileHeader, zipEncoding: ZipEncoding) : ArchiveEntry { - private val name: String - - init { - @Suppress("DEPRECATION") - var name = header.fileNameW - if (name.isNullOrEmpty()) { - name = zipEncoding.decode(header.fileNameByteArray) - } - name = name.replace('\\', '/') - this.name = name - } - - override fun getName(): String = name - - override fun getSize(): Long = header.fullUnpackSize - - override fun isDirectory(): Boolean = header.isDirectory - - override fun getLastModifiedDate(): Date = header.mTime -} diff --git a/app/src/main/java/me/zhanghai/android/files/nonfree/RarChannelVolumeManager.kt b/app/src/main/java/me/zhanghai/android/files/nonfree/RarChannelVolumeManager.kt deleted file mode 100644 index e8596371f..000000000 --- a/app/src/main/java/me/zhanghai/android/files/nonfree/RarChannelVolumeManager.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2022 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.nonfree - -import com.github.junrar.Archive -import com.github.junrar.io.SeekableReadOnlyByteChannel -import com.github.junrar.volume.Volume -import com.github.junrar.volume.VolumeManager -import java8.nio.channels.SeekableByteChannel -import me.zhanghai.android.files.compat.withInitial -import java.io.EOFException -import java.io.IOException -import java.nio.ByteBuffer - -internal class RarChannelVolumeManager( - private val channel: SeekableByteChannel -) : VolumeManager { - override fun nextVolume(archive: Archive, lastVolume: Volume?): Volume? = - if (lastVolume == null) RarChannelVolume(archive, channel) else null -} - -private class RarChannelVolume( - private val archive: Archive, - private val channel: SeekableByteChannel -) : Volume { - private val delegateChannel = DelegateSeekableReadOnlyByteChannel(channel) - - @Throws(IOException::class) - override fun getChannel(): SeekableReadOnlyByteChannel = delegateChannel - - override fun getLength(): Long = - try { - channel.size() - } catch (e: IOException) { - e.printStackTrace() - 0 - } - - override fun getArchive(): Archive = archive -} - -private class DelegateSeekableReadOnlyByteChannel( - private val channel: SeekableByteChannel -) : SeekableReadOnlyByteChannel { - private val SINGLE_BYTE_BUFFER = ThreadLocal::class.withInitial { ByteBuffer.allocate(1) } - - @Throws(IOException::class) - override fun getPosition(): Long = channel.position() - - @Throws(IOException::class) - override fun setPosition(pos: Long) { - channel.position(pos) - } - - @Throws(IOException::class) - override fun read(): Int { - val buffer = SINGLE_BYTE_BUFFER.get()!! - buffer.clear() - while (true) { - when (channel.read(buffer)) { - -1 -> return -1 - 0 -> continue - else -> return buffer[0].toInt() and 0xFF - } - } - } - - @Throws(IOException::class) - override fun read(buffer: ByteArray, off: Int, count: Int): Int { - if (buffer.isEmpty()) { - return 0 - } - val byteBuffer = ByteBuffer.wrap(buffer, off, count) - while (true) { - val bytesRead = channel.read(byteBuffer) - if (bytesRead == 0) { - continue - } - return bytesRead - } - } - - @Throws(IOException::class) - override fun readFully(buffer: ByteArray, count: Int): Int { - require(count <= buffer.size) { - "count > buffer.size: count = $count, buffer.size = ${buffer.size}" - } - if (count == 0) { - return 0 - } - val byteBuffer = ByteBuffer.wrap(buffer, 0, count) - while (byteBuffer.hasRemaining()) { - if (channel.read(byteBuffer) == -1) { - throw EOFException() - } - } - return count - } - - @Throws(IOException::class) - override fun close() { - channel.close() - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/nonfree/RarFile.kt b/app/src/main/java/me/zhanghai/android/files/nonfree/RarFile.kt deleted file mode 100644 index d278c0b16..000000000 --- a/app/src/main/java/me/zhanghai/android/files/nonfree/RarFile.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.nonfree - -import com.github.junrar.Archive -import com.github.junrar.exception.RarException -import java8.nio.channels.SeekableByteChannel -import java8.nio.file.Path -import me.zhanghai.android.files.provider.archive.archiver.ArchiveException -import me.zhanghai.android.files.provider.common.newByteChannel -import org.apache.commons.compress.archivers.zip.ZipEncodingHelper -import org.apache.commons.compress.utils.IOUtils -import java.io.Closeable -import java.io.IOException -import java.io.InputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream -import kotlin.math.max - -class RarFile(channel: SeekableByteChannel, encoding: String?) : Closeable { - private var archive = - try { - Archive(RarChannelVolumeManager(channel), null, null) - } catch (e: RarException) { - throw ArchiveException(e) - } - - private val zipEncoding = ZipEncodingHelper.getZipEncoding(encoding) - - @get:Throws(IOException::class) - val nextEntry: RarArchiveEntry? - get() = archive.nextFileHeader()?.let { RarArchiveEntry(it, zipEncoding) } - - @get:Throws(IOException::class) - val entries: Iterable - get() { - val entries = mutableListOf() - for (header in archive.fileHeaders) { - entries += RarArchiveEntry(header, zipEncoding) - } - return entries - } - - @Throws(IOException::class) - fun getInputStream(entry: RarArchiveEntry): InputStream { - val inputStream = PipedInputStream() - val outputStream = PipedOutputStream(inputStream) - Thread { - try { - outputStream.use { archive.extractFile(entry.header, it) } - } catch (e: IOException) { - e.printStackTrace() - } catch (e: RarException) { - e.printStackTrace() - } - }.start() - return inputStream - } - - @Throws(IOException::class) - override fun close() { - archive.close() - } - - companion object { - const val RAR = "rar" - - private val SIGNATURE_OLD = byteArrayOf(0x52, 0x45, 0x7e, 0x5e) - private val SIGNATURE_V4 = byteArrayOf(0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00) - private val SIGNATURE_V5 = byteArrayOf(0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01) - - @Throws(IOException::class) - fun detect(inputStream: InputStream): String? { - require(inputStream.markSupported()) { "InputStream.markSupported() returned false" } - val signature = ByteArray(max(SIGNATURE_OLD.size, SIGNATURE_V4.size)) - inputStream.mark(signature.size) - val signatureLength = try { - IOUtils.readFully(inputStream, signature) - } finally { - inputStream.reset() - } - return if (matches(signature, signatureLength)) RAR else null - } - - private fun matches(signature: ByteArray, length: Int): Boolean = - matches(signature, length, SIGNATURE_OLD) - || matches(signature, length, SIGNATURE_V4) - || matches(signature, length, SIGNATURE_V5) - - private fun matches(actual: ByteArray, actualLength: Int, expected: ByteArray): Boolean { - if (actualLength < expected.size) { - return false - } - for (index in expected.indices) { - if (actual[index] != expected[index]) { - return false - } - } - return true - } - - fun create(file: Path, encoding: String?): RarFile = - RarFile(file.newByteChannel(), encoding) - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt b/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt index bde790c95..7126ca270 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt @@ -19,6 +19,8 @@ import me.zhanghai.android.files.provider.linux.LinuxFileSystemProvider import me.zhanghai.android.files.provider.root.isRunningAsRoot import me.zhanghai.android.files.provider.sftp.SftpFileSystemProvider import me.zhanghai.android.files.provider.smb.SmbFileSystemProvider +import me.zhanghai.android.files.provider.webdav.WebDavFileSystemProvider +import me.zhanghai.android.files.provider.webdav.WebDavsFileSystemProvider object FileSystemProviders { /** @@ -42,6 +44,8 @@ object FileSystemProviders { FileSystemProvider.installProvider(FtpesFileSystemProvider) FileSystemProvider.installProvider(SftpFileSystemProvider) FileSystemProvider.installProvider(SmbFileSystemProvider) + FileSystemProvider.installProvider(WebDavFileSystemProvider) + FileSystemProvider.installProvider(WebDavsFileSystemProvider) } Files.installFileTypeDetector(AndroidFileTypeDetector) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveExceptionExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveExceptionExtensions.kt new file mode 100644 index 000000000..f9613dd50 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveExceptionExtensions.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.archive + +import android.system.OsConstants +import java8.nio.file.FileSystemException +import java8.nio.file.Path +import me.zhanghai.android.files.provider.common.DelegateInputStream +import me.zhanghai.android.libarchive.ArchiveException +import java.io.IOException +import java.io.InputStream +import java.io.InterruptedIOException + +// See also libarchive/archive_platform.h . +private const val ARCHIVE_ERRNO_MISC = -1 + +fun ArchiveException.toFileSystemOrInterruptedIOException(file: Path): IOException = + when { + // See also ReadArchive.toArchiveException . + code == OsConstants.EINTR -> InterruptedIOException(message) + // See also libarchive/archive_read_support_format_zip.c . + code == ARCHIVE_ERRNO_MISC && ( + message == "Incorrect passphrase" || message == "Passphrase required for this entry" + ) -> ArchivePasswordRequiredException(file, message) + else -> FileSystemException(file.toString(), null, message) + }.apply { initCause(this@toFileSystemOrInterruptedIOException) } + +class ArchiveExceptionInputStream( + inputStream: InputStream, + private val file: Path +) : DelegateInputStream(inputStream) { + @Throws(IOException::class) + override fun read(): Int = + try { + super.read() + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + + @Throws(IOException::class) + override fun read(b: ByteArray): Int = + try { + super.read(b) + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + + @Throws(IOException::class) + override fun read(b: ByteArray, off: Int, len: Int): Int = + try { + super.read(b, off, len) + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + + @Throws(IOException::class) + override fun skip(n: Long): Long = try { + super.skip(n) + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + + @Throws(IOException::class) + override fun available(): Int = + try { + super.available() + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + + @Throws(IOException::class) + override fun close() { + try { + super.close() + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + } + + @Throws(IOException::class) + override fun reset() { + try { + super.reset() + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributeView.kt index d9566a24a..683146881 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributeView.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributeView.kt @@ -1,37 +1,60 @@ /* - * Copyright (c) 2018 Hai Zhang + * Copyright (c) 2019 Hai Zhang * All Rights Reserved. */ package me.zhanghai.android.files.provider.archive -import android.os.Parcel -import android.os.Parcelable -import me.zhanghai.android.files.provider.root.RootablePosixFileAttributeView -import me.zhanghai.android.files.util.readParcelable +import java8.nio.file.Path +import java8.nio.file.attribute.FileTime +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.PosixFileAttributeView +import me.zhanghai.android.files.provider.common.PosixFileModeBit +import me.zhanghai.android.files.provider.common.PosixGroup +import me.zhanghai.android.files.provider.common.PosixUser +import java.io.IOException + +internal class ArchiveFileAttributeView(private val path: Path) : PosixFileAttributeView { + override fun name(): String = NAME + + @Throws(IOException::class) + override fun readAttributes(): ArchiveFileAttributes { + val fileSystem = path.fileSystem as ArchiveFileSystem + val entry = fileSystem.getEntry(path) + return ArchiveFileAttributes.from(fileSystem.archiveFile, entry) + } -internal class ArchiveFileAttributeView( - private val path: ArchivePath -) : RootablePosixFileAttributeView( - path, LocalArchiveFileAttributeView(path), { RootArchiveFileAttributeView(it, path) } -) { - private constructor(source: Parcel) : this(source.readParcelable()!!) + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + throw UnsupportedOperationException() + } - override fun describeContents(): Int = 0 + override fun setOwner(owner: PosixUser) { + throw UnsupportedOperationException() + } - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeParcelable(path as Parcelable, flags) + override fun setGroup(group: PosixGroup) { + throw UnsupportedOperationException() } - companion object { - val SUPPORTED_NAMES = LocalArchiveFileAttributeView.SUPPORTED_NAMES + override fun setMode(mode: Set) { + throw UnsupportedOperationException() + } - @JvmField - val CREATOR = object : Parcelable.Creator { - override fun createFromParcel(source: Parcel): ArchiveFileAttributeView = - ArchiveFileAttributeView(source) + override fun setSeLinuxContext(context: ByteString) { + throw UnsupportedOperationException() + } + + override fun restoreSeLinuxContext() { + throw UnsupportedOperationException() + } + + companion object { + private val NAME = ArchiveFileSystemProvider.scheme - override fun newArray(size: Int): Array = arrayOfNulls(size) - } + val SUPPORTED_NAMES = setOf("basic", "posix", NAME) } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributes.kt index b462a8b62..4050b8767 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileAttributes.kt @@ -10,22 +10,15 @@ import java8.nio.file.Path import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith +import me.zhanghai.android.files.provider.archive.archiver.ReadArchive import me.zhanghai.android.files.provider.common.AbstractPosixFileAttributes import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.EncryptedFileAttributes import me.zhanghai.android.files.provider.common.FileTimeParceler -import me.zhanghai.android.files.provider.common.PosixFileMode import me.zhanghai.android.files.provider.common.PosixFileModeBit import me.zhanghai.android.files.provider.common.PosixFileType import me.zhanghai.android.files.provider.common.PosixGroup import me.zhanghai.android.files.provider.common.PosixUser -import me.zhanghai.android.files.provider.common.posixFileType -import me.zhanghai.android.files.provider.common.toByteString -import org.apache.commons.compress.archivers.ArchiveEntry -import org.apache.commons.compress.archivers.dump.DumpArchiveEntry -import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry -import org.apache.commons.compress.archivers.tar.TarArchiveEntry -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry -import org.threeten.bp.Instant @Parcelize internal class ArchiveFileAttributes( @@ -39,94 +32,31 @@ internal class ArchiveFileAttributes( override val group: PosixGroup?, override val mode: Set?, override val seLinuxContext: ByteString?, + private val isEncrypted: Boolean, private val entryName: String -) : AbstractPosixFileAttributes() { +) : AbstractPosixFileAttributes(), EncryptedFileAttributes { + override fun isEncrypted(): Boolean = isEncrypted + fun entryName(): String = entryName companion object { - fun from(archiveFile: Path, entry: ArchiveEntry): ArchiveFileAttributes { - val lastModifiedTime = FileTime.from(Instant.ofEpochMilli(entry.lastModifiedDate.time)) - val lastAccessTime = when (entry) { - is DumpArchiveEntry -> FileTime.from(Instant.ofEpochMilli(entry.accessTime.time)) - is SevenZArchiveEntry -> - if (entry.hasAccessDate) { - FileTime.from(Instant.ofEpochMilli(entry.accessDate.time)) - } else { - lastModifiedTime - } - is TarArchiveEntry -> { - val atimeMillis = entry.getExtraPaxHeaderTimeMillis("atime") - if (atimeMillis != null) { - FileTime.from(Instant.ofEpochMilli(atimeMillis)) - } else { - lastModifiedTime - } - } - else -> lastModifiedTime - } - val creationTime = when (entry) { - is DumpArchiveEntry -> FileTime.from(Instant.ofEpochMilli(entry.creationTime.time)) - is SevenZArchiveEntry -> - if (entry.hasCreationDate) { - FileTime.from(Instant.ofEpochMilli(entry.creationDate.time)) - } else { - lastModifiedTime - } - is TarArchiveEntry -> { - val ctimeMillis = entry.getExtraPaxHeaderTimeMillis("ctime") - if (ctimeMillis != null) { - FileTime.from(Instant.ofEpochMilli(ctimeMillis)) - } else { - lastModifiedTime - } - } - else -> lastModifiedTime - } - val type = entry.posixFileType + fun from(archiveFile: Path, entry: ReadArchive.Entry): ArchiveFileAttributes { + val lastModifiedTime = entry.lastModifiedTime ?: FileTime.fromMillis(0) + val lastAccessTime = entry.lastAccessTime ?: lastModifiedTime + val creationTime = entry.creationTime ?: lastModifiedTime + val type = entry.type val size = entry.size val fileKey = ArchiveFileKey(archiveFile, entry.name) - val owner = when (entry) { - is DumpArchiveEntry -> PosixUser(entry.userId, null) - is TarArchiveEntry -> - @Suppress("DEPRECATION") - PosixUser(entry.userId, entry.userName?.toByteString()) - else -> null - } - val group = when (entry) { - is DumpArchiveEntry -> PosixGroup(entry.groupId, null) - is TarArchiveEntry -> - @Suppress("DEPRECATION") - PosixGroup(entry.groupId, entry.groupName?.toByteString()) - else -> null - } - val mode = when (entry) { - is DumpArchiveEntry -> PosixFileMode.fromInt(entry.mode) - is TarArchiveEntry -> PosixFileMode.fromInt(entry.mode) - is ZipArchiveEntry -> - if (entry.platform == ZipArchiveEntry.PLATFORM_UNIX) { - PosixFileMode.fromInt(entry.unixMode) - } else { - null - } - else -> null - } + val owner = entry.owner + val group = entry.group + val mode = entry.mode val seLinuxContext = null + val isEncrypted = entry.isEncrypted val entryName = entry.name return ArchiveFileAttributes( lastModifiedTime, lastAccessTime, creationTime, type, size, fileKey, owner, group, - mode, seLinuxContext, entryName + mode, seLinuxContext, isEncrypted, entryName ) } - - private fun TarArchiveEntry.getExtraPaxHeaderTimeMillis(name: String): Long? { - val timeString = getExtraPaxHeader(name) ?: return null - val timeSeconds = try { - timeString.toDouble() - } catch (e: NumberFormatException) { - e.printStackTrace() - return null - } - return (timeSeconds * 1000).toLong() - } } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileStore.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileStore.kt index a7ca58b47..71a8fb0e2 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileStore.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileStore.kt @@ -1,36 +1,42 @@ /* - * Copyright (c) 2018 Hai Zhang + * Copyright (c) 2019 Hai Zhang * All Rights Reserved. */ package me.zhanghai.android.files.provider.archive -import android.os.Parcel -import android.os.Parcelable import java8.nio.file.Path -import me.zhanghai.android.files.provider.root.RootPosixFileStore -import me.zhanghai.android.files.provider.root.RootablePosixFileStore +import java8.nio.file.attribute.FileAttributeView +import me.zhanghai.android.files.file.MimeType +import me.zhanghai.android.files.file.guessFromPath +import me.zhanghai.android.files.provider.common.PosixFileStore +import me.zhanghai.android.files.provider.common.size +import java.io.IOException -internal class ArchiveFileStore(private val archiveFile: Path) : RootablePosixFileStore( - archiveFile, LocalArchiveFileStore(archiveFile), { RootPosixFileStore(it) } -) { - private constructor(source: Parcel) : this( - source.readParcelable(Path::class.java.classLoader) as Path - ) +internal class ArchiveFileStore(private val archiveFile: Path) : PosixFileStore() { + override fun refresh() {} - override fun describeContents(): Int = 0 + override fun name(): String = archiveFile.toString() - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeParcelable(archiveFile as Parcelable, flags) - } + override fun type(): String = MimeType.guessFromPath(archiveFile.toString()).value - companion object { - @JvmField - val CREATOR = object : Parcelable.Creator { - override fun createFromParcel(source: Parcel): ArchiveFileStore = - ArchiveFileStore(source) + override fun isReadOnly(): Boolean = true - override fun newArray(size: Int): Array = arrayOfNulls(size) - } + @Throws(IOException::class) + override fun setReadOnly(readOnly: Boolean) { + throw UnsupportedOperationException() } + + @Throws(IOException::class) + override fun getTotalSpace(): Long = archiveFile.size() + + override fun getUsableSpace(): Long = 0 + + override fun getUnallocatedSpace(): Long = 0 + + override fun supportsFileAttributeView(type: Class): Boolean = + ArchiveFileSystemProvider.supportsFileAttributeView(type) + + override fun supportsFileAttributeView(name: String): Boolean = + name in ArchiveFileAttributeView.SUPPORTED_NAMES } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystem.kt index b906dc50f..c43903828 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystem.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystem.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 Hai Zhang + * Copyright (c) 2019 Hai Zhang * All Rights Reserved. */ @@ -7,62 +7,215 @@ package me.zhanghai.android.files.provider.archive import android.os.Parcel import android.os.Parcelable +import java8.nio.file.ClosedFileSystemException +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.NoSuchFileException +import java8.nio.file.NotDirectoryException +import java8.nio.file.NotLinkException import java8.nio.file.Path +import java8.nio.file.PathMatcher +import java8.nio.file.WatchService +import java8.nio.file.attribute.UserPrincipalLookupService +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.archive.archiver.ArchiveReader +import me.zhanghai.android.files.provider.archive.archiver.ReadArchive import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringBuilder import me.zhanghai.android.files.provider.common.ByteStringListPathCreator -import me.zhanghai.android.files.provider.remote.RemoteFileSystemException -import me.zhanghai.android.files.provider.root.RootableFileSystem -import org.apache.commons.compress.archivers.ArchiveEntry +import me.zhanghai.android.files.provider.common.IsDirectoryException +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.libarchive.ArchiveException import java.io.IOException import java.io.InputStream internal class ArchiveFileSystem( - provider: ArchiveFileSystemProvider, - archiveFile: Path -) : RootableFileSystem( - { LocalArchiveFileSystem(it as ArchiveFileSystem, provider, archiveFile) }, - { RootArchiveFileSystem(it) } -), ByteStringListPathCreator { - override val localFileSystem: LocalArchiveFileSystem - get() = super.localFileSystem as LocalArchiveFileSystem - - override val rootFileSystem: RootArchiveFileSystem - get() = super.rootFileSystem as RootArchiveFileSystem + private val provider: ArchiveFileSystemProvider, + val archiveFile: Path +) : FileSystem(), ByteStringListPathCreator, Parcelable { + val rootDirectory = ArchivePath(this, SEPARATOR_BYTE_STRING) - val rootDirectory: ArchivePath - get() = localFileSystem.rootDirectory + init { + if (!rootDirectory.isAbsolute) { + throw AssertionError("Root directory $rootDirectory must be absolute") + } + if (rootDirectory.nameCount != 0) { + throw AssertionError("Root directory $rootDirectory must contain no names") + } + } val defaultDirectory: ArchivePath - get() = localFileSystem.defaultDirectory + get() = rootDirectory - val archiveFile: Path - get() = localFileSystem.archiveFile + private val lock = Any() + + private var isOpen = true + + private var passwords = listOf() + + private var isRefreshNeeded = true + + private var entries: Map? = null + + private var tree: Map>? = null @Throws(IOException::class) - fun getEntryAsLocal(path: Path): ArchiveEntry = localFileSystem.getEntry(path) + fun getEntry(path: Path): ReadArchive.Entry = + synchronized(lock) { + ensureEntriesLocked(path) + getEntryLocked(path) + } @Throws(IOException::class) - fun newInputStreamAsLocal(file: Path): InputStream = localFileSystem.newInputStream(file) + private fun getEntryLocked(path: Path): ReadArchive.Entry = + synchronized(lock) { + entries!![path] ?: throw NoSuchFileException(path.toString()) + } @Throws(IOException::class) - fun getDirectoryChildrenAsLocal(directory: Path): List = - localFileSystem.getDirectoryChildren(directory) + fun newInputStream(file: Path): InputStream = + synchronized(lock) { + ensureEntriesLocked(file) + val entry = getEntryLocked(file) + if (entry.isDirectory) { + throw IsDirectoryException(file.toString()) + } + val inputStream = try { + ArchiveReader.newInputStream(archiveFile, passwords, entry) + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } ?: throw NoSuchFileException(file.toString()) + ArchiveExceptionInputStream(inputStream, file) + } @Throws(IOException::class) - fun readSymbolicLinkAsLocal(link: Path): String = localFileSystem.readSymbolicLink(link) + fun getDirectoryChildren(directory: Path): List = + synchronized(lock) { + ensureEntriesLocked(directory) + val entry = getEntryLocked(directory) + if (!entry.isDirectory) { + throw NotDirectoryException(directory.toString()) + } + tree!![directory]!! + } + + @Throws(IOException::class) + fun readSymbolicLink(link: Path): String = + synchronized(lock) { + ensureEntriesLocked(link) + val entry = getEntryLocked(link) + if (!entry.isSymbolicLink) { + throw NotLinkException(link.toString()) + } + entry.symbolicLinkTarget.orEmpty() + } + + fun addPassword(password: String) { + synchronized(lock) { + if (!isOpen) { + throw ClosedFileSystemException() + } + passwords += password + } + } fun refresh() { - localFileSystem.refresh() - rootFileSystem.refresh() + synchronized(lock) { + if (!isOpen) { + throw ClosedFileSystemException() + } + isRefreshNeeded = true + } + } + + @Throws(IOException::class) + private fun ensureEntriesLocked(file: Path) { + if (!isOpen) { + throw ClosedFileSystemException() + } + if (isRefreshNeeded) { + val entriesAndTree = try { + ArchiveReader.readEntries(archiveFile, passwords, rootDirectory) + } catch (e: ArchiveException) { + throw e.toFileSystemOrInterruptedIOException(file) + } + entries = entriesAndTree.first + tree = entriesAndTree.second + isRefreshNeeded = false + } + } + + override fun provider(): FileSystemProvider = provider + + override fun close() { + synchronized(lock) { + if (!isOpen) { + return + } + provider.removeFileSystem(this) + isRefreshNeeded = false + entries = null + tree = null + isOpen = false + } + } + + override fun isOpen(): Boolean = synchronized(lock) { isOpen } + + override fun isReadOnly(): Boolean = true + + override fun getSeparator(): String = SEPARATOR_STRING + + override fun getRootDirectories(): Iterable = listOf(rootDirectory) + + override fun getFileStores(): Iterable { + // TODO + throw UnsupportedOperationException() } - @Throws(RemoteFileSystemException::class) - fun doRefreshIfNeededAsRoot() { - rootFileSystem.doRefreshIfNeeded() + override fun supportedFileAttributeViews(): Set = + ArchiveFileAttributeView.SUPPORTED_NAMES + + override fun getPath(first: String, vararg more: String): ArchivePath { + val path = ByteStringBuilder(first.toByteString()) + .apply { more.forEach { append(SEPARATOR).append(it.toByteString()) } } + .toByteString() + return ArchivePath(this, path) + } + + override fun getPath(first: ByteString, vararg more: ByteString): ArchivePath { + val path = ByteStringBuilder(first) + .apply { more.forEach { append(SEPARATOR).append(it) } } + .toByteString() + return ArchivePath(this, path) + } + + override fun getPathMatcher(syntaxAndPattern: String): PathMatcher { + throw UnsupportedOperationException() + } + + override fun getUserPrincipalLookupService(): UserPrincipalLookupService { + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newWatchService(): WatchService { + // TODO + throw UnsupportedOperationException() + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (javaClass != other?.javaClass) { + return false + } + other as ArchiveFileSystem + return archiveFile == other.archiveFile } - override fun getPath(first: ByteString, vararg more: ByteString): ArchivePath = - localFileSystem.getPath(first, *more) + override fun hashCode(): Int = archiveFile.hashCode() override fun describeContents(): Int = 0 @@ -71,7 +224,9 @@ internal class ArchiveFileSystem( } companion object { - const val SEPARATOR: Byte = LocalArchiveFileSystem.SEPARATOR + const val SEPARATOR = '/'.code.toByte() + private val SEPARATOR_BYTE_STRING = SEPARATOR.toByteString() + private const val SEPARATOR_STRING = SEPARATOR.toInt().toChar().toString() @JvmField val CREATOR = object : Parcelable.Creator { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystemProvider.kt index 7f9e2c890..972e367dc 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchiveFileSystemProvider.kt @@ -1,35 +1,293 @@ /* - * Copyright (c) 2018 Hai Zhang + * Copyright (c) 2019 Hai Zhang * All Rights Reserved. */ package me.zhanghai.android.files.provider.archive +import java8.nio.channels.FileChannel +import java8.nio.channels.SeekableByteChannel +import java8.nio.file.AccessDeniedException +import java8.nio.file.AccessMode +import java8.nio.file.CopyOption +import java8.nio.file.DirectoryStream +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.LinkOption +import java8.nio.file.OpenOption import java8.nio.file.Path +import java8.nio.file.Paths +import java8.nio.file.ProviderMismatchException +import java8.nio.file.attribute.BasicFileAttributes +import java8.nio.file.attribute.FileAttribute import java8.nio.file.attribute.FileAttributeView -import me.zhanghai.android.files.provider.root.RootableFileSystemProvider +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.ByteStringPath +import me.zhanghai.android.files.provider.common.FileSystemCache +import me.zhanghai.android.files.provider.common.PathListDirectoryStream +import me.zhanghai.android.files.provider.common.PathObservable +import me.zhanghai.android.files.provider.common.PathObservableProvider +import me.zhanghai.android.files.provider.common.ReadOnlyFileSystemException +import me.zhanghai.android.files.provider.common.Searchable +import me.zhanghai.android.files.provider.common.WalkFileTreeSearchable +import me.zhanghai.android.files.provider.common.decodedPathByteString +import me.zhanghai.android.files.provider.common.decodedQueryByteString +import me.zhanghai.android.files.provider.common.isSameFile +import me.zhanghai.android.files.provider.common.toAccessModes +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.common.toOpenOptions +import java.io.IOException +import java.io.InputStream +import java.net.URI -object ArchiveFileSystemProvider : RootableFileSystemProvider( - { LocalArchiveFileSystemProvider(it as ArchiveFileSystemProvider) }, - { RootArchiveFileSystemProvider(LocalArchiveFileSystemProvider.SCHEME) } -) { - override val localProvider: LocalArchiveFileSystemProvider - get() = super.localProvider as LocalArchiveFileSystemProvider +object ArchiveFileSystemProvider : FileSystemProvider(), PathObservableProvider, Searchable { + private const val SCHEME = "archive" - override val rootProvider: RootArchiveFileSystemProvider - get() = super.rootProvider as RootArchiveFileSystemProvider + private val fileSystems = FileSystemCache() + + override fun getScheme(): String = SCHEME + + override fun newFileSystem(uri: URI, env: Map): FileSystem { + uri.requireSameScheme() + val archiveFile = uri.archiveFile + return fileSystems.create(archiveFile) { newFileSystem(archiveFile) } + } + + override fun newFileSystem(file: Path, env: Map): FileSystem = newFileSystem(file) internal fun getOrNewFileSystem(archiveFile: Path): ArchiveFileSystem = - localProvider.getOrNewFileSystem(archiveFile) + fileSystems.getOrCreate(archiveFile) { newFileSystem(archiveFile) } + + private fun newFileSystem(archiveFile: Path): ArchiveFileSystem = + ArchiveFileSystem(this, archiveFile) + + override fun getFileSystem(uri: URI): FileSystem { + uri.requireSameScheme() + val archiveFile = uri.archiveFile + return fileSystems[archiveFile] + } internal fun removeFileSystem(fileSystem: ArchiveFileSystem) { - localProvider.removeFileSystem(fileSystem) + fileSystems.remove(fileSystem.archiveFile, fileSystem) + } + + override fun getPath(uri: URI): Path { + uri.requireSameScheme() + val archiveFile = uri.archiveFile + val path = uri.decodedQueryByteString + ?: throw IllegalArgumentException("URI must have a query") + return getOrNewFileSystem(archiveFile).getPath(path) + } + + private fun URI.requireSameScheme() { + val scheme = scheme + require(scheme == SCHEME) { "URI scheme $scheme must be $SCHEME" } + } + + private val URI.archiveFile: Path + get() { + val path = decodedPathByteString + ?: throw IllegalArgumentException("URI must have a path") + // Drop the first character which is always a slash. + val archiveUri = URI.create(path.toString().drop(1)) + return Paths.get(archiveUri) + } + + @Throws(IOException::class) + override fun newInputStream(file: Path, vararg options: OpenOption): InputStream { + file as? ArchivePath ?: throw ProviderMismatchException(file.toString()) + options.toOpenOptions().checkForArchive() + return file.fileSystem.newInputStream(file) + } + + override fun newFileChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): FileChannel { + file as? ArchivePath ?: throw ProviderMismatchException(file.toString()) + options.toOpenOptions().checkForArchive() + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + throw UnsupportedOperationException() + } + + override fun newByteChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): SeekableByteChannel { + file as? ArchivePath ?: throw ProviderMismatchException(file.toString()) + options.toOpenOptions().checkForArchive() + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newDirectoryStream( + directory: Path, + filter: DirectoryStream.Filter + ): DirectoryStream { + directory as? ArchivePath ?: throw ProviderMismatchException(directory.toString()) + val children = directory.fileSystem.getDirectoryChildren(directory) + return PathListDirectoryStream(children, filter) + } + + @Throws(IOException::class) + override fun createDirectory(directory: Path, vararg attributes: FileAttribute<*>) { + directory as? ArchivePath ?: throw ProviderMismatchException(directory.toString()) + throw ReadOnlyFileSystemException(directory.toString()) + } + + @Throws(IOException::class) + override fun createSymbolicLink(link: Path, target: Path, vararg attributes: FileAttribute<*>) { + link as? ArchivePath ?: throw ProviderMismatchException(link.toString()) + when (target) { + is ArchivePath, is ByteStringPath -> {} + else -> throw ProviderMismatchException(target.toString()) + } + throw ReadOnlyFileSystemException(link.toString(), target.toString(), null) + } + + @Throws(IOException::class) + override fun createLink(link: Path, existing: Path) { + link as? ArchivePath ?: throw ProviderMismatchException(link.toString()) + existing as? ArchivePath ?: throw ProviderMismatchException(existing.toString()) + throw ReadOnlyFileSystemException(link.toString(), existing.toString(), null) + } + + @Throws(IOException::class) + override fun delete(path: Path) { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + throw ReadOnlyFileSystemException(path.toString()) + } + + @Throws(IOException::class) + override fun readSymbolicLink(link: Path): Path { + link as? ArchivePath ?: throw ProviderMismatchException(link.toString()) + val target = link.fileSystem.readSymbolicLink(link) + return ByteStringPath(target.toByteString()) + } + + @Throws(IOException::class) + override fun copy(source: Path, target: Path, vararg options: CopyOption) { + source as? ArchivePath ?: throw ProviderMismatchException(source.toString()) + target as? ArchivePath ?: throw ProviderMismatchException(target.toString()) + throw ReadOnlyFileSystemException(source.toString(), target.toString(), null) + } + + @Throws(IOException::class) + override fun move(source: Path, target: Path, vararg options: CopyOption) { + source as? ArchivePath ?: throw ProviderMismatchException(source.toString()) + target as? ArchivePath ?: throw ProviderMismatchException(target.toString()) + throw ReadOnlyFileSystemException(source.toString(), target.toString(), null) + } + + @Throws(IOException::class) + override fun isSameFile(path: Path, path2: Path): Boolean { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + if (path == path2) { + return true + } + if (path2 !is ArchivePath) { + return false + } + val fileSystem = path.fileSystem + if (!fileSystem.archiveFile.isSameFile(path2.fileSystem.archiveFile)) { + return false + } + return path == fileSystem.getPath(path2.toString()) + } + + override fun isHidden(path: Path): Boolean { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + return false + } + + override fun getFileStore(path: Path): FileStore { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + val archiveFile = path.fileSystem.archiveFile + return ArchiveFileStore(archiveFile) + } + + @Throws(IOException::class) + override fun checkAccess(path: Path, vararg modes: AccessMode) { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + val accessModes = modes.toAccessModes() + path.fileSystem.getEntry(path) + if (accessModes.write || accessModes.execute) { + throw AccessDeniedException(path.toString()) + } + } + + override fun getFileAttributeView( + path: Path, + type: Class, + vararg options: LinkOption + ): V? { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + if (!supportsFileAttributeView(type)) { + return null + } + @Suppress("UNCHECKED_CAST") + return getFileAttributeView(path) as V } internal fun supportsFileAttributeView(type: Class): Boolean = - LocalArchiveFileSystemProvider.supportsFileAttributeView(type) + type.isAssignableFrom(ArchiveFileAttributeView::class.java) + + @Throws(IOException::class) + override fun readAttributes( + path: Path, + type: Class, + vararg options: LinkOption + ): A { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + if (!type.isAssignableFrom(ArchiveFileAttributes::class.java)) { + throw UnsupportedOperationException(type.toString()) + } + @Suppress("UNCHECKED_CAST") + return getFileAttributeView(path).readAttributes() as A + } + + private fun getFileAttributeView(path: ArchivePath): ArchiveFileAttributeView = + ArchiveFileAttributeView(path) + + override fun readAttributes( + path: Path, + attributes: String, + vararg options: LinkOption + ): Map { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + override fun setAttribute( + path: Path, + attribute: String, + value: Any, + vararg options: LinkOption + ) { + path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun observe(path: Path, intervalMillis: Long): PathObservable { + throw UnsupportedOperationException() + } - internal fun doRefreshIfNeeded(path: Path) { - rootProvider.doRefreshIfNeeded(path) + @Throws(IOException::class) + override fun search( + directory: Path, + query: String, + intervalMillis: Long, + listener: (List) -> Unit + ) { + directory as? ArchivePath ?: throw ProviderMismatchException(directory.toString()) + WalkFileTreeSearchable.search(directory, query, intervalMillis, listener) } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchivePasswordRequiredException.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchivePasswordRequiredException.kt new file mode 100644 index 000000000..5ffe81da2 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/ArchivePasswordRequiredException.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.archive + +import android.content.Context +import java8.nio.file.Path +import me.zhanghai.android.files.fileaction.ArchivePasswordDialogActivity +import me.zhanghai.android.files.fileaction.ArchivePasswordDialogFragment +import me.zhanghai.android.files.provider.common.UserAction +import me.zhanghai.android.files.provider.common.UserActionRequiredException +import me.zhanghai.android.files.util.createIntent +import me.zhanghai.android.files.util.putArgs +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class ArchivePasswordRequiredException( + private val file: Path, + reason: String? +) : + UserActionRequiredException(file.toString(), null, reason) { + + override fun getUserAction(continuation: Continuation, context: Context): UserAction { + return UserAction( + ArchivePasswordDialogActivity::class.createIntent().putArgs( + ArchivePasswordDialogFragment.Args(file) { continuation.resume(it) } + ), ArchivePasswordDialogFragment.getTitle(context), + ArchivePasswordDialogFragment.getMessage(file, context) + ) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileAttributeView.kt deleted file mode 100644 index 534735925..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileAttributeView.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.file.Path -import java8.nio.file.attribute.FileTime -import me.zhanghai.android.files.provider.common.ByteString -import me.zhanghai.android.files.provider.common.PosixFileAttributeView -import me.zhanghai.android.files.provider.common.PosixFileModeBit -import me.zhanghai.android.files.provider.common.PosixGroup -import me.zhanghai.android.files.provider.common.PosixUser -import java.io.IOException - -internal class LocalArchiveFileAttributeView(private val path: Path) : PosixFileAttributeView { - override fun name(): String = NAME - - @Throws(IOException::class) - override fun readAttributes(): ArchiveFileAttributes { - val fileSystem = path.fileSystem as ArchiveFileSystem - val entry = fileSystem.getEntryAsLocal(path) - return ArchiveFileAttributes.from(fileSystem.archiveFile, entry) - } - - override fun setTimes( - lastModifiedTime: FileTime?, - lastAccessTime: FileTime?, - createTime: FileTime? - ) { - throw UnsupportedOperationException() - } - - override fun setOwner(owner: PosixUser) { - throw UnsupportedOperationException() - } - - override fun setGroup(group: PosixGroup) { - throw UnsupportedOperationException() - } - - override fun setMode(mode: Set) { - throw UnsupportedOperationException() - } - - override fun setSeLinuxContext(context: ByteString) { - throw UnsupportedOperationException() - } - - override fun restoreSeLinuxContext() { - throw UnsupportedOperationException() - } - - companion object { - private val NAME = ArchiveFileSystemProvider.scheme - - val SUPPORTED_NAMES = setOf("basic", "posix", NAME) - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileStore.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileStore.kt deleted file mode 100644 index 714c1a139..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileStore.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.file.Path -import java8.nio.file.attribute.FileAttributeView -import me.zhanghai.android.files.file.MimeType -import me.zhanghai.android.files.file.guessFromPath -import me.zhanghai.android.files.provider.common.PosixFileStore -import me.zhanghai.android.files.provider.common.size -import org.tukaani.xz.UnsupportedOptionsException -import java.io.IOException - -internal class LocalArchiveFileStore(private val archiveFile: Path) : PosixFileStore() { - override fun refresh() {} - - override fun name(): String = archiveFile.toString() - - override fun type(): String = MimeType.guessFromPath(archiveFile.toString()).value - - override fun isReadOnly(): Boolean = true - - @Throws(IOException::class) - override fun setReadOnly(readOnly: Boolean) { - throw UnsupportedOptionsException() - } - - @Throws(IOException::class) - override fun getTotalSpace(): Long = archiveFile.size() - - override fun getUsableSpace(): Long = 0 - - override fun getUnallocatedSpace(): Long = 0 - - override fun supportsFileAttributeView(type: Class): Boolean = - ArchiveFileSystemProvider.supportsFileAttributeView(type) - - override fun supportsFileAttributeView(name: String): Boolean = - name in ArchiveFileAttributeView.SUPPORTED_NAMES -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileSystem.kt deleted file mode 100644 index 0cca8a316..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileSystem.kt +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.file.ClosedFileSystemException -import java8.nio.file.FileStore -import java8.nio.file.FileSystem -import java8.nio.file.NoSuchFileException -import java8.nio.file.NotDirectoryException -import java8.nio.file.Path -import java8.nio.file.PathMatcher -import java8.nio.file.WatchService -import java8.nio.file.attribute.UserPrincipalLookupService -import java8.nio.file.spi.FileSystemProvider -import me.zhanghai.android.files.provider.archive.archiver.ArchiveReader -import me.zhanghai.android.files.provider.common.ByteString -import me.zhanghai.android.files.provider.common.ByteStringBuilder -import me.zhanghai.android.files.provider.common.ByteStringListPathCreator -import me.zhanghai.android.files.provider.common.toByteString -import org.apache.commons.compress.archivers.ArchiveEntry -import java.io.IOException -import java.io.InputStream - -internal class LocalArchiveFileSystem( - private val fileSystem: ArchiveFileSystem, - private val provider: ArchiveFileSystemProvider, - val archiveFile: Path -) : FileSystem(), ByteStringListPathCreator { - val rootDirectory = ArchivePath(fileSystem, SEPARATOR_BYTE_STRING) - - init { - if (!rootDirectory.isAbsolute) { - throw AssertionError("Root directory $rootDirectory must be absolute") - } - if (rootDirectory.nameCount != 0) { - throw AssertionError("Root directory $rootDirectory must contain no names") - } - } - - val defaultDirectory: ArchivePath - get() = rootDirectory - - private val lock = Any() - - private var isOpen = true - - private var isRefreshNeeded = true - - private var entries: Map? = null - - private var tree: Map>? = null - - @Throws(IOException::class) - fun getEntry(path: Path): ArchiveEntry = - synchronized(lock) { - ensureEntriesLocked() - getEntryLocked(path) - } - - @Throws(IOException::class) - private fun getEntryLocked(path: Path): ArchiveEntry = - synchronized(lock) { - entries!![path] ?: throw NoSuchFileException(path.toString()) - } - - @Throws(IOException::class) - fun newInputStream(file: Path): InputStream = - synchronized(lock) { - ensureEntriesLocked() - val entry = getEntryLocked(file) - ArchiveReader.newInputStream(archiveFile, entry) - } - - @Throws(IOException::class) - fun getDirectoryChildren(directory: Path): List = - synchronized(lock) { - ensureEntriesLocked() - val entry = getEntryLocked(directory) - if (!entry.isDirectory) { - throw NotDirectoryException(directory.toString()) - } - tree!![directory]!! - } - - @Throws(IOException::class) - fun readSymbolicLink(link: Path): String = - synchronized(lock) { - ensureEntriesLocked() - val entry = getEntryLocked(link) - ArchiveReader.readSymbolicLink(archiveFile, entry) - } - - fun refresh() { - synchronized(lock) { - if (!isOpen) { - throw ClosedFileSystemException() - } - isRefreshNeeded = true - } - } - - @Throws(IOException::class) - private fun ensureEntriesLocked() { - if (!isOpen) { - throw ClosedFileSystemException() - } - if (isRefreshNeeded) { - val entriesAndTree = ArchiveReader.readEntries(archiveFile, rootDirectory) - entries = entriesAndTree.first - tree = entriesAndTree.second - isRefreshNeeded = false - } - } - - override fun provider(): FileSystemProvider = provider - - override fun close() { - synchronized(lock) { - if (!isOpen) { - return - } - provider.removeFileSystem(fileSystem) - isRefreshNeeded = false - entries = null - tree = null - isOpen = false - } - } - - override fun isOpen(): Boolean = synchronized(lock) { isOpen } - - override fun isReadOnly(): Boolean = true - - override fun getSeparator(): String = SEPARATOR_STRING - - override fun getRootDirectories(): Iterable = listOf(rootDirectory) - - override fun getFileStores(): Iterable { - // TODO - throw UnsupportedOperationException() - } - - override fun supportedFileAttributeViews(): Set = - ArchiveFileAttributeView.SUPPORTED_NAMES - - override fun getPath(first: String, vararg more: String): ArchivePath { - val path = ByteStringBuilder(first.toByteString()) - .apply { more.forEach { append(SEPARATOR).append(it.toByteString()) } } - .toByteString() - return ArchivePath(fileSystem, path) - } - - override fun getPath(first: ByteString, vararg more: ByteString): ArchivePath { - val path = ByteStringBuilder(first) - .apply { more.forEach { append(SEPARATOR).append(it) } } - .toByteString() - return ArchivePath(fileSystem, path) - } - - override fun getPathMatcher(syntaxAndPattern: String): PathMatcher { - throw UnsupportedOperationException() - } - - override fun getUserPrincipalLookupService(): UserPrincipalLookupService { - throw UnsupportedOperationException() - } - - @Throws(IOException::class) - override fun newWatchService(): WatchService { - // TODO - throw UnsupportedOperationException() - } - - override fun equals(other: Any?): Boolean { - if (this === other) { - return true - } - if (javaClass != other?.javaClass) { - return false - } - other as LocalArchiveFileSystem - return archiveFile == other.archiveFile - } - - override fun hashCode(): Int = archiveFile.hashCode() - - companion object { - const val SEPARATOR = '/'.code.toByte() - private val SEPARATOR_BYTE_STRING = SEPARATOR.toByteString() - private const val SEPARATOR_STRING = SEPARATOR.toInt().toChar().toString() - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileSystemProvider.kt deleted file mode 100644 index 808d38c09..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/LocalArchiveFileSystemProvider.kt +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.channels.FileChannel -import java8.nio.channels.SeekableByteChannel -import java8.nio.file.AccessDeniedException -import java8.nio.file.AccessMode -import java8.nio.file.CopyOption -import java8.nio.file.DirectoryStream -import java8.nio.file.FileStore -import java8.nio.file.FileSystem -import java8.nio.file.LinkOption -import java8.nio.file.OpenOption -import java8.nio.file.Path -import java8.nio.file.Paths -import java8.nio.file.ProviderMismatchException -import java8.nio.file.attribute.BasicFileAttributes -import java8.nio.file.attribute.FileAttribute -import java8.nio.file.attribute.FileAttributeView -import java8.nio.file.spi.FileSystemProvider -import me.zhanghai.android.files.provider.common.ByteStringPath -import me.zhanghai.android.files.provider.common.FileSystemCache -import me.zhanghai.android.files.provider.common.PathListDirectoryStream -import me.zhanghai.android.files.provider.common.ReadOnlyFileSystemException -import me.zhanghai.android.files.provider.common.Searchable -import me.zhanghai.android.files.provider.common.WalkFileTreeSearchable -import me.zhanghai.android.files.provider.common.decodedPathByteString -import me.zhanghai.android.files.provider.common.decodedQueryByteString -import me.zhanghai.android.files.provider.common.isSameFile -import me.zhanghai.android.files.provider.common.toAccessModes -import me.zhanghai.android.files.provider.common.toByteString -import me.zhanghai.android.files.provider.common.toOpenOptions -import java.io.IOException -import java.io.InputStream -import java.net.URI - -class LocalArchiveFileSystemProvider( - private val provider: ArchiveFileSystemProvider -) : FileSystemProvider(), Searchable { - private val fileSystems = FileSystemCache() - - override fun getScheme(): String = SCHEME - - override fun newFileSystem(uri: URI, env: Map): FileSystem { - uri.requireSameScheme() - val archiveFile = uri.archiveFile - return fileSystems.create(archiveFile) { newFileSystem(archiveFile) } - } - - override fun newFileSystem(file: Path, env: Map): FileSystem = newFileSystem(file) - - internal fun getOrNewFileSystem(archiveFile: Path): ArchiveFileSystem = - fileSystems.getOrCreate(archiveFile) { newFileSystem(archiveFile) } - - private fun newFileSystem(archiveFile: Path): ArchiveFileSystem = - ArchiveFileSystem(provider, archiveFile) - - override fun getFileSystem(uri: URI): FileSystem { - uri.requireSameScheme() - val archiveFile = uri.archiveFile - return fileSystems[archiveFile] - } - - internal fun removeFileSystem(fileSystem: ArchiveFileSystem) { - fileSystems.remove(fileSystem.archiveFile, fileSystem) - } - - override fun getPath(uri: URI): Path { - uri.requireSameScheme() - val archiveFile = uri.archiveFile - val path = uri.decodedQueryByteString - ?: throw IllegalArgumentException("URI must have a query") - return getOrNewFileSystem(archiveFile).getPath(path) - } - - private fun URI.requireSameScheme() { - val scheme = scheme - require(scheme == SCHEME) { "URI scheme $scheme must be $SCHEME" } - } - - private val URI.archiveFile: Path - get() { - val path = decodedPathByteString - ?: throw IllegalArgumentException("URI must have a path") - // Drop the first character which is always a slash. - val archiveUri = URI.create(path.toString().drop(1)) - return Paths.get(archiveUri) - } - - @Throws(IOException::class) - override fun newInputStream(file: Path, vararg options: OpenOption): InputStream { - file as? ArchivePath ?: throw ProviderMismatchException(file.toString()) - options.toOpenOptions().checkForArchive() - return file.fileSystem.newInputStreamAsLocal(file) - } - - override fun newFileChannel( - file: Path, - options: Set, - vararg attributes: FileAttribute<*> - ): FileChannel { - file as? ArchivePath ?: throw ProviderMismatchException(file.toString()) - options.toOpenOptions().checkForArchive() - if (attributes.isNotEmpty()) { - throw UnsupportedOperationException(attributes.contentToString()) - } - throw UnsupportedOperationException() - } - - override fun newByteChannel( - file: Path, - options: Set, - vararg attributes: FileAttribute<*> - ): SeekableByteChannel { - file as? ArchivePath ?: throw ProviderMismatchException(file.toString()) - options.toOpenOptions().checkForArchive() - if (attributes.isNotEmpty()) { - throw UnsupportedOperationException(attributes.contentToString()) - } - throw UnsupportedOperationException() - } - - @Throws(IOException::class) - override fun newDirectoryStream( - directory: Path, - filter: DirectoryStream.Filter - ): DirectoryStream { - directory as? ArchivePath ?: throw ProviderMismatchException(directory.toString()) - val children = directory.fileSystem.getDirectoryChildrenAsLocal(directory) - return PathListDirectoryStream(children, filter) - } - - @Throws(IOException::class) - override fun createDirectory(directory: Path, vararg attributes: FileAttribute<*>) { - directory as? ArchivePath ?: throw ProviderMismatchException(directory.toString()) - throw ReadOnlyFileSystemException(directory.toString()) - } - - @Throws(IOException::class) - override fun createSymbolicLink(link: Path, target: Path, vararg attributes: FileAttribute<*>) { - link as? ArchivePath ?: throw ProviderMismatchException(link.toString()) - when (target) { - is ArchivePath, is ByteStringPath -> {} - else -> throw ProviderMismatchException(target.toString()) - } - throw ReadOnlyFileSystemException(link.toString(), target.toString(), null) - } - - @Throws(IOException::class) - override fun createLink(link: Path, existing: Path) { - link as? ArchivePath ?: throw ProviderMismatchException(link.toString()) - existing as? ArchivePath ?: throw ProviderMismatchException(existing.toString()) - throw ReadOnlyFileSystemException(link.toString(), existing.toString(), null) - } - - @Throws(IOException::class) - override fun delete(path: Path) { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - throw ReadOnlyFileSystemException(path.toString()) - } - - @Throws(IOException::class) - override fun readSymbolicLink(link: Path): Path { - link as? ArchivePath ?: throw ProviderMismatchException(link.toString()) - val target = link.fileSystem.readSymbolicLinkAsLocal(link) - return ByteStringPath(target.toByteString()) - } - - @Throws(IOException::class) - override fun copy(source: Path, target: Path, vararg options: CopyOption) { - source as? ArchivePath ?: throw ProviderMismatchException(source.toString()) - target as? ArchivePath ?: throw ProviderMismatchException(target.toString()) - throw ReadOnlyFileSystemException(source.toString(), target.toString(), null) - } - - @Throws(IOException::class) - override fun move(source: Path, target: Path, vararg options: CopyOption) { - source as? ArchivePath ?: throw ProviderMismatchException(source.toString()) - target as? ArchivePath ?: throw ProviderMismatchException(target.toString()) - throw ReadOnlyFileSystemException(source.toString(), target.toString(), null) - } - - @Throws(IOException::class) - override fun isSameFile(path: Path, path2: Path): Boolean { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - if (path == path2) { - return true - } - if (path2 !is ArchivePath) { - return false - } - val fileSystem = path.fileSystem - if (!fileSystem.archiveFile.isSameFile(path2.fileSystem.archiveFile)) { - return false - } - return path == fileSystem.getPath(path2.toString()) - } - - override fun isHidden(path: Path): Boolean { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - return false - } - - override fun getFileStore(path: Path): FileStore { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - val archiveFile = path.fileSystem.archiveFile - return ArchiveFileStore(archiveFile) - } - - @Throws(IOException::class) - override fun checkAccess(path: Path, vararg modes: AccessMode) { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - val accessModes = modes.toAccessModes() - path.fileSystem.getEntryAsLocal(path) - if (accessModes.write || accessModes.execute) { - throw AccessDeniedException(path.toString()) - } - } - - override fun getFileAttributeView( - path: Path, - type: Class, - vararg options: LinkOption - ): V? { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - if (!supportsFileAttributeView(type)) { - return null - } - @Suppress("UNCHECKED_CAST") - return getFileAttributeView(path) as V - } - - @Throws(IOException::class) - override fun readAttributes( - path: Path, - type: Class, - vararg options: LinkOption - ): A { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - if (!type.isAssignableFrom(ArchiveFileAttributes::class.java)) { - throw UnsupportedOperationException(type.toString()) - } - @Suppress("UNCHECKED_CAST") - return getFileAttributeView(path).readAttributes() as A - } - - private fun getFileAttributeView(path: ArchivePath): ArchiveFileAttributeView = - ArchiveFileAttributeView(path) - - override fun readAttributes( - path: Path, - attributes: String, - vararg options: LinkOption - ): Map { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - throw UnsupportedOperationException() - } - - override fun setAttribute( - path: Path, - attribute: String, - value: Any, - vararg options: LinkOption - ) { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - throw UnsupportedOperationException() - } - - @Throws(IOException::class) - override fun search( - directory: Path, - query: String, - intervalMillis: Long, - listener: (List) -> Unit - ) { - directory as? ArchivePath ?: throw ProviderMismatchException(directory.toString()) - WalkFileTreeSearchable.search(directory, query, intervalMillis, listener) - } - - companion object { - internal const val SCHEME = "archive" - - internal fun supportsFileAttributeView(type: Class): Boolean = - type.isAssignableFrom(ArchiveFileAttributeView::class.java) - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/PathArchiveExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/PathArchiveExtensions.kt index 511744e2d..1376d1ee2 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/PathArchiveExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/PathArchiveExtensions.kt @@ -8,6 +8,11 @@ package me.zhanghai.android.files.provider.archive import java8.nio.file.Path import java8.nio.file.ProviderMismatchException +fun Path.archiveAddPassword(password: String) { + this as? ArchivePath ?: throw ProviderMismatchException(toString()) + fileSystem.addPassword(password) +} + val Path.archiveFile: Path get() { this as? ArchivePath ?: throw ProviderMismatchException(toString()) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileAttributeView.kt deleted file mode 100644 index fd5a7bf09..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileAttributeView.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.file.Path -import me.zhanghai.android.files.provider.common.PosixFileAttributeView -import me.zhanghai.android.files.provider.common.PosixFileAttributes -import me.zhanghai.android.files.provider.root.RootPosixFileAttributeView -import java.io.IOException - -internal class RootArchiveFileAttributeView( - attributeView: PosixFileAttributeView, - private val path: Path -) : RootPosixFileAttributeView(attributeView) { - @Throws(IOException::class) - override fun readAttributes(): PosixFileAttributes { - ArchiveFileSystemProvider.doRefreshIfNeeded(path) - return super.readAttributes() - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileSystem.kt deleted file mode 100644 index 002a60825..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileSystem.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.file.FileSystem -import me.zhanghai.android.files.provider.remote.RemoteFileSystemException -import me.zhanghai.android.files.provider.root.RootFileService -import me.zhanghai.android.files.provider.root.RootFileSystem - -internal class RootArchiveFileSystem( - private val fileSystem: FileSystem -) : RootFileSystem(fileSystem) { - private var isRefreshNeeded = false - - private val lock = Any() - - fun refresh() { - synchronized(lock) { - if (hasRemoteInterface()) { - isRefreshNeeded = true - } - } - } - - @Throws(RemoteFileSystemException::class) - fun doRefreshIfNeeded() { - synchronized(lock) { - if (isRefreshNeeded) { - if (hasRemoteInterface()) { - RootFileService.refreshArchiveFileSystem(fileSystem) - } - isRefreshNeeded = false - } - } - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileSystemProvider.kt deleted file mode 100644 index f8ef31081..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/RootArchiveFileSystemProvider.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2019 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive - -import java8.nio.file.AccessMode -import java8.nio.file.DirectoryStream -import java8.nio.file.OpenOption -import java8.nio.file.Path -import java8.nio.file.ProviderMismatchException -import me.zhanghai.android.files.provider.remote.RemoteFileSystemException -import me.zhanghai.android.files.provider.root.RootFileSystemProvider -import java.io.IOException -import java.io.InputStream - -class RootArchiveFileSystemProvider(scheme: String) : RootFileSystemProvider(scheme) { - @Throws(IOException::class) - override fun newInputStream(file: Path, vararg options: OpenOption): InputStream { - prepareFileSystem(file) - return super.newInputStream(file, *options) - } - - @Throws(IOException::class) - override fun newDirectoryStream( - directory: Path, - filter: DirectoryStream.Filter - ): DirectoryStream { - prepareFileSystem(directory) - return super.newDirectoryStream(directory, filter) - } - - @Throws(IOException::class) - override fun readSymbolicLink(link: Path): Path { - prepareFileSystem(link) - return super.readSymbolicLink(link) - } - - @Throws(IOException::class) - override fun checkAccess(path: Path, vararg modes: AccessMode) { - prepareFileSystem(path) - super.checkAccess(path, *modes) - } - - @Throws(RemoteFileSystemException::class) - private fun prepareFileSystem(path: Path) { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - val fileSystem = path.fileSystem - fileSystem.ensureRootInterface() - fileSystem.doRefreshIfNeededAsRoot() - } - - @Throws(RemoteFileSystemException::class) - internal fun doRefreshIfNeeded(path: Path) { - path as? ArchivePath ?: throw ProviderMismatchException(path.toString()) - path.fileSystem.doRefreshIfNeededAsRoot() - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveException.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveException.kt deleted file mode 100644 index 9b5c2ca2b..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveException.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2018 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive.archiver - -//#ifdef NONFREE -import com.github.junrar.exception.RarException -//#endif -import org.apache.commons.compress.compressors.CompressorException -import java.io.IOException -import org.apache.commons.compress.archivers.ArchiveException as ApacheArchiveException - -class ArchiveException : IOException { - constructor(cause: ApacheArchiveException) : super(cause) - - constructor(cause: CompressorException) : super(cause) - -//#ifdef NONFREE - constructor(cause: RarException) : super(cause) -//#endif -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveReader.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveReader.kt index 5c41b47b4..66595c745 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveReader.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveReader.kt @@ -5,66 +5,37 @@ package me.zhanghai.android.files.provider.archive.archiver -import android.os.Build -import androidx.annotation.RequiresApi import androidx.preference.PreferenceManager import java8.nio.channels.SeekableByteChannel import java8.nio.charset.StandardCharsets -import java8.nio.file.AccessMode -import java8.nio.file.NoSuchFileException -import java8.nio.file.NotLinkException import java8.nio.file.Path import me.zhanghai.android.files.R -import me.zhanghai.android.files.compat.toJavaSeekableByteChannel -import me.zhanghai.android.files.compat.use -//#ifdef NONFREE -import me.zhanghai.android.files.nonfree.RarArchiveEntry -import me.zhanghai.android.files.nonfree.RarFile -//#endif import me.zhanghai.android.files.provider.common.DelegateForceableSeekableByteChannel import me.zhanghai.android.files.provider.common.DelegateInputStream import me.zhanghai.android.files.provider.common.DelegateNonForceableSeekableByteChannel import me.zhanghai.android.files.provider.common.ForceableChannel -import me.zhanghai.android.files.provider.common.IsDirectoryException +import me.zhanghai.android.files.provider.common.PosixFileMode import me.zhanghai.android.files.provider.common.PosixFileType -import me.zhanghai.android.files.provider.common.checkAccess import me.zhanghai.android.files.provider.common.newByteChannel import me.zhanghai.android.files.provider.common.newInputStream -import me.zhanghai.android.files.provider.common.posixFileType -import me.zhanghai.android.files.provider.linux.isLinuxPath import me.zhanghai.android.files.provider.root.isRunningAsRoot import me.zhanghai.android.files.provider.root.rootContext import me.zhanghai.android.files.settings.Settings import me.zhanghai.android.files.util.valueCompat -import org.apache.commons.compress.archivers.ArchiveEntry -import org.apache.commons.compress.archivers.ArchiveInputStream -import org.apache.commons.compress.archivers.ArchiveStreamFactory -import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry -import org.apache.commons.compress.archivers.sevenz.SevenZFile -import org.apache.commons.compress.archivers.tar.TarArchiveEntry -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry -import org.apache.commons.compress.compressors.CompressorException -import org.apache.commons.compress.compressors.CompressorStreamFactory -import java.io.BufferedInputStream import java.io.Closeable -import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream -import java.util.Date -import kotlin.reflect.KClass -import org.apache.commons.compress.archivers.ArchiveException as ApacheArchiveException +import java.nio.charset.Charset object ArchiveReader { - private val compressorStreamFactory = CompressorStreamFactory() - private val archiveStreamFactory = ArchiveStreamFactory() - @Throws(IOException::class) fun readEntries( file: Path, + passwords: List, rootPath: Path - ): Pair, Map>> { - val entries = mutableMapOf() - val rawEntries = readEntries(file) + ): Pair, Map>> { + val entries = mutableMapOf() + val rawEntries = readEntries(file, passwords) for (entry in rawEntries) { var path = rootPath.resolve(entry.name) // Normalize an absolute path to prevent path traversal attack. @@ -78,10 +49,15 @@ object ArchiveReader { // Don't allow a path to become the root path only after normalization. continue } + } else { + if (!entry.isDirectory) { + // Ignore a root path that's not a directory + continue + } } entries.getOrPut(path) { entry } } - entries.getOrPut(rootPath) { DirectoryArchiveEntry("") } + entries.getOrPut(rootPath) { createDirectoryEntry("") } val tree = mutableMapOf>() tree[rootPath] = mutableListOf() val paths = entries.keys.toList() @@ -97,235 +73,42 @@ object ArchiveReader { if (entries.containsKey(parentPath)) { break } - entries[parentPath] = DirectoryArchiveEntry(parentPath.toString()) + entries[parentPath] = createDirectoryEntry(parentPath.toString()) path = parentPath } } return entries to tree } + private fun createDirectoryEntry(name: String): ReadArchive.Entry { + require(!name.endsWith("/")) { "name $name should not end with a slash" } + return ReadArchive.Entry( + name, false, null, null, null, PosixFileType.DIRECTORY, 0, null, null, + PosixFileMode.DIRECTORY_DEFAULT, null + ) + } + @Throws(IOException::class) - private fun readEntries(file: Path): List { - val compressorType: String? - val archiveType = try { - file.newInputStream().buffered().use { inputStream -> - compressorType = try { - // inputStream must be buffered for markSupported(). - CompressorStreamFactory.detect(inputStream) - } catch (e: CompressorException) { - // Ignored. - null - } - val compressorInputStream = if (compressorType != null) { - compressorStreamFactory.createCompressorInputStream(compressorType, inputStream) - .buffered() - } else { - inputStream - } - try { - // compressorInputStream must be buffered for markSupported(). - compressorInputStream.use { detectArchiveType(it) } - } catch (e: ApacheArchiveException) { - throw ArchiveException(e) - } catch (e: CompressorException) { - throw ArchiveException(e) + private fun readEntries(file: Path, passwords: List): List { + val charset = archiveFileNameCharset + val (archive, closeable) = openArchive(file, passwords) + return closeable.use { + buildList { + while (true) { + this += archive.readEntry(charset) ?: break } } - } catch (e: FileNotFoundException) { - file.checkAccess(AccessMode.READ) - throw NoSuchFileException(file.toString()).apply { initCause(e) } - } - val encoding = archiveFileNameEncoding - if (compressorType == null) { - when { - archiveType == ArchiveStreamFactory.ZIP && ZipFileCompat::class.isSupported(file) -> - return ZipFileCompat::class.create(file, encoding).use { it.entries.toList() } - archiveType == ArchiveStreamFactory.SEVEN_Z -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - throw IOException(UnsupportedOperationException("SevenZFile")) - } - return SevenZFile::class.create(file).use { it.entries.toList() } - } - //#ifdef NONFREE - archiveType == RarFile.RAR -> - return RarFile.create(file, encoding).use { it.entries.toList() } - //#endif - // Unnecessary, but teaches lint that compressorType != null below might be false. - else -> {} - } - } - return try { - file.newInputStream().buffered().use { inputStream -> - val compressorInputStream = if (compressorType != null) { - compressorStreamFactory.createCompressorInputStream(compressorType, inputStream) - } else { - inputStream - } - compressorInputStream.use { - archiveStreamFactory.createArchiveInputStream( - archiveType, compressorInputStream, encoding - ).use { archiveInputStream -> - val entries = mutableListOf() - while (true) { - val entry = archiveInputStream.nextEntry ?: break - entries += entry - } - entries - } - } - } - } catch (e: FileNotFoundException) { - throw NoSuchFileException(file.toString()).apply { initCause(e) } - } catch (e: ApacheArchiveException) { - throw ArchiveException(e) - } catch (e: CompressorException) { - throw ArchiveException(e) } } - private val archiveFileNameEncoding: String - get() = - if (isRunningAsRoot) { - try { - val sharedPreferences = - PreferenceManager.getDefaultSharedPreferences(rootContext) - val key = rootContext.getString(R.string.pref_key_archive_file_name_encoding) - val defaultValue = rootContext.getString( - R.string.pref_default_value_archive_file_name_encoding - ) - sharedPreferences.getString(key, defaultValue)!! - } catch (e: Exception) { - e.printStackTrace() - StandardCharsets.UTF_8.name() - } - } else { - Settings.ARCHIVE_FILE_NAME_ENCODING.valueCompat - } - @Throws(IOException::class) - fun newInputStream(file: Path, entry: ArchiveEntry): InputStream { - if (entry.isDirectory) { - throw IsDirectoryException(file.toString()) - } - val compressorType: String? - val archiveType = try { - file.newInputStream().buffered().use { inputStream -> - compressorType = try { - // inputStream must be buffered for markSupported(). - CompressorStreamFactory.detect(inputStream) - } catch (e: CompressorException) { - // Ignored. - null - } - val compressorInputStream = if (compressorType != null) { - compressorStreamFactory.createCompressorInputStream(compressorType, inputStream) - .buffered() - } else { - inputStream - } - try { - // compressorInputStream must be buffered for markSupported(). - compressorInputStream.use { detectArchiveType(it) } - } catch (e: ApacheArchiveException) { - throw ArchiveException(e) - } catch (e: CompressorException) { - throw ArchiveException(e) - } - } - } catch (e: FileNotFoundException) { - file.checkAccess(AccessMode.READ) - throw NoSuchFileException(file.toString()).apply { initCause(e) } - } - val encoding = archiveFileNameEncoding - if (compressorType == null) { - when { - entry is ZipArchiveEntry && ZipFileCompat::class.isSupported(file) -> { - var successful = false - var zipFile: ZipFileCompat? = null - var zipEntryInputStream: InputStream? = null - return try { - zipFile = ZipFileCompat::class.create(file, encoding) - zipEntryInputStream = zipFile.getInputStream(entry) - ?: throw NoSuchFileException(file.toString()) - val inputStream = CloseableInputStream(zipEntryInputStream, zipFile) - successful = true - inputStream - } finally { - if (!successful) { - zipEntryInputStream?.close() - zipFile?.close() - } - } - } - entry is SevenZArchiveEntry -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - throw IOException(UnsupportedOperationException("SevenZFile")) - } - var successful = false - var sevenZFile: SevenZFile? = null - return try { - sevenZFile = SevenZFile::class.create(file) - var inputStream: InputStream? = null - while (true) { - val currentEntry = sevenZFile.nextEntry ?: break - if (currentEntry.name != entry.name) { - continue - } - inputStream = SevenZArchiveEntryInputStream(sevenZFile, currentEntry) - successful = true - break - } - inputStream ?: throw NoSuchFileException(file.toString()) - } finally { - if (!successful) { - sevenZFile?.close() - } - } - } -//#ifdef NONFREE - entry is RarArchiveEntry -> { - var successful = false - var rarFile: RarFile? = null - return try { - rarFile = RarFile.create(file, encoding) - var inputStream: InputStream? = null - while (true) { - val currentEntry = rarFile.nextEntry ?: break - if (currentEntry.name != entry.name) { - continue - } - inputStream = rarFile.getInputStream(currentEntry) - successful = true - break - } - inputStream ?: throw NoSuchFileException(file.toString()) - } finally { - if (!successful) { - rarFile?.close() - } - } - } -//#endif - // Unnecessary, but teaches lint that compressorType != null below might be false. - else -> {} - } - } + fun newInputStream(file: Path, passwords: List, entry: ReadArchive.Entry): InputStream? { + val charset = archiveFileNameCharset + val (archive, closeable) = openArchive(file, passwords) var successful = false - var inputStream: BufferedInputStream? = null - var compressorInputStream: InputStream? = null - var archiveInputStream: ArchiveInputStream? = null return try { - inputStream = file.newInputStream().buffered() - compressorInputStream = if (compressorType != null) { - compressorStreamFactory.createCompressorInputStream(compressorType, inputStream) - } else { - inputStream - } - archiveInputStream = archiveStreamFactory.createArchiveInputStream( - archiveType, compressorInputStream, encoding - ) while (true) { - val currentEntry = archiveInputStream.nextEntry ?: break + val currentEntry = archive.readEntry(charset) ?: break if (currentEntry.name != entry.name) { continue } @@ -333,55 +116,54 @@ object ArchiveReader { break } if (successful) { - archiveInputStream + CloseableInputStream(archive.newDataInputStream(), closeable) } else { - throw NoSuchFileException(file.toString()) + null } - } catch (e: FileNotFoundException) { - throw NoSuchFileException(file.toString()).apply { initCause(e) } - } catch (e: ApacheArchiveException) { - throw ArchiveException(e) - } catch (e: CompressorException) { - throw ArchiveException(e) } finally { if (!successful) { - archiveInputStream?.close() - compressorInputStream?.close() - inputStream?.close() + closeable.close() } } } - @Throws(ApacheArchiveException::class) - private fun detectArchiveType(inputStream: InputStream): String = -//#ifdef NONFREE + @Throws(IOException::class) + private fun openArchive( + file: Path, + passwords: List + ): Pair { + val channel = try { + CacheSizeSeekableByteChannel(file.newByteChannel()) + } catch (e: Exception) { + e.printStackTrace() + null + } + if (channel != null) { + var successful = false + try { + val archive = ReadArchive(channel, passwords) + successful = true + return archive to ArchiveCloseable(archive, channel) + } finally { + if (!successful) { + channel.close() + } + } + } + val inputStream = file.newInputStream() + var successful = false try { - RarFile.detect(inputStream) - } catch (e: IOException) { - throw ApacheArchiveException("RarFile.detect()", e) - } ?: -//#endif - ArchiveStreamFactory.detect(inputStream) - - private fun KClass.isSupported(file: Path): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N || file.isLinuxPath - - private fun KClass.create(file: Path, encoding: String?): ZipFileCompat = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - ZipFileCompat( - CacheSizeSeekableByteChannel(file.newByteChannel()).toJavaSeekableByteChannel(), - encoding - ) - } else { - ZipFileCompat(file.toFile()) + val archive = ReadArchive(inputStream, passwords) + successful = true + return archive to ArchiveCloseable(archive, inputStream) + } finally { + if (!successful) { + inputStream.close() + } } + } - @RequiresApi(Build.VERSION_CODES.N) - private fun KClass.create(file: Path): SevenZFile = - SevenZFile(CacheSizeSeekableByteChannel(file.newByteChannel()).toJavaSeekableByteChannel()) - - // ZipFileCompat and SevenZFile call size() repeatedly, especially ZipFile.skipBytes(), so make - // it cached to improve performance. + // size() may be called repeatedly for ZIP and 7Z, so make it cached to improve performance. private fun CacheSizeSeekableByteChannel(channel: SeekableByteChannel): SeekableByteChannel = if (channel is ForceableChannel) { CacheSizeForceableSeekableByteChannel(channel) @@ -405,48 +187,37 @@ object ArchiveReader { override fun size(): Long = size } - @Throws(IOException::class) - fun readSymbolicLink(file: Path, entry: ArchiveEntry): String { - if (!isSymbolicLink(entry)) { - throw NotLinkException(file.toString()) - } - return if (entry is TarArchiveEntry) { - entry.linkName - } else { - newInputStream(file, entry).use { it.reader(StandardCharsets.UTF_8).readText() } - } - } - - private fun isSymbolicLink(entry: ArchiveEntry): Boolean = - entry.posixFileType == PosixFileType.SYMBOLIC_LINK - - private class DirectoryArchiveEntry(name: String) : ArchiveEntry { - init { - require(!name.endsWith("/")) { "name $name should not end with a slash" } - } - - private val name = "$name/" - - override fun getName(): String = name - - override fun getSize(): Long = 0 - - override fun isDirectory(): Boolean = true - - override fun getLastModifiedDate(): Date = Date(-1) - - override fun equals(other: Any?): Boolean { - if (this === other) { - return true + private val archiveFileNameCharset: Charset + get() = + if (isRunningAsRoot) { + try { + val sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(rootContext) + val key = rootContext.getString(R.string.pref_key_archive_file_name_encoding) + val defaultValue = rootContext.getString( + R.string.pref_default_value_archive_file_name_encoding + ) + Charset.forName(sharedPreferences.getString(key, defaultValue)!!) + } catch (e: Exception) { + e.printStackTrace() + StandardCharsets.UTF_8 + } + } else { + Charset.forName(Settings.ARCHIVE_FILE_NAME_ENCODING.valueCompat) } - if (javaClass != other?.javaClass) { - return false + + private class ArchiveCloseable( + private val archive: ReadArchive, + private val closeable: Closeable + ) : Closeable { + override fun close() { + @Suppress("ConvertTryFinallyToUseCall") + try { + archive.close() + } finally { + closeable.close() } - other as DirectoryArchiveEntry - return name == other.name } - - override fun hashCode(): Int = name.hashCode() } private class CloseableInputStream( @@ -460,31 +231,4 @@ object ArchiveReader { closeable.close() } } - - private class SevenZArchiveEntryInputStream( - private val file: SevenZFile, - private val entry: SevenZArchiveEntry - ) : InputStream() { - override fun available(): Int { - val size = entry.size - val read = file.statisticsForCurrentEntry - .uncompressedCount - val available = size - read - return available.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - } - - @Throws(IOException::class) - override fun read(): Int = file.read() - - @Throws(IOException::class) - override fun read(b: ByteArray): Int = file.read(b) - - @Throws(IOException::class) - override fun read(b: ByteArray, off: Int, len: Int): Int = file.read(b, off, len) - - @Throws(IOException::class) - override fun close() { - file.close() - } - } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveWriter.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveWriter.kt index f8a1e2958..3524f897b 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveWriter.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ArchiveWriter.kt @@ -5,224 +5,74 @@ package me.zhanghai.android.files.provider.archive.archiver -import android.os.Build import java8.nio.channels.SeekableByteChannel import java8.nio.file.LinkOption import java8.nio.file.Path import java8.nio.file.attribute.BasicFileAttributes -import me.zhanghai.android.files.compat.toJavaSeekableByteChannel import me.zhanghai.android.files.provider.common.PosixFileAttributes +import me.zhanghai.android.files.provider.common.PosixFileMode +import me.zhanghai.android.files.provider.common.PosixFileType import me.zhanghai.android.files.provider.common.copyTo import me.zhanghai.android.files.provider.common.getLastModifiedTime -import me.zhanghai.android.files.provider.common.isDirectory -import me.zhanghai.android.files.provider.common.isRegularFile import me.zhanghai.android.files.provider.common.newInputStream -import me.zhanghai.android.files.provider.common.newOutputStream import me.zhanghai.android.files.provider.common.readAttributes import me.zhanghai.android.files.provider.common.readSymbolicLinkByteString import me.zhanghai.android.files.provider.common.size -import me.zhanghai.android.files.provider.common.toInt -import me.zhanghai.android.files.util.lazyReflectedField -import org.apache.commons.compress.archivers.ArchiveEntry -import org.apache.commons.compress.archivers.ArchiveOutputStream -import org.apache.commons.compress.archivers.ArchiveStreamFactory -import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile -import org.apache.commons.compress.archivers.tar.TarArchiveEntry -import org.apache.commons.compress.archivers.tar.TarConstants -import org.apache.commons.compress.archivers.zip.UnixStat -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry -import org.apache.commons.compress.compressors.CompressorException -import org.apache.commons.compress.compressors.CompressorStreamFactory import java.io.Closeable -import java.io.File import java.io.IOException -import java.io.OutputStream -class ArchiveWriter( - archiveType: String, - compressorType: String?, - channel: SeekableByteChannel +class ArchiveWriter @Throws(IOException::class) constructor( + channel: SeekableByteChannel, + format: Int, + filter: Int, + password: String? ) : Closeable { - private val archiveOutputStream: ArchiveOutputStream - - init { - when (archiveType) { - ArchiveStreamFactory.SEVEN_Z -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - throw IOException(UnsupportedOperationException("SevenZOutputFile")) - } - archiveOutputStream = SevenZArchiveOutputStream( - SevenZOutputFile(channel.toJavaSeekableByteChannel()) - ) - } - else -> { - var successful = false - var outputStream: OutputStream? = null - var compressorOutputStream: OutputStream? = null - try { - outputStream = channel.newOutputStream().buffered() - compressorOutputStream = if (compressorType != null) { - compressorStreamFactory.createCompressorOutputStream( - compressorType, outputStream - ) - } else { - outputStream - } - // Use the platform default encoding (which is UTF-8) instead of the user-set - // one, because that one is for reading archives instead of creating. - archiveOutputStream = archiveStreamFactory.createArchiveOutputStream( - archiveType, compressorOutputStream - ) - successful = true - } catch (e: org.apache.commons.compress.archivers.ArchiveException) { - throw ArchiveException(e) - } catch (e: CompressorException) { - throw ArchiveException(e) - } finally { - if (!successful) { - compressorOutputStream?.close() - outputStream?.close() - } - } - } - } - } + private val archive = WriteArchive(channel, format, filter, password) @Throws(IOException::class) fun write(file: Path, entryName: Path, intervalMillis: Long, listener: ((Long) -> Unit)?) { - val entry = archiveOutputStream.createArchiveEntry(PathFile(file), entryName.toString()) + val name = entryName.toString() + val lastModifiedTime = file.getLastModifiedTime(LinkOption.NOFOLLOW_LINKS) + val lastAccessTime = null + val creationTime = null val attributes = file.readAttributes( BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS ) - val writeData = when { - attributes.isRegularFile -> true - attributes.isDirectory -> false - attributes.isSymbolicLink -> - when (entry) { - is ZipArchiveEntry -> { - entry.unixMode = UnixStat.LINK_FLAG or UnixStat.DEFAULT_LINK_PERM - true - } - is TarArchiveEntry -> { - tarArchiveEntryLinkFlagsField.setByte(entry, TarConstants.LF_SYMLINK) - entry.linkName = file.readSymbolicLinkByteString().toString() - false - } - else -> throw IOException(UnsupportedOperationException("symbolic link")) - } - else -> throw IOException(UnsupportedOperationException("type")) + val type = when { + attributes is PosixFileAttributes -> attributes.type() + attributes.isDirectory -> PosixFileType.DIRECTORY + attributes.isSymbolicLink -> PosixFileType.SYMBOLIC_LINK + else -> PosixFileType.REGULAR_FILE } - if (entry is TarArchiveEntry && attributes is PosixFileAttributes) { - attributes.mode()?.let { entry.mode = it.toInt() } - val owner = attributes.owner() - if (owner != null) { - entry.userId = owner.id - owner.name?.let { entry.userName = it } - } - val group = attributes.group() - if (group != null) { - entry.groupId = group.id - group.name?.let { entry.groupName = it } - } + val size = file.size(LinkOption.NOFOLLOW_LINKS) + val posixAttributes = attributes as? PosixFileAttributes + val owner = posixAttributes?.owner() + val group = posixAttributes?.group() + val mode = posixAttributes?.mode() ?: when { + attributes.isDirectory -> PosixFileMode.DIRECTORY_DEFAULT + attributes.isSymbolicLink -> PosixFileMode.SYMBOLIC_LINK_DEFAULT + else -> PosixFileMode.FILE_DEFAULT } - archiveOutputStream.putArchiveEntry(entry) - var isListenerNotified = false - if (writeData) { - if (attributes.isSymbolicLink) { - val target = file.readSymbolicLinkByteString().borrowBytes() - archiveOutputStream.write(target) - } else { - file.newInputStream(LinkOption.NOFOLLOW_LINKS).use { inputStream -> - inputStream.copyTo(archiveOutputStream, intervalMillis, listener) - } - isListenerNotified = true - } + val symbolicLinkTarget = if (attributes.isSymbolicLink) { + file.readSymbolicLinkByteString().toString() + } else { + null } - archiveOutputStream.closeArchiveEntry() - if (!isListenerNotified) { + archive.Entry( + name, lastModifiedTime, lastAccessTime, creationTime, type, size, owner, group, mode, + symbolicLinkTarget + ).use { archive.writeEntry(it) } + if (type == PosixFileType.REGULAR_FILE) { + file.newInputStream(LinkOption.NOFOLLOW_LINKS).use { inputStream -> + inputStream.copyTo(archive.newDataOutputStream(), intervalMillis, listener) + } + } else { listener?.invoke(attributes.size()) } } @Throws(IOException::class) override fun close() { - archiveOutputStream.finish() - archiveOutputStream.close() - } - - private class SevenZArchiveOutputStream( - private val file: SevenZOutputFile - ) : ArchiveOutputStream() { - @Throws(IOException::class) - override fun createArchiveEntry(file: File, entryName: String): ArchiveEntry = - this.file.createArchiveEntry(file, entryName) - - @Throws(IOException::class) - override fun putArchiveEntry(entry: ArchiveEntry) { - file.putArchiveEntry(entry) - } - - @Throws(IOException::class) - override fun write(b: Int) { - file.write(b) - } - - @Throws(IOException::class) - override fun write(b: ByteArray) { - file.write(b) - } - - @Throws(IOException::class) - override fun write(b: ByteArray, off: Int, len: Int) { - file.write(b, off, len) - } - - @Throws(IOException::class) - override fun closeArchiveEntry() { - file.closeArchiveEntry() - } - - @Throws(IOException::class) - override fun finish() { - file.finish() - } - - @Throws(IOException::class) - override fun close() { - file.close() - } - } - - // {@link ArchiveOutputStream#createArchiveEntry(File, String)} doesn't actually need a real - // file. - private class PathFile(private val path: Path) : File(path.toString()) { - override fun isDirectory(): Boolean = path.isDirectory(LinkOption.NOFOLLOW_LINKS) - - override fun isFile(): Boolean = path.isRegularFile(LinkOption.NOFOLLOW_LINKS) - - override fun lastModified(): Long = - try { - path.getLastModifiedTime(LinkOption.NOFOLLOW_LINKS).toMillis() - } catch (e: IOException) { - e.printStackTrace() - 0 - } - - override fun length(): Long = - try { - path.size(LinkOption.NOFOLLOW_LINKS) - } catch (e: IOException) { - e.printStackTrace() - 0 - } - } - - companion object { - private val compressorStreamFactory = CompressorStreamFactory() - private val archiveStreamFactory = ArchiveStreamFactory() - - private val tarArchiveEntryLinkFlagsField by lazyReflectedField( - TarArchiveEntry::class.java, "linkFlag" - ) + archive.close() } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ReadArchive.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ReadArchive.kt new file mode 100644 index 000000000..5f0e3c838 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ReadArchive.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.archive.archiver + +import android.system.OsConstants +import java.io.Closeable +import java.io.IOException +import java.io.InputStream +import java.io.InterruptedIOException +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.time.Instant +import java8.nio.channels.SeekableByteChannel +import java8.nio.charset.StandardCharsets +import java8.nio.file.attribute.FileTime +import me.zhanghai.android.files.provider.common.PosixFileMode +import me.zhanghai.android.files.provider.common.PosixFileModeBit +import me.zhanghai.android.files.provider.common.PosixFileType +import me.zhanghai.android.files.provider.common.PosixGroup +import me.zhanghai.android.files.provider.common.PosixUser +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.libarchive.Archive +import me.zhanghai.android.libarchive.ArchiveEntry +import me.zhanghai.android.libarchive.ArchiveException + +class ReadArchive : Closeable { + private val archive = Archive.readNew() + + @Throws(ArchiveException::class) + constructor(inputStream: InputStream, passwords: List) { + var successful = false + try { + Archive.setCharset(archive, StandardCharsets.UTF_8.name().toByteArray()) + Archive.readSupportFilterAll(archive) + Archive.readSupportFormatAll(archive) + Archive.readSetCallbackData(archive, null) + val buffer = ByteBuffer.allocate(DEFAULT_BUFFER_SIZE) + Archive.readSetReadCallback(archive) { _, _ -> + buffer.clear() + val bytesRead = try { + inputStream.read(buffer.array()) + } catch (e: IOException) { + throw e.toArchiveException("InputStream.read") + } + if (bytesRead != -1) { + buffer.limit(bytesRead) + buffer + } else { + null + } + } + Archive.readSetSkipCallback(archive) { _, _, request -> + try { + inputStream.skip(request) + } catch (e: IOException) { + throw e.toArchiveException("InputStream.skip") + } + } + for (password in passwords) { + Archive.readAddPassphrase(archive, password.toByteArray()) + } + Archive.readOpen1(archive) + successful = true + } finally { + if (!successful) { + close() + } + } + } + + @Throws(ArchiveException::class) + constructor(channel: SeekableByteChannel, passwords: List) { + var successful = false + try { + Archive.setCharset(archive, StandardCharsets.UTF_8.name().toByteArray()) + Archive.readSupportFilterAll(archive) + Archive.readSupportFormatAll(archive) + Archive.readSetCallbackData(archive, null) + val buffer = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE) + Archive.readSetReadCallback(archive) { _, _ -> + buffer.clear() + val bytesRead = try { + channel.read(buffer) + } catch (e: IOException) { + throw e.toArchiveException("SeekableByteChannel.read") + } + if (bytesRead != -1) { + buffer.flip() + buffer + } else { + null + } + } + Archive.readSetSkipCallback(archive) { _, _, request -> + try { + channel.position(channel.position() + request) + } catch (e: IOException) { + throw e.toArchiveException("SeekableByteChannel.position") + } + request + } + Archive.readSetSeekCallback(archive) { _, _, offset, whence -> + val newPosition: Long + try { + newPosition = when (whence) { + OsConstants.SEEK_SET -> offset + OsConstants.SEEK_CUR -> channel.position() + offset + OsConstants.SEEK_END -> channel.size() + offset + else -> throw ArchiveException( + Archive.ERRNO_FATAL, + "Unknown whence $whence" + ) + } + channel.position(newPosition) + } catch (e: IOException) { + throw e.toArchiveException("SeekableByteChannel.position") + } + newPosition + } + for (password in passwords) { + Archive.readAddPassphrase(archive, password.toByteArray()) + } + Archive.readOpen1(archive) + successful = true + } finally { + if (!successful) { + close() + } + } + } + + private fun IOException.toArchiveException(message: String): ArchiveException = + when (this) { + is InterruptedIOException -> ArchiveException(OsConstants.EINTR, message, this) + else -> ArchiveException(Archive.ERRNO_FATAL, message, this) + } + + @Throws(ArchiveException::class) + fun readEntry(charset: Charset): Entry? { + val entry = Archive.readNextHeader(archive) + if (entry == 0L) { + return null + } + val name = + getEntryString(ArchiveEntry.pathnameUtf8(entry), ArchiveEntry.pathname(entry), charset) + ?: throw ArchiveException( + Archive.ERRNO_FATAL, "pathname == null && pathnameUtf8 == null" + ) + val isEncrypted = ArchiveEntry.isEncrypted(entry) + val stat = ArchiveEntry.stat(entry) + val lastModifiedTime = if (ArchiveEntry.mtimeIsSet(entry)) { + FileTime.from( + Instant.ofEpochSecond(stat.stMtim.tvSec, stat.stMtim.tvNsec) + ) + } else { + null + } + val lastAccessTime = if (ArchiveEntry.atimeIsSet(entry)) { + FileTime.from( + Instant.ofEpochSecond(stat.stAtim.tvSec, stat.stAtim.tvNsec) + ) + } else { + null + } + val creationTime = if (ArchiveEntry.birthtimeIsSet(entry)) { + FileTime.from( + Instant.ofEpochSecond( + ArchiveEntry.birthtime(entry), ArchiveEntry.birthtimeNsec(entry) + ) + ) + } else { + null + } + val type = PosixFileType.fromMode(stat.stMode) + val size = stat.stSize + // TODO: There's no way to know if UID/GID is unset or root. + val owner = PosixUser( + stat.stUid, getEntryString( + ArchiveEntry.unameUtf8(entry), ArchiveEntry.uname(entry), charset + )?.toByteString() + ) + val group = PosixGroup( + stat.stGid, getEntryString( + ArchiveEntry.gnameUtf8(entry), ArchiveEntry.gname(entry), charset + )?.toByteString() + ) + val mode = PosixFileMode.fromInt(stat.stMode) + val symbolicLinkTarget = + getEntryString(ArchiveEntry.symlinkUtf8(entry), ArchiveEntry.symlink(entry), charset) + return Entry( + name, isEncrypted, lastModifiedTime, lastAccessTime, creationTime, type, size, owner, + group, mode, symbolicLinkTarget + ) + } + + private fun getEntryString(stringUtf8: String?, string: ByteArray?, charset: Charset): String? = + stringUtf8 ?: string?.toString(charset) + + @Throws(ArchiveException::class) + fun newDataInputStream(): InputStream = DataInputStream() + + @Throws(ArchiveException::class) + override fun close() { + Archive.readFree(archive) + } + + class Entry( + val name: String, + val isEncrypted: Boolean, + val lastModifiedTime: FileTime?, + val lastAccessTime: FileTime?, + val creationTime: FileTime?, + val type: PosixFileType, + val size: Long, + val owner: PosixUser?, + val group: PosixGroup?, + val mode: Set, + val symbolicLinkTarget: String? + ) { + val isDirectory: Boolean + get() = type == PosixFileType.DIRECTORY + + val isSymbolicLink: Boolean + get() = type == PosixFileType.SYMBOLIC_LINK + } + + private inner class DataInputStream : InputStream() { + private val oneByteBuffer = ByteBuffer.allocateDirect(1) + + @Throws(IOException::class) + override fun read(): Int { + read(oneByteBuffer) + return if (oneByteBuffer.hasRemaining()) oneByteBuffer.get().toUByte().toInt() else -1 + } + + @Throws(IOException::class) + override fun read(b: ByteArray, off: Int, len: Int): Int { + val buffer = ByteBuffer.wrap(b, off, len) + read(buffer) + return if (buffer.hasRemaining()) buffer.remaining() else -1 + } + + @Throws(IOException::class) + private fun read(buffer: ByteBuffer) { + buffer.clear() + Archive.readData(archive, buffer) + buffer.flip() + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/WriteArchive.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/WriteArchive.kt new file mode 100644 index 000000000..c17424201 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/WriteArchive.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.archive.archiver + +import java8.nio.channels.SeekableByteChannel +import java8.nio.charset.StandardCharsets +import java8.nio.file.attribute.FileTime +import me.zhanghai.android.files.provider.common.PosixFileModeBit +import me.zhanghai.android.files.provider.common.PosixFileType +import me.zhanghai.android.files.provider.common.PosixGroup +import me.zhanghai.android.files.provider.common.PosixUser +import me.zhanghai.android.files.provider.common.toInt +import me.zhanghai.android.libarchive.Archive +import me.zhanghai.android.libarchive.ArchiveEntry +import me.zhanghai.android.libarchive.ArchiveException +import java.io.Closeable +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +class WriteArchive @Throws(ArchiveException::class) constructor( + channel: SeekableByteChannel, + format: Int, + filter: Int, + password: String? +) : Closeable { + private val archive = Archive.writeNew() + + init { + var successful = false + try { + Archive.writeSetBytesPerBlock(archive, DEFAULT_BUFFER_SIZE) + Archive.writeSetBytesInLastBlock(archive, 1) + Archive.writeSetFormat(archive, format) + Archive.writeAddFilter(archive, filter) + if (password != null) { + require(format == Archive.FORMAT_ZIP) + Archive.writeSetPassphrase(archive, password.toByteArray()) + Archive.writeSetFormatOption( + archive, null, "encryption".toByteArray(), "zipcrypt".toByteArray() + ) + } + Archive.writeOpen( + archive, null, null, { _, _, buffer -> channel.write(buffer) }, null + ) + successful = true + } finally { + if (!successful) { + close() + } + } + } + + @Throws(ArchiveException::class) + fun writeEntry(entry: Entry) { + Archive.writeHeader(archive, entry.entry) + } + + @Throws(ArchiveException::class) + fun newDataOutputStream(): OutputStream = DataOutputStream() + + @Throws(ArchiveException::class) + override fun close() { + Archive.writeFree(archive) + } + + inner class Entry( + name: String, + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + creationTime: FileTime?, + type: PosixFileType, + size: Long, + owner: PosixUser?, + group: PosixGroup?, + mode: Set, + symbolicLinkTarget: String? + ) : Closeable { + internal val entry = ArchiveEntry.new2(archive) + + init { + Archive.setCharset(archive, StandardCharsets.UTF_8.name().toByteArray()) + ArchiveEntry.setPathname(entry, name.toByteArray()) + if (lastModifiedTime != null) { + val lastModifiedTimeInstant = lastModifiedTime.toInstant() + ArchiveEntry.setMtime( + entry, lastModifiedTimeInstant.epochSecond, + lastModifiedTimeInstant.nano.toLong() + ) + } + if (lastAccessTime != null) { + val lastAccessTimeInstant = lastAccessTime.toInstant() + ArchiveEntry.setAtime( + entry, lastAccessTimeInstant.epochSecond, lastAccessTimeInstant.nano.toLong() + ) + } + if (creationTime != null) { + val creationTimeInstant = creationTime.toInstant() + ArchiveEntry.setBirthtime( + entry, creationTimeInstant.epochSecond, creationTimeInstant.nano.toLong() + ) + } + ArchiveEntry.setFiletype(entry, type.mode) + ArchiveEntry.setSize(entry, size) + if (owner != null) { + ArchiveEntry.setUid(entry, owner.id.toLong()) + val ownerName = owner.name + if (ownerName != null) { + ArchiveEntry.setUname(entry, ownerName.toByteArray()) + } + } + if (group != null) { + ArchiveEntry.setGid(entry, group.id.toLong()) + val groupName = group.name + if (groupName != null) { + ArchiveEntry.setGname(entry, groupName.toByteArray()) + } + } + ArchiveEntry.setPerm(entry, mode.toInt()) + if (symbolicLinkTarget != null) { + ArchiveEntry.setSymlink(entry, symbolicLinkTarget.toByteArray()) + } + } + + override fun close() { + ArchiveEntry.free(entry) + } + } + + private inner class DataOutputStream : OutputStream() { + private val oneByteBuffer = ByteBuffer.allocateDirect(1) + + @Throws(IOException::class) + override fun write(b: Int) { + oneByteBuffer.clear() + oneByteBuffer.put(b.toByte()) + Archive.writeData(archive, oneByteBuffer) + } + + @Throws(IOException::class) + override fun write(b: ByteArray, off: Int, len: Int) { + val buffer = ByteBuffer.wrap(b, off, len) + while (buffer.hasRemaining()) { + Archive.writeData(archive, buffer) + } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ZipFileCompat.kt b/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ZipFileCompat.kt deleted file mode 100644 index 45265ec7e..000000000 --- a/app/src/main/java/me/zhanghai/android/files/provider/archive/archiver/ZipFileCompat.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2018 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.provider.archive.archiver - -import android.annotation.SuppressLint -import android.os.Build -import androidx.annotation.RequiresApi -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry -import org.apache.commons.compress.archivers.zip.ZipFile -import java.io.Closeable -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.nio.channels.SeekableByteChannel -import java.util.Enumeration -import java.util.zip.ZipEntry -import java.util.zip.ZipException -import java.util.zip.ZipFile as JavaZipFile - -internal class ZipFileCompat : Closeable { - @RequiresApi(Build.VERSION_CODES.N) - private val zipFile: ZipFile? - private val javaZipFile: JavaZipFile? - - @RequiresApi(Build.VERSION_CODES.N) - constructor(channel: SeekableByteChannel, encoding: String?) { - zipFile = ZipFile(channel, encoding) - javaZipFile = null - } - - constructor(file: File) { - @SuppressLint("NewApi") - zipFile = null - javaZipFile = JavaZipFile(file) - } - - val entries: Enumeration - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - zipFile!!.entries - } else { - val entries = javaZipFile!!.entries() - object : Enumeration { - override fun hasMoreElements(): Boolean = entries.hasMoreElements() - - override fun nextElement(): ZipArchiveEntry { - val entry = entries.nextElement() - return try { - ZipArchiveEntry(entry) - } catch (e: ZipException) { - e.printStackTrace() - UnparseableExtraZipArchiveEntry(entry) - } - } - } - } - - @Throws(IOException::class) - fun getInputStream(entry: ZipArchiveEntry): InputStream? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - zipFile!!.getInputStream(entry) - } else { - javaZipFile!!.getInputStream(entry) - } - - @Throws(IOException::class) - override fun close() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - zipFile!!.close() - } else { - javaZipFile!!.close() - } - } - - private class UnparseableExtraZipArchiveEntry(entry: ZipEntry) : ZipArchiveEntry(entry.name) { - init { - time = entry.time - setExtra() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - lastModifiedTime = entry.lastModifiedTime - lastAccessTime = entry.lastAccessTime - creationTime = entry.creationTime - } - val crc = entry.crc - if (crc in 0..0xFFFFFFFFL) { - setCrc(entry.crc) - } - val size = entry.size - if (size >= 0) { - setSize(size) - } - compressedSize = entry.compressedSize - method = entry.method - comment = entry.comment - } - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractFileByteChannel.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractFileByteChannel.kt new file mode 100644 index 000000000..14455e2ad --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractFileByteChannel.kt @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import java8.nio.channels.SeekableByteChannel +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeout +import me.zhanghai.android.files.util.closeSafe +import java.io.Closeable +import java.io.IOException +import java.io.InterruptedIOException +import java.nio.ByteBuffer +import java.nio.channels.ClosedChannelException +import java.nio.channels.NonReadableChannelException +import java.util.concurrent.CancellationException +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future + +abstract class AbstractFileByteChannel( + private val isAppend: Boolean, + private val shouldCancelRead: Boolean = true, + private val joinCancelledRead: Boolean = false +) : ForceableChannel, SeekableByteChannel { + private var position = 0L + private val readBuffer = ReadBuffer() + private val ioLock = Any() + + private var isOpen = true + private val closeLock = Any() + + @Throws(IOException::class) + final override fun read(destination: ByteBuffer): Int { + ensureOpen() + if (isAppend) { + throw NonReadableChannelException() + } + val remaining = destination.remaining() + if (remaining == 0) { + return 0 + } + return synchronized(ioLock) { + readBuffer.read(destination).also { + if (it != -1) { + position += it + } + } + } + } + + protected open fun onReadAsync( + position: Long, + size: Int, + timeoutMillis: Long + ): Future = + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.async(Dispatchers.IO) { + withTimeout(timeoutMillis) { + runInterruptible { + onRead(position, size) + } + } + } + .asFuture() + + @Throws(IOException::class) + protected open fun onRead(position: Long, size: Int): ByteBuffer { + throw NotImplementedError() + } + + @Throws(IOException::class) + final override fun write(source: ByteBuffer): Int { + ensureOpen() + val remaining = source.remaining() + if (remaining == 0) { + return 0 + } + synchronized(ioLock) { + if (isAppend) { + onAppend(source) + position = onSize() + } else { + onWrite(position, source) + position += remaining - source.remaining() + } + return remaining + } + } + + @Throws(IOException::class) + protected abstract fun onWrite(position: Long, source: ByteBuffer) + + @Throws(IOException::class) + protected open fun onAppend(source: ByteBuffer) { + val position = onSize() + onWrite(position, source) + } + + @Throws(IOException::class) + final override fun position(): Long { + ensureOpen() + synchronized(ioLock) { + if (isAppend) { + position = onSize() + } + return position + } + } + + final override fun position(newPosition: Long): SeekableByteChannel { + ensureOpen() + if (isAppend) { + // Ignored. + return this + } + synchronized(ioLock) { + readBuffer.reposition(position, newPosition) + position = newPosition + } + return this + } + + @Throws(IOException::class) + final override fun size(): Long { + ensureOpen() + return onSize() + } + + @Throws(IOException::class) + final override fun truncate(size: Long): SeekableByteChannel { + ensureOpen() + require(size >= 0) + synchronized(ioLock) { + val currentSize = onSize() + if (size >= currentSize) { + return this + } + onTruncate(size) + position = position.coerceAtMost(size) + } + return this + } + + @Throws(IOException::class) + protected abstract fun onTruncate(size: Long) + + @Throws(IOException::class) + protected abstract fun onSize(): Long + + @Throws(IOException::class) + final override fun force(metaData: Boolean) { + ensureOpen() + synchronized(ioLock) { + onForce(metaData) + } + } + + @Throws(IOException::class) + protected open fun onForce(metaData: Boolean) {} + + @Throws(ClosedChannelException::class) + private fun ensureOpen() { + synchronized(closeLock) { + if (!isOpen) { + throw ClosedChannelException() + } + } + } + + final override fun isOpen(): Boolean = synchronized(closeLock) { isOpen } + + @Throws(IOException::class) + final override fun close() { + synchronized(closeLock) { + if (!isOpen) { + return + } + isOpen = false + synchronized(ioLock) { + readBuffer.closeSafe() + onClose() + } + } + } + + protected fun setClosed() { + synchronized(closeLock) { + isOpen = false + } + } + + @Throws(IOException::class) + protected open fun onClose() {} + + private inner class ReadBuffer : Closeable { + private val buffer = ByteBuffer.allocate(BUFFER_SIZE).apply { limit(0) } + private var bufferedPosition = 0L + + private var pendingRead: Future? = null + private val pendingReadLock = Any() + + @Throws(IOException::class) + fun read(destination: ByteBuffer): Int { + if (!buffer.hasRemaining()) { + readIntoBuffer() + if (!buffer.hasRemaining()) { + return -1 + } + } + val length = destination.remaining().coerceAtMost(buffer.remaining()) + val bufferLimit = buffer.limit() + buffer.limit(buffer.position() + length) + destination.put(buffer) + buffer.limit(bufferLimit) + return length + } + + @Throws(IOException::class) + private fun readIntoBuffer() { + val future = synchronized(pendingReadLock) { + pendingRead?.also { pendingRead = null } + } ?: readIntoBufferAsync() + val newBuffer = try { + future.get() + } catch (e: CancellationException) { + throw InterruptedIOException().apply { initCause(e) } + } catch (e: InterruptedException) { + throw InterruptedIOException().apply { initCause(e) } + } catch (e: ExecutionException) { + val exception = e.cause ?: e + if (exception is IOException) { + throw exception + } else { + throw IOException(exception) + } + } + buffer.clear() + buffer.put(newBuffer) + buffer.flip() + if (!buffer.hasRemaining()) { + return + } + bufferedPosition += buffer.remaining() + synchronized(pendingReadLock) { + pendingRead = readIntoBufferAsync() + } + } + + private fun readIntoBufferAsync(): Future = + onReadAsync(bufferedPosition, BUFFER_SIZE, TIMEOUT_MILLIS) + + fun reposition(oldPosition: Long, newPosition: Long) { + if (newPosition == oldPosition) { + return + } + val newBufferPosition = buffer.position() + (newPosition - oldPosition) + if (newBufferPosition in 0..buffer.limit()) { + buffer.position(newBufferPosition.toInt()) + } else { + cancelPendingRead() + buffer.limit(0) + bufferedPosition = newPosition + } + } + + override fun close() { + cancelPendingRead() + } + + private fun cancelPendingRead() { + synchronized(pendingReadLock) { + pendingRead?.let { + if (shouldCancelRead) { + it.cancel(true) + if (joinCancelledRead) { + try { + it.get() + } catch (e: Exception) { + // Ignored + } + } + } + pendingRead = null + } + } + } + } + + companion object { + private const val BUFFER_SIZE = 1024 * 1024 + private const val TIMEOUT_MILLIS = 15_000L + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractPath.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractPath.kt index 8d73bd2f1..b2b6151c8 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractPath.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/AbstractPath.kt @@ -17,13 +17,6 @@ abstract class AbstractPath> : CovariantPath { return if (nameCount != 0) getName(nameCount - 1) else null } - override fun getParent(): T? = - when (val nameCount = nameCount) { - 0 -> null - 1 -> root - else -> root!!.resolve(subpath(0, nameCount - 1)) - } - override fun startsWith(other: String): Boolean = startsWith(fileSystem.getPath(other)) override fun endsWith(other: String): Boolean = endsWith(fileSystem.getPath(other)) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/ByteBufferExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/ByteBufferExtensions.kt new file mode 100644 index 000000000..6c99cefa1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/ByteBufferExtensions.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import java.nio.ByteBuffer +import kotlin.reflect.KClass + +private val EMPTY_BYTE_BUFFER = ByteBuffer.allocate(0) + +val KClass.EMPTY: ByteBuffer + get() = EMPTY_BYTE_BUFFER diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/ByteBufferInputStream.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/ByteBufferInputStream.kt new file mode 100644 index 000000000..5fe347463 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/ByteBufferInputStream.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer + +class ByteBufferInputStream(buffer: ByteBuffer) : InputStream() { + private var buffer: ByteBuffer? = buffer + + override fun read(): Int { + val buffer = ensureOpen() + return if (buffer.hasRemaining()) buffer.get().toInt() and 0xFF else -1 + } + + override fun read(bytes: ByteArray, offset: Int, length: Int): Int { + val buffer = ensureOpen() + if (length == 0) { + return 0 + } + val remaining = buffer.remaining() + if (remaining == 0) { + return -1 + } + val readLength = length.coerceAtMost(remaining) + buffer.get(bytes, offset, readLength) + return readLength + } + + override fun skip(length: Long): Long { + val buffer = ensureOpen() + if (length <= 0) { + return 0 + } + val skippedLength = length.toInt().coerceAtMost(buffer.remaining()) + buffer.position(buffer.position() + skippedLength) + return skippedLength.toLong() + } + + override fun available(): Int { + val buffer = ensureOpen() + return buffer.remaining() + } + + override fun markSupported(): Boolean = true + + override fun mark(readlimit: Int) { + val buffer = ensureOpen() + buffer.mark() + } + + override fun reset() { + val buffer = ensureOpen() + buffer.reset() + } + + override fun close() { + buffer = null + } + + private fun ensureOpen(): ByteBuffer = buffer ?: throw IOException("Stream closed") +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/ByteString.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/ByteString.kt index 0dcf6ae9b..96de54d4c 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/ByteString.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/ByteString.kt @@ -54,7 +54,7 @@ class ByteString internal constructor( fun endsWith(suffix: ByteString): Boolean = startsWith(suffix, length - suffix.length) fun indexOf(byte: Byte, fromIndex: Int = 0): Int { - for (index in fromIndex.coerceAtLeast(0) until length) { + for (index in fromIndex.coerceAtLeast(0)..> : AbstractPath, Parcelable { protected val separator: Byte @@ -76,6 +77,9 @@ abstract class ByteStringListPath> : AbstractPath, val fileNameByteString: ByteString? get() = segments.lastOrNull() + override fun getParent(): T? = + if (segments.isNotEmpty()) createPath(isAbsolute, segments.dropLast(1)) else null + override fun getNameCount(): Int = segments.size override fun getName(index: Int): T = createPath(false, listOf(getNameByteString(index))) @@ -91,7 +95,8 @@ abstract class ByteStringListPath> : AbstractPath, if (this === other) { return true } - if (other.javaClass != javaClass) { + if (javaClass != other.javaClass || provider != other.provider + || fileSystem != other.fileSystem) { return false } other as ByteStringListPath<*> @@ -104,7 +109,8 @@ abstract class ByteStringListPath> : AbstractPath, if (this === other) { return true } - if (javaClass != other.javaClass) { + if (javaClass != other.javaClass || provider != other.provider + || fileSystem != other.fileSystem) { return false } other as ByteStringListPath<*> @@ -127,8 +133,7 @@ abstract class ByteStringListPath> : AbstractPath, if (normalizedSegments.last() == BYTE_STRING_DOT_DOT) { normalizedSegments += segment } else { - @OptIn(ExperimentalStdlibApi::class) - normalizedSegments.removeLast() + normalizedSegments.removeLastCompat() } } } else { @@ -142,8 +147,14 @@ abstract class ByteStringListPath> : AbstractPath, } override fun resolve(other: Path): T { + if (javaClass != other.javaClass || provider != other.provider) { + throw ProviderMismatchException(other.toString()) + } @Suppress("UNCHECKED_CAST") - other as? T ?: throw ProviderMismatchException(other.toString()) + other as T + require(fileSystem == other.fileSystem) { + "The other path must have the same file system as this path" + } if (other.isAbsolute) { return other } @@ -163,9 +174,15 @@ abstract class ByteStringListPath> : AbstractPath, fun resolveSibling(other: ByteString): T = resolveSibling(createPath(other)) override fun relativize(other: Path): T { + if (javaClass != other.javaClass || provider != other.provider) { + throw ProviderMismatchException(other.toString()) + } @Suppress("UNCHECKED_CAST") - other as? T ?: throw ProviderMismatchException(other.toString()) - require(other.isAbsolute == isAbsolute) { + other as T + require(fileSystem == other.fileSystem) { + "The other path must have the same file system as this path" + } + require(isAbsolute == other.isAbsolute) { "The other path must be as absolute as this path" } if (isEmpty) { @@ -245,7 +262,12 @@ abstract class ByteStringListPath> : AbstractPath, override fun hashCode(): Int = hash(separator, segments, isAbsolute, fileSystem) override fun compareTo(other: Path): Int { - other as? ByteStringListPath<*> ?: throw ProviderMismatchException(other.toString()) + javaClass.cast(other) + @Suppress("UNCHECKED_CAST") + other as T + if (provider != other.provider) { + throw ClassCastException(other.toString()) + } return toByteString().compareTo(other.toByteString()) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/EncryptedFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/EncryptedFileAttributes.kt new file mode 100644 index 000000000..1b939c312 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/EncryptedFileAttributes.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import java8.nio.file.attribute.BasicFileAttributes + +interface EncryptedFileAttributes { + fun isEncrypted(): Boolean +} + +fun BasicFileAttributes.isEncrypted(): Boolean = + if (this is EncryptedFileAttributes) isEncrypted() else false diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/FileChannelExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/FileChannelExtensions.kt index aedb06113..f9f8f4903 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/FileChannelExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/FileChannelExtensions.kt @@ -9,8 +9,8 @@ import android.os.ParcelFileDescriptor import java8.nio.channels.FileChannel import java8.nio.channels.FileChannels import me.zhanghai.android.files.compat.NioUtilsCompat +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import java.io.Closeable import java.io.FileDescriptor import java.io.IOException @@ -19,7 +19,7 @@ import kotlin.reflect.KClass fun KClass.open(fd: FileDescriptor, flags: Int): FileChannel { val closeable = Closeable { try { - Syscalls.close(fd) + Syscall.close(fd) } catch (e: SyscallException) { throw IOException(e) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/FileSystemCache.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/FileSystemCache.kt index dd80007f9..eb84d8638 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/FileSystemCache.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/FileSystemCache.kt @@ -10,7 +10,7 @@ import java8.nio.file.FileSystemAlreadyExistsException import java8.nio.file.FileSystemNotFoundException import java.lang.ref.WeakReference -class FileSystemCache { +class FileSystemCache { private val fileSystems: MutableMap> = HashMap() private val lock = Any() diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeExtensions.kt new file mode 100644 index 000000000..850b3ce36 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeExtensions.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import java.time.Instant +import java8.nio.file.attribute.FileTime +import kotlin.reflect.KClass + +val KClass.EPOCH: FileTime + get() = FileTime.from(Instant.EPOCH) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeParceler.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeParceler.kt index 84b146589..acce6de17 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeParceler.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/FileTimeParceler.kt @@ -6,10 +6,10 @@ package me.zhanghai.android.files.provider.common import android.os.Parcel +import java.time.Instant import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parceler import me.zhanghai.android.files.compat.readSerializableCompat -import org.threeten.bp.Instant object FileTimeParceler : Parceler { override fun create(parcel: Parcel): FileTime? = diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt index 74ae90e99..6ecbbcd6b 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt @@ -15,6 +15,7 @@ import java8.nio.file.StandardCopyOption import java8.nio.file.StandardOpenOption import java8.nio.file.attribute.BasicFileAttributeView import java8.nio.file.attribute.BasicFileAttributes +import java8.nio.file.attribute.FileTime import java.io.IOException internal object ForeignCopyMove { @@ -102,12 +103,17 @@ internal object ForeignCopyMove { // now on. val targetAttributeView = target.getFileAttributeView(BasicFileAttributeView::class.java)!! val lastModifiedTime = sourceAttributes.lastModifiedTime() + .takeIf { it != FileTime::class.EPOCH } val lastAccessTime = if (copyOptions.copyAttributes) { - sourceAttributes.lastAccessTime() + sourceAttributes.lastAccessTime().takeIf { it != FileTime::class.EPOCH } + } else { + null + } + val creationTime = if (copyOptions.copyAttributes) { + sourceAttributes.creationTime().takeIf { it != FileTime::class.EPOCH } } else { null } - val creationTime = if (copyOptions.copyAttributes) sourceAttributes.creationTime() else null try { targetAttributeView.setTimes(lastModifiedTime, lastAccessTime, creationTime) } catch (e: IOException) { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/FutureExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/FutureExtensions.kt new file mode 100644 index 000000000..2af8a435e --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/FutureExtensions.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import net.schmizz.concurrent.Promise +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +// The following causes a NoSuchFieldError during runtime, similar to +// https://youtrack.jetbrains.com/issue/KT-20245/ +//inline fun Future.map( +// crossinline transform: (T) -> R, +// crossinline transformException: (Exception) -> Exception = { it } +fun Future.map( + transform: (T) -> R, + transformException: (Exception) -> Exception = { it } +): Future = + object : Future { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = + this@map.cancel(mayInterruptIfRunning) + + override fun isCancelled(): Boolean = this@map.isCancelled + + override fun isDone(): Boolean = this@map.isDone + + @Throws(ExecutionException::class, InterruptedException::class) + override fun get(): R = transformGet { this@map.get() } + + @Throws(ExecutionException::class, InterruptedException::class, TimeoutException::class) + override fun get(timeout: Long, unit: TimeUnit): R = + transformGet { this@map.get(timeout, unit) } + + @Throws(ExecutionException::class, InterruptedException::class, TimeoutException::class) + private inline fun transformGet(get: () -> T): R { + val result = try { + get() + } catch (e: Exception) { + val exception = try { + transformException(e) + } catch (e2: Exception) { + e2.addSuppressed(e) + throw ExecutionException(e2) + } + check( + exception is ExecutionException || exception is InterruptedException || + exception is TimeoutException + ) + throw exception + } + try { + return transform(result) + } catch (e: Exception) { + throw ExecutionException(e) + } + } + } + +fun Deferred.asFuture(): Future = + object : Future { + private val latch = CountDownLatch(1) + + init { + invokeOnCompletion { latch.countDown() } + } + + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + cancel() + return this@asFuture.isCancelled + } + + override fun isCancelled(): Boolean = this@asFuture.isCancelled + + override fun isDone(): Boolean = isCompleted + + @Throws(ExecutionException::class, InterruptedException::class) + override fun get(): T { + latch.await() + return getCompleted() + } + + @Throws(ExecutionException::class, InterruptedException::class, TimeoutException::class) + override fun get(timeout: Long, unit: TimeUnit): T { + latch.await(timeout, unit) + return getCompleted() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Throws(ExecutionException::class) + private fun getCompleted(): T = + try { + this@asFuture.getCompleted() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw ExecutionException(e) + } + } + +fun Promise.asFuture(): Future = + object : Future { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + + override fun isCancelled(): Boolean = false + + override fun isDone(): Boolean = isFulfilled + + @Throws(ExecutionException::class, InterruptedException::class) + override fun get(): T = tryRetrieve { retrieve() } + + @Throws(ExecutionException::class, InterruptedException::class, TimeoutException::class) + override fun get(timeout: Long, unit: TimeUnit?): T = + tryRetrieve { retrieve(timeout, unit) } + + @Throws(ExecutionException::class, InterruptedException::class, TimeoutException::class) + private inline fun tryRetrieve(retrieve: () -> T): T = + try { + retrieve() + } catch (e: Exception) { + when (val cause = e.cause) { + is InterruptedException -> { + Thread.interrupted() + throw cause + } + is TimeoutException -> throw cause + else -> throw ExecutionException(e) + } + } + } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/InputStreamExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/InputStreamExtensions.kt index eed9746a2..65267bcb4 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/InputStreamExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/InputStreamExtensions.kt @@ -10,8 +10,6 @@ import java.io.InputStream import java.io.InterruptedIOException import java.io.OutputStream -private const val BUFFER_SIZE = 8 * 1024 - // Can handle ProgressCopyOption. @Throws(IOException::class) fun InputStream.copyTo( @@ -19,7 +17,7 @@ fun InputStream.copyTo( intervalMillis: Long, listener: ((Long) -> Unit)? ) { - val buffer = ByteArray(BUFFER_SIZE) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var lastProgressMillis = System.currentTimeMillis() var copiedSize = 0L while (true) { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt index fd3d4ad84..c3d56b485 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt @@ -321,7 +321,8 @@ fun Path.readSymbolicLinkByteString(): ByteString { fun Path.resolveForeign(other: Path): Path { asByteStringListPath() other.asByteStringListPath() - if (provider == other.provider) { + if (javaClass == other.javaClass && provider == other.provider + && fileSystem == other.fileSystem) { return resolve(other) } if (other.isAbsolute) { @@ -330,9 +331,6 @@ fun Path.resolveForeign(other: Path): Path { if (other.isEmpty) { return this } - // TODO: kotlinc: None of the following functions can be called with the arguments supplied: - // public abstract fun resolve(p0: Path!): Path! defined in java8.nio.file.Path - // public abstract fun resolve(p0: String!): Path! defined in java8.nio.file.Path var result: ByteStringListPath<*> = this for (name in other.nameByteStrings) { result = result.resolve(name) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileMode.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileMode.kt index b4c4b8434..bbf3e2c1d 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileMode.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileMode.kt @@ -27,12 +27,25 @@ enum class PosixFileModeBit { } object PosixFileMode { + val CREATE_DIRECTORY_DEFAULT = fromInt( + OsConstants.S_IRWXU or OsConstants.S_IRWXG or OsConstants.S_IRWXO + ) + val CREATE_FILE_DEFAULT = fromInt( - OsConstants.S_IRUSR or OsConstants.S_IWUSR or OsConstants.S_IRGRP or OsConstants.S_IWGRP - or OsConstants.S_IROTH or OsConstants.S_IWOTH + OsConstants.S_IRUSR or OsConstants.S_IWUSR or OsConstants.S_IRGRP or OsConstants.S_IWGRP or + OsConstants.S_IROTH or OsConstants.S_IWOTH ) - val CREATE_DIRECTORY_DEFAULT = fromInt( + val DIRECTORY_DEFAULT = fromInt( + OsConstants.S_IRWXU or OsConstants.S_IRGRP or OsConstants.S_IXGRP or + OsConstants.S_IROTH or OsConstants.S_IXOTH + ) + + val FILE_DEFAULT = fromInt( + OsConstants.S_IRUSR or OsConstants.S_IWUSR or OsConstants.S_IRGRP or OsConstants.S_IROTH + ) + + val SYMBOLIC_LINK_DEFAULT = fromInt( OsConstants.S_IRWXU or OsConstants.S_IRWXG or OsConstants.S_IRWXO ) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileType.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileType.kt index eec607f96..faf405c27 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileType.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/PosixFileType.kt @@ -7,21 +7,17 @@ package me.zhanghai.android.files.provider.common import android.system.OsConstants import java8.nio.file.attribute.BasicFileAttributes -import org.apache.commons.compress.archivers.ArchiveEntry -import org.apache.commons.compress.archivers.dump.DumpArchiveEntry -import org.apache.commons.compress.archivers.tar.TarArchiveEntry -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry // https://www.gnu.org/software/libc/manual/html_node/Testing-File-Type.html -enum class PosixFileType { - UNKNOWN, - DIRECTORY, - CHARACTER_DEVICE, - BLOCK_DEVICE, - REGULAR_FILE, - FIFO, - SYMBOLIC_LINK, - SOCKET; +enum class PosixFileType(val mode: Int) { + UNKNOWN(0), + DIRECTORY(OsConstants.S_IFDIR), + CHARACTER_DEVICE(OsConstants.S_IFCHR), + BLOCK_DEVICE(OsConstants.S_IFBLK), + REGULAR_FILE(OsConstants.S_IFREG), + FIFO(OsConstants.S_IFIFO), + SYMBOLIC_LINK(OsConstants.S_IFLNK), + SOCKET((OsConstants.S_IFSOCK)); companion object { fun fromMode(mode: Int): PosixFileType = @@ -38,49 +34,6 @@ enum class PosixFileType { } } -val ArchiveEntry.posixFileType: PosixFileType - get() = - when (this) { - is DumpArchiveEntry -> posixFileType - is TarArchiveEntry -> posixFileType - is ZipArchiveEntry -> posixFileType - else -> if (isDirectory) PosixFileType.DIRECTORY else PosixFileType.REGULAR_FILE - } - -private val DumpArchiveEntry.posixFileType: PosixFileType - get() = - when (type) { - DumpArchiveEntry.TYPE.SOCKET -> PosixFileType.SOCKET - DumpArchiveEntry.TYPE.LINK -> PosixFileType.SYMBOLIC_LINK - DumpArchiveEntry.TYPE.FILE -> PosixFileType.REGULAR_FILE - DumpArchiveEntry.TYPE.BLKDEV -> PosixFileType.BLOCK_DEVICE - DumpArchiveEntry.TYPE.DIRECTORY -> PosixFileType.DIRECTORY - DumpArchiveEntry.TYPE.CHRDEV -> PosixFileType.CHARACTER_DEVICE - DumpArchiveEntry.TYPE.FIFO -> PosixFileType.FIFO - DumpArchiveEntry.TYPE.WHITEOUT, DumpArchiveEntry.TYPE.UNKNOWN -> PosixFileType.UNKNOWN - else -> PosixFileType.UNKNOWN - } - -private val TarArchiveEntry.posixFileType: PosixFileType - get() = - when { - isDirectory -> PosixFileType.DIRECTORY - isFile -> PosixFileType.REGULAR_FILE - isSymbolicLink -> PosixFileType.SYMBOLIC_LINK - isCharacterDevice -> PosixFileType.CHARACTER_DEVICE - isBlockDevice -> PosixFileType.BLOCK_DEVICE - isFIFO -> PosixFileType.FIFO - else -> PosixFileType.UNKNOWN - } - -private val ZipArchiveEntry.posixFileType: PosixFileType - get() = - when { - isDirectory -> PosixFileType.DIRECTORY - isUnixSymlink -> PosixFileType.SYMBOLIC_LINK - else -> PosixFileType.REGULAR_FILE - } - val BasicFileAttributes.posixFileType: PosixFileType get() = when (this) { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/UriAuthority.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/UriAuthority.kt index 65e6c73dc..312433654 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/UriAuthority.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/UriAuthority.kt @@ -16,7 +16,7 @@ data class UriAuthority( throw IllegalArgumentException(e) } // URI.getRawAuthority() returns null when authority is empty. - return uri.rawAuthority ?: "" + return uri.rawAuthority.orEmpty() } // toString() is called by UI when the URI may not be valid, so build the string manually. diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/UserActionRequiredException.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/UserActionRequiredException.kt new file mode 100644 index 000000000..32440ce4d --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/UserActionRequiredException.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.common + +import android.content.Context +import android.content.Intent +import java8.nio.file.FileSystemException +import kotlin.coroutines.Continuation + +abstract class UserActionRequiredException : FileSystemException { + constructor(file: String?) : super(file) + + constructor(file: String?, other: String?, reason: String?) : super(file, other, reason) + + abstract fun getUserAction(continuation: Continuation, context: Context): UserAction +} + +class UserAction( + val intent: Intent, + val title: String, + val message: String? +) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileAttributes.kt index bbe0e60d1..52786fc2a 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileAttributes.kt @@ -11,8 +11,8 @@ import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.provider.common.AbstractContentProviderFileAttributes +import me.zhanghai.android.files.provider.common.EPOCH import me.zhanghai.android.files.provider.common.FileTimeParceler -import org.threeten.bp.Instant @Parcelize internal class ContentFileAttributes( @@ -23,7 +23,7 @@ internal class ContentFileAttributes( ) : AbstractContentProviderFileAttributes() { companion object { fun from(mimeType: String?, size: Long, uri: Uri): ContentFileAttributes { - val lastModifiedTime = FileTime.from(Instant.EPOCH) + val lastModifiedTime = FileTime::class.EPOCH val fileKey = uri return ContentFileAttributes(lastModifiedTime, mimeType, size, fileKey) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileSystem.kt index c09219ba9..e6834598f 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileSystem.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/content/ContentFileSystem.kt @@ -31,9 +31,7 @@ internal class ContentFileSystem(private val provider: ContentFileSystemProvider override fun isReadOnly(): Boolean = false - override fun getSeparator(): String { - throw UnsupportedOperationException() - } + override fun getSeparator(): String = SEPARATOR_STRING override fun getRootDirectories(): Iterable = emptyList() @@ -80,6 +78,9 @@ internal class ContentFileSystem(private val provider: ContentFileSystemProvider override fun writeToParcel(dest: Parcel, flags: Int) {} companion object { + const val SEPARATOR = '/'.code.toByte() + private const val SEPARATOR_STRING = SEPARATOR.toInt().toChar().toString() + @JvmField val CREATOR = object : Parcelable.Creator { override fun createFromParcel(source: Parcel): ContentFileSystem = diff --git a/app/src/main/java/me/zhanghai/android/files/provider/content/ContentPath.kt b/app/src/main/java/me/zhanghai/android/files/provider/content/ContentPath.kt index 5fd4f1cda..ab1480fca 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/content/ContentPath.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/content/ContentPath.kt @@ -21,6 +21,7 @@ import me.zhanghai.android.files.provider.common.UriAuthority import me.zhanghai.android.files.provider.common.toByteString import me.zhanghai.android.files.provider.content.resolver.Resolver import me.zhanghai.android.files.provider.content.resolver.ResolverException +import me.zhanghai.android.files.util.StableUriParceler import me.zhanghai.android.files.util.readParcelable import java.io.File import java.net.URI @@ -31,14 +32,15 @@ internal class ContentPath : ByteStringListPath { val uri: Uri? constructor(fileSystem: ContentFileSystem, uri: Uri) : super( - 0.toByte(), true, listOf(uri.displayNameOrUri) + ContentFileSystem.SEPARATOR, true, + listOf(Uri.encode(uri.toString()).toByteString(), uri.bestFileName) ) { this.fileSystem = fileSystem this.uri = uri } private constructor(fileSystem: ContentFileSystem, segments: List) : super( - 0.toByte(), false, segments + ContentFileSystem.SEPARATOR, false, segments ) { this.fileSystem = fileSystem uri = null @@ -51,13 +53,14 @@ internal class ContentPath : ByteStringListPath { override fun createPath(path: ByteString): ContentPath = ContentPath(fileSystem, path.toString().toUri()) - override fun createPath(absolute: Boolean, segments: List): ContentPath = + override fun createPath(absolute: Boolean, segments: List): ContentPath { if (absolute) { - require(segments.size == 1) { segments.toString() } - createPath(segments.single()) - } else { - ContentPath(fileSystem, segments) + require(segments.size == 2) { + "Cannot create absolute ContentPath with segments $segments" + } } + return ContentPath(fileSystem, segments) + } override val uriScheme: String get() { @@ -88,9 +91,11 @@ internal class ContentPath : ByteStringListPath { override fun getRoot(): ContentPath? = null + override fun getParent(): ContentPath? = null + override fun normalize(): ContentPath = this - override fun toUri(): URI = URI.create(uri.toString()) + override fun toUri(): URI = URI.create(uri!!.toString()) override fun toAbsolutePath(): ContentPath { if (!isAbsolute) { @@ -135,25 +140,27 @@ internal class ContentPath : ByteStringListPath { private constructor(source: Parcel) : super(source) { fileSystem = source.readParcelable()!! - uri = source.readParcelable() + //uri = source.readParcelable() + uri = StableUriParceler.create(source) } override fun writeToParcel(dest: Parcel, flags: Int) { super.writeToParcel(dest, flags) dest.writeParcelable(fileSystem, flags) - dest.writeParcelable(uri, flags) + //dest.writeParcelable(uri, flags) + with(StableUriParceler) { uri.write(dest, flags) } } companion object { - private val Uri.displayNameOrUri: ByteString + private val Uri.bestFileName: ByteString get() = (try { Resolver.getDisplayName(this) } catch (e: ResolverException) { e.printStackTrace() null - } ?: lastPathSegment ?: toString()).toByteString() + } ?: lastPathSegment ?: "file").toByteString() @JvmField val CREATOR = object : Parcelable.Creator { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributeView.kt index a2b9af134..fd32488c6 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributeView.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributeView.kt @@ -10,7 +10,6 @@ import android.os.Parcelable import android.provider.DocumentsContract import java8.nio.file.attribute.BasicFileAttributeView import java8.nio.file.attribute.FileTime -import me.zhanghai.android.files.compat.use import me.zhanghai.android.files.provider.content.resolver.ResolverException import me.zhanghai.android.files.provider.content.resolver.getInt import me.zhanghai.android.files.provider.content.resolver.getLong diff --git a/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributes.kt index 15fa15be0..69cfad349 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileAttributes.kt @@ -7,12 +7,12 @@ package me.zhanghai.android.files.provider.document import android.net.Uri import android.os.Parcelable +import java.time.Instant import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.provider.common.AbstractContentProviderFileAttributes import me.zhanghai.android.files.provider.common.FileTimeParceler -import org.threeten.bp.Instant @Parcelize internal class DocumentFileAttributes( diff --git a/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileSystem.kt index c515d9178..452c84639 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileSystem.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/document/DocumentFileSystem.kt @@ -19,7 +19,7 @@ import me.zhanghai.android.files.provider.common.ByteString import me.zhanghai.android.files.provider.common.ByteStringBuilder import me.zhanghai.android.files.provider.common.ByteStringListPathCreator import me.zhanghai.android.files.provider.common.toByteString -import me.zhanghai.android.files.util.readParcelable +import me.zhanghai.android.files.util.StableUriParceler import java.io.IOException internal class DocumentFileSystem( @@ -116,7 +116,8 @@ internal class DocumentFileSystem( override fun describeContents(): Int = 0 override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeParcelable(treeUri, flags) + //dest.writeParcelable(treeUri, flags) + with(StableUriParceler) { treeUri.write(dest, flags) } } companion object { @@ -127,7 +128,8 @@ internal class DocumentFileSystem( @JvmField val CREATOR = object : Parcelable.Creator { override fun createFromParcel(source: Parcel): DocumentFileSystem { - val treeUri = source.readParcelable()!! + //val treeUri = source.readParcelable()!! + val treeUri = StableUriParceler.create(source)!! return DocumentFileSystemProvider.getOrNewFileSystem(treeUri) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/document/resolver/ExternalStorageProviderPrimaryAndroidDataHack.kt b/app/src/main/java/me/zhanghai/android/files/provider/document/resolver/ExternalStorageProviderHacks.kt similarity index 100% rename from app/src/main/java/me/zhanghai/android/files/provider/document/resolver/ExternalStorageProviderPrimaryAndroidDataHack.kt rename to app/src/main/java/me/zhanghai/android/files/provider/document/resolver/ExternalStorageProviderHacks.kt diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpCopyMove.kt index cab944ecc..cc9591cc5 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpCopyMove.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpCopyMove.kt @@ -8,7 +8,6 @@ package me.zhanghai.android.files.provider.ftp import java8.nio.file.FileAlreadyExistsException import java8.nio.file.NoSuchFileException import java8.nio.file.StandardCopyOption -import me.zhanghai.android.files.compat.toInstantCompat import me.zhanghai.android.files.provider.common.CopyOptions import me.zhanghai.android.files.provider.common.copyTo import me.zhanghai.android.files.provider.ftp.client.Client @@ -79,7 +78,7 @@ internal object FtpCopyMove { try { targetOutputStream.close() } catch (e: IOException) { - throw IOException(e).toFileSystemExceptionForFtp(target.toString()) + throw e.toFileSystemExceptionForFtp(target.toString()) } finally { if (!successful) { try { @@ -94,7 +93,7 @@ internal object FtpCopyMove { try { sourceInputStream.close() } catch (e: IOException) { - throw IOException(e).toFileSystemExceptionForFtp(source.toString()) + throw e.toFileSystemExceptionForFtp(source.toString()) } } } @@ -105,7 +104,7 @@ internal object FtpCopyMove { val timestamp = sourceFile.timestamp if (timestamp != null) { try { - Client.setLastModifiedTime(target, sourceFile.timestamp.toInstantCompat()) + Client.setLastModifiedTime(target, timestamp.toInstant()) } catch (e: IOException) { e.printStackTrace() } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileAttributes.kt index fce1f253d..7b790a909 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileAttributes.kt @@ -6,15 +6,14 @@ package me.zhanghai.android.files.provider.ftp import android.os.Parcelable +import java.time.Instant import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith -import me.zhanghai.android.files.compat.toInstantCompat import me.zhanghai.android.files.provider.common.AbstractBasicFileAttributes import me.zhanghai.android.files.provider.common.BasicFileType import me.zhanghai.android.files.provider.common.FileTimeParceler import org.apache.commons.net.ftp.FTPFile -import org.threeten.bp.Instant @Parcelize internal data class FtpFileAttributes( @@ -27,7 +26,7 @@ internal data class FtpFileAttributes( ) : AbstractBasicFileAttributes() { companion object { fun from(file: FTPFile, path: FtpPath): FtpFileAttributes { - val lastModifiedTime = FileTime.from(file.timestamp?.toInstantCompat() ?: Instant.EPOCH) + val lastModifiedTime = FileTime.from(file.timestamp?.toInstant() ?: Instant.EPOCH) val lastAccessTime = lastModifiedTime val creationTime = lastModifiedTime val type = when { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileSystemProvider.kt index 67d27f467..570a856b6 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/FtpFileSystemProvider.kt @@ -110,10 +110,10 @@ object FtpFileSystemProvider : FileSystemProvider(), PathObservableProvider, Sea get() { val protocol = Protocol.fromScheme(scheme) val port = if (port != -1) port else protocol.defaultPort - val username = userInfo ?: "" + val username = userInfo.orEmpty() val queryUri = decodedQueryByteString?.toString()?.let { Uri.parse(it) } val mode = queryUri?.getQueryParameter(FtpPath.QUERY_PARAMETER_MODE) - ?.let { mode -> Mode.values().first { it.name.equals(mode, true) } } + ?.let { mode -> Mode.entries.first { it.name.equals(mode, true) } } ?: Authority.DEFAULT_MODE val encoding = queryUri?.getQueryParameter(FtpPath.QUERY_PARAMETER_ENCODING) ?: Authority.DEFAULT_ENCODING @@ -140,14 +140,14 @@ object FtpFileSystemProvider : FileSystemProvider(), PathObservableProvider, Sea } catch (e: IOException) { throw e.toFileSystemExceptionForFtp(file.toString()) } + if (openOptions.createNew && fileFile != null) { + throw FileAlreadyExistsException(file.toString()) + } if (openOptions.noFollowLinks && fileFile != null && fileFile.isSymbolicLink) { throw FileSystemException( file.toString(), null, "File is a symbolic link: $fileFile" ) } - if (openOptions.createNew && fileFile != null) { - throw FileAlreadyExistsException(file.toString()) - } if ((openOptions.create || openOptions.createNew) && fileFile == null) { try { Client.createFile(file) @@ -221,6 +221,32 @@ object FtpFileSystemProvider : FileSystemProvider(), PathObservableProvider, Sea if (openOptions.write && !openOptions.truncateExisting) { throw UnsupportedOperationException("Missing ${StandardOpenOption.TRUNCATE_EXISTING}") } + if (openOptions.write || openOptions.create || openOptions.createNew || + openOptions.noFollowLinks) { + val fileFile = try { + Client.listFileOrNull(file, true) + } catch (e: IOException) { + throw e.toFileSystemExceptionForFtp(file.toString()) + } + if (openOptions.createNew && fileFile != null) { + throw FileAlreadyExistsException(file.toString()) + } + if (openOptions.noFollowLinks && fileFile != null && fileFile.isSymbolicLink) { + throw FileSystemException( + file.toString(), null, "File is a symbolic link: $fileFile" + ) + } + if (fileFile == null) { + if (!(openOptions.create || openOptions.createNew)) { + throw NoSuchFileException(file.toString()) + } + try { + Client.createFile(file) + } catch (e: IOException) { + throw e.toFileSystemExceptionForFtp(file.toString()) + } + } + } if (attributes.isNotEmpty()) { throw UnsupportedOperationException(attributes.contentToString()) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Client.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Client.kt index 73b190e89..d91152d1d 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Client.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Client.kt @@ -5,6 +5,17 @@ package me.zhanghai.android.files.provider.ftp.client +import java8.nio.file.Path as Java8Path +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.time.Instant +import java.time.ZoneOffset +import java.time.chrono.IsoChronology +import java.time.format.DateTimeFormatter +import java.util.Collections +import java.util.Locale +import java.util.WeakHashMap import java8.nio.channels.SeekableByteChannel import me.zhanghai.android.files.provider.common.DelegateInputStream import me.zhanghai.android.files.provider.common.DelegateOutputStream @@ -17,17 +28,6 @@ import org.apache.commons.net.ftp.FTPCmd import org.apache.commons.net.ftp.FTPFile import org.apache.commons.net.ftp.FTPReply import org.apache.commons.net.ftp.FTPSClient -import org.threeten.bp.Instant -import org.threeten.bp.ZoneOffset -import org.threeten.bp.chrono.IsoChronology -import org.threeten.bp.format.DateTimeFormatter -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.Collections -import java.util.Locale -import java.util.WeakHashMap -import java8.nio.file.Path as Java8Path object Client { private val TIMESTAMP_FORMATTER = diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/FileByteChannel.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/FileByteChannel.kt index 0ee8962a7..aaa12dc5b 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/FileByteChannel.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/FileByteChannel.kt @@ -5,150 +5,82 @@ package me.zhanghai.android.files.provider.ftp.client -import java8.nio.channels.SeekableByteChannel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withTimeout -import me.zhanghai.android.files.provider.common.ForceableChannel +import me.zhanghai.android.files.compat.nullInputStream +import me.zhanghai.android.files.provider.common.AbstractFileByteChannel +import me.zhanghai.android.files.provider.common.ByteBufferInputStream import me.zhanghai.android.files.provider.common.readFully -import me.zhanghai.android.files.util.closeSafe import org.apache.commons.net.ftp.FTPClient -import java.io.ByteArrayInputStream -import java.io.Closeable import java.io.IOException -import java.io.InterruptedIOException +import java.io.InputStream import java.nio.ByteBuffer -import java.nio.channels.ClosedChannelException -import java.nio.channels.NonReadableChannelException class FileByteChannel( private val client: FTPClient, private val releaseClient: (FTPClient) -> Unit, private val path: String, - private val isAppend: Boolean -) : ForceableChannel, SeekableByteChannel { + isAppend: Boolean +) : AbstractFileByteChannel(isAppend, joinCancelledRead = true) { private val clientLock = Any() - private var position = 0L - private val readBuffer = ReadBuffer() - private val ioLock = Any() - - private var isOpen = true - private val closeLock = Any() - @Throws(IOException::class) - override fun read(destination: ByteBuffer): Int { - ensureOpen() - if (isAppend) { - throw NonReadableChannelException() - } - val remaining = destination.remaining() - if (remaining == 0) { - return 0 - } - return synchronized(ioLock) { - readBuffer.read(destination).also { - if (it != -1) { - position += it + override fun onRead(position: Long, size: Int): ByteBuffer { + val destination = ByteBuffer.allocate(size) + synchronized(clientLock) { + client.restartOffset = position + val inputStream = client.retrieveFileStream(path) + ?: client.throwNegativeReplyCodeException() + try { + val limit = inputStream.use { + it.readFully(destination.array(), destination.arrayOffset(), size) } + destination.limit(limit) + } finally { + // We will likely close the input stream before the file is fully + // read and it will result in a false return value here, but that's + // totally fine. + client.completePendingCommand() } } + return destination } @Throws(IOException::class) - override fun write(source: ByteBuffer): Int { - ensureOpen() - val remaining = source.remaining() - if (remaining == 0) { - return 0 - } - // I don't think we are using native or read-only ByteBuffer, so just call array() here. - synchronized(ioLock) { - if (isAppend) { - synchronized(clientLock) { - ByteArrayInputStream(source.array(), source.position(), remaining).use { - if (!client.appendFile(path, it)) { - client.throwNegativeReplyCodeException() - } - } - } - position = getSize() - } else { - synchronized(clientLock) { - client.restartOffset = position - ByteArrayInputStream(source.array(), source.position(), remaining).use { - if (!client.storeFile(path, it)) { - client.throwNegativeReplyCodeException() - } - } + override fun onWrite(position: Long, source: ByteBuffer) { + synchronized(clientLock) { + client.restartOffset = position + ByteBufferInputStream(source).use { + if (!client.storeFile(path, it)) { + client.throwNegativeReplyCodeException() } - position += remaining } - source.position(source.limit()) - return remaining } } @Throws(IOException::class) - override fun position(): Long { - ensureOpen() - synchronized(ioLock) { - if (isAppend) { - position = getSize() + override fun onAppend(source: ByteBuffer) { + synchronized(clientLock) { + ByteBufferInputStream(source).use { + if (!client.appendFile(path, it)) { + client.throwNegativeReplyCodeException() + } } - return position } } - override fun position(newPosition: Long): SeekableByteChannel { - ensureOpen() - if (isAppend) { - // Ignored. - return this - } - synchronized(ioLock) { - readBuffer.reposition(position, newPosition) - position = newPosition - } - return this - } - - @Throws(IOException::class) - override fun size(): Long { - ensureOpen() - return getSize() - } - @Throws(IOException::class) - override fun truncate(size: Long): SeekableByteChannel { - ensureOpen() - require(size >= 0) - synchronized(ioLock) { - val currentSize = getSize() - if (size >= currentSize) { - return this - } - synchronized(clientLock) { - client.restartOffset = size - ByteArrayInputStream(byteArrayOf()).use { - if (!client.storeFile(path, it)) { - client.throwNegativeReplyCodeException() - } + override fun onTruncate(size: Long) { + synchronized(clientLock) { + client.restartOffset = size + InputStream::class.nullInputStream().use { + if (!client.storeFile(path, it)) { + client.throwNegativeReplyCodeException() } } - position = position.coerceAtMost(size) } - return this } @Throws(IOException::class) - private fun getSize(): Long { + override fun onSize(): Long { val sizeString = synchronized(clientLock) { client.getSize(path) ?: client.throwNegativeReplyCodeException() } @@ -156,145 +88,7 @@ class FileByteChannel( } @Throws(IOException::class) - override fun force(metaData: Boolean) { - ensureOpen() - // Unsupported. - } - - @Throws(ClosedChannelException::class) - private fun ensureOpen() { - synchronized(closeLock) { - if (!isOpen) { - throw ClosedChannelException() - } - } - } - - override fun isOpen(): Boolean = synchronized(closeLock) { isOpen } - - @Throws(IOException::class) - override fun close() { - synchronized(closeLock) { - if (!isOpen) { - return - } - isOpen = false - synchronized(ioLock) { - readBuffer.closeSafe() - synchronized(clientLock) { releaseClient(client) } - } - } - } - - private inner class ReadBuffer : Closeable { - private val bufferSize = DEFAULT_BUFFER_SIZE - private val timeoutMillis = 15_000L - - private val buffer = ByteBuffer.allocate(bufferSize).apply { limit(0) } - private var bufferedPosition = 0L - - private var pendingDeferred: Deferred? = null - private val pendingDeferredLock = Any() - - @Throws(IOException::class) - fun read(destination: ByteBuffer): Int { - if (!buffer.hasRemaining()) { - readIntoBuffer() - if (!buffer.hasRemaining()) { - return -1 - } - } - val length = destination.remaining().coerceAtMost(buffer.remaining()) - val bufferLimit = buffer.limit() - buffer.limit(buffer.position() + length) - destination.put(buffer) - buffer.limit(bufferLimit) - return length - } - - @Throws(IOException::class) - private fun readIntoBuffer() { - val deferred = synchronized(pendingDeferredLock) { - pendingDeferred?.also { pendingDeferred = null } - } ?: readIntoBufferAsync() - val newBuffer = try { - runBlocking { deferred.await() } - } catch (e: CancellationException) { - throw InterruptedIOException().apply { initCause(e) } - } - buffer.clear() - buffer.put(newBuffer) - buffer.flip() - if (!buffer.hasRemaining()) { - return - } - bufferedPosition += buffer.remaining() - synchronized(pendingDeferredLock) { - pendingDeferred = readIntoBufferAsync() - } - } - - private fun readIntoBufferAsync(): Deferred = - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.async(Dispatchers.IO) { - withTimeout(timeoutMillis) { - runInterruptible { - synchronized(clientLock) { - client.restartOffset = bufferedPosition - val inputStream = client.retrieveFileStream(path) - ?: client.throwNegativeReplyCodeException() - try { - val buffer = ByteBuffer.allocate(bufferSize) - val limit = inputStream.use { - it.readFully( - buffer.array(), buffer.position(), buffer.remaining() - ) - } - buffer.limit(limit) - buffer - } finally { - // We will likely close the input stream before the file is fully - // read and it will result in a false return value here, but that's - // totally fine. - client.completePendingCommand() - } - } - } - } - } - - fun reposition(oldPosition: Long, newPosition: Long) { - if (newPosition == oldPosition) { - return - } - val newBufferPosition = buffer.position() + (newPosition - oldPosition) - if (newBufferPosition in 0..buffer.limit()) { - buffer.position(newBufferPosition.toInt()) - } else { - synchronized(pendingDeferredLock) { - pendingDeferred?.let { - it.cancel() - runBlocking { it.join() } - pendingDeferred = null - } - } - buffer.limit(0) - bufferedPosition = newPosition - } - } - - override fun close() { - synchronized(pendingDeferredLock) { - pendingDeferred?.let { - it.cancel() - runBlocking { it.join() } - pendingDeferred = null - } - } - } - } - - companion object { - private const val DEFAULT_BUFFER_SIZE = 1024 * 1024 + override fun onClose() { + synchronized(clientLock) { releaseClient(client) } } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Mode.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Mode.kt index 96d771890..95e9156f7 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Mode.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Mode.kt @@ -7,5 +7,5 @@ package me.zhanghai.android.files.provider.ftp.client enum class Mode { ACTIVE, - PASSIVE; + PASSIVE } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Protocol.kt b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Protocol.kt index b4026b380..48942a0a2 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Protocol.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/ftp/client/Protocol.kt @@ -14,9 +14,9 @@ enum class Protocol(val scheme: String, val defaultPort: Int, val createClient: FTPES("ftpes", FTPClient.DEFAULT_PORT, { FTPSClient(false) }); companion object { - val SCHEMES = values().map { it.scheme } + val SCHEMES = entries.map { it.scheme } fun fromScheme(scheme: String): Protocol = - values().firstOrNull() { it.scheme == scheme } ?: throw IllegalArgumentException(scheme) + entries.firstOrNull { it.scheme == scheme } ?: throw IllegalArgumentException(scheme) } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxCopyMove.kt index 6b4d42fd3..714160dbc 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxCopyMove.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxCopyMove.kt @@ -14,8 +14,8 @@ import me.zhanghai.android.files.provider.common.CopyOptions import me.zhanghai.android.files.provider.common.toByteString import me.zhanghai.android.files.provider.linux.syscall.Constants import me.zhanghai.android.files.provider.linux.syscall.StructTimespec +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import java.io.IOException import java.io.InterruptedIOException @@ -30,12 +30,12 @@ internal object LinuxCopyMove { throw UnsupportedOperationException(StandardCopyOption.ATOMIC_MOVE.toString()) } val sourceStat = try { - if (copyOptions.noFollowLinks) Syscalls.lstat(source) else Syscalls.stat(source) + if (copyOptions.noFollowLinks) Syscall.lstat(source) else Syscall.stat(source) } catch (e: SyscallException) { throw e.toFileSystemException(source.toString()) } val targetStat = try { - Syscalls.lstat(target) + Syscall.lstat(target) } catch (e: SyscallException) { if (e.errno != OsConstants.ENOENT) { throw e.toFileSystemException(target.toString()) @@ -56,7 +56,7 @@ internal object LinuxCopyMove { if (OsConstants.S_ISREG(sourceStat.st_mode)) { if (targetStat != null) { try { - Syscalls.remove(target) + Syscall.remove(target) } catch (e: SyscallException) { if (e.errno != OsConstants.ENOENT) { throw e.toFileSystemException(target.toString()) @@ -64,7 +64,7 @@ internal object LinuxCopyMove { } } val sourceFd = try { - Syscalls.open(source, OsConstants.O_RDONLY, 0) + Syscall.open(source, OsConstants.O_RDONLY, 0) } catch (e: SyscallException) { throw e.toFileSystemException(source.toString()) } @@ -75,7 +75,7 @@ internal object LinuxCopyMove { targetFlags = targetFlags or OsConstants.O_EXCL } val targetFd = try { - Syscalls.open(target, targetFlags, sourceStat.st_mode) + Syscall.open(target, targetFlags, sourceStat.st_mode) } catch (e: SyscallException) { e.maybeThrowInvalidFileNameException(target.toString()) throw e.toFileSystemException(target.toString()) @@ -88,7 +88,7 @@ internal object LinuxCopyMove { var copiedSize = 0L while (true) { val sentSize = try { - Syscalls.sendfile(targetFd, sourceFd, null, SEND_FILE_COUNT.toLong()) + Syscall.sendfile(targetFd, sourceFd, null, SEND_FILE_COUNT.toLong()) } catch (e: SyscallException) { throw e.toFileSystemException(source.toString(), target.toString()) } @@ -109,13 +109,13 @@ internal object LinuxCopyMove { successful = true } finally { try { - Syscalls.close(targetFd) + Syscall.close(targetFd) } catch (e: SyscallException) { throw e.toFileSystemException(target.toString()) } finally { if (!successful) { try { - Syscalls.remove(target) + Syscall.remove(target) } catch (e: SyscallException) { e.printStackTrace() } @@ -124,7 +124,7 @@ internal object LinuxCopyMove { } } finally { try { - Syscalls.close(sourceFd) + Syscall.close(sourceFd) } catch (e: SyscallException) { throw e.toFileSystemException(source.toString()) } @@ -132,7 +132,7 @@ internal object LinuxCopyMove { } else if (OsConstants.S_ISDIR(sourceStat.st_mode)) { if (targetStat != null) { try { - Syscalls.remove(target) + Syscall.remove(target) } catch (e: SyscallException) { if (e.errno != OsConstants.ENOENT) { throw e.toFileSystemException(target.toString()) @@ -140,7 +140,7 @@ internal object LinuxCopyMove { } } try { - Syscalls.mkdir(target, sourceStat.st_mode) + Syscall.mkdir(target, sourceStat.st_mode) } catch (e: SyscallException) { e.maybeThrowInvalidFileNameException(target.toString()) throw e.toFileSystemException(target.toString()) @@ -148,16 +148,16 @@ internal object LinuxCopyMove { copyOptions.progressListener?.invoke(sourceStat.st_size) } else if (OsConstants.S_ISLNK(sourceStat.st_mode)) { val sourceTarget = try { - Syscalls.readlink(source) + Syscall.readlink(source) } catch (e: SyscallException) { throw e.toFileSystemException(source.toString()) } try { - Syscalls.symlink(sourceTarget, target) + Syscall.symlink(sourceTarget, target) } catch (e: SyscallException) { if (e.errno == OsConstants.EEXIST && copyOptions.replaceExisting) { try { - Syscalls.remove(target) + Syscall.remove(target) } catch (e2: SyscallException) { if (e2.errno != OsConstants.ENOENT) { e2.addSuppressed(e.toFileSystemException(target.toString())) @@ -165,7 +165,7 @@ internal object LinuxCopyMove { } } try { - Syscalls.symlink(sourceTarget, target) + Syscall.symlink(sourceTarget, target) } catch (e2: SyscallException) { e2.addSuppressed(e.toFileSystemException(target.toString())) throw e2.toFileSystemException(target.toString()) @@ -184,14 +184,14 @@ internal object LinuxCopyMove { // setuid work properly. try { if (copyOptions.copyAttributes) { - Syscalls.lchown(target, sourceStat.st_uid, sourceStat.st_gid) + Syscall.lchown(target, sourceStat.st_uid, sourceStat.st_gid) } } catch (e: SyscallException) { e.printStackTrace() } try { if (!OsConstants.S_ISLNK(sourceStat.st_mode)) { - Syscalls.chmod(target, sourceStat.st_mode) + Syscall.chmod(target, sourceStat.st_mode) } } catch (e: SyscallException) { e.printStackTrace() @@ -205,19 +205,19 @@ internal object LinuxCopyMove { StructTimespec(0, Constants.UTIME_OMIT) }, sourceStat.st_mtim ) - Syscalls.lutimens(target, times) + Syscall.lutimens(target, times) } catch (e: SyscallException) { e.printStackTrace() } try { // TODO: Allow u+rw temporarily if we are to copy xattrs. - val xattrNames = Syscalls.llistxattr(source) + val xattrNames = Syscall.llistxattr(source) for (xattrName in xattrNames) { if (!(copyOptions.copyAttributes || xattrName.startsWith(XATTR_NAME_PREFIX_USER))) { continue } - val xattrValue = Syscalls.lgetxattr(target, xattrName) - Syscalls.lsetxattr(target, xattrName, xattrValue, 0) + val xattrValue = Syscall.lgetxattr(target, xattrName) + Syscall.lsetxattr(target, xattrName, xattrValue, 0) } } catch (e: SyscallException) { e.printStackTrace() @@ -234,12 +234,12 @@ internal object LinuxCopyMove { @Throws(IOException::class) fun move(source: ByteString, target: ByteString, copyOptions: CopyOptions) { val sourceStat = try { - Syscalls.lstat(source) + Syscall.lstat(source) } catch (e: SyscallException) { throw e.toFileSystemException(source.toString()) } val targetStat = try { - Syscalls.lstat(target) + Syscall.lstat(target) } catch (e: SyscallException) { if (e.errno != OsConstants.ENOENT) { throw e.toFileSystemException(target.toString()) @@ -256,14 +256,14 @@ internal object LinuxCopyMove { throw FileAlreadyExistsException(source.toString(), target.toString(), null) } try { - Syscalls.remove(target) + Syscall.remove(target) } catch (e: SyscallException) { throw e.toFileSystemException(target.toString()) } } var renameSuccessful = false try { - Syscalls.rename(source, target) + Syscall.rename(source, target) renameSuccessful = true } catch (e: SyscallException) { if (copyOptions.atomicMove) { @@ -289,11 +289,11 @@ internal object LinuxCopyMove { } copy(source, target, copyOptions) try { - Syscalls.remove(source) + Syscall.remove(source) } catch (e: SyscallException) { if (e.errno != OsConstants.ENOENT) { try { - Syscalls.remove(target) + Syscall.remove(target) } catch (e2: SyscallException) { e.addSuppressed(e2.toFileSystemException(target.toString())) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxDirectoryStream.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxDirectoryStream.kt index ce21cf780..598f406ec 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxDirectoryStream.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxDirectoryStream.kt @@ -9,8 +9,8 @@ import java8.nio.file.DirectoryIteratorException import java8.nio.file.DirectoryStream import java8.nio.file.Path import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import java.io.IOException internal class LinuxDirectoryStream( @@ -42,7 +42,7 @@ internal class LinuxDirectoryStream( return } try { - Syscalls.closedir(dir) + Syscall.closedir(dir) } catch (e: SyscallException) { throw e.toFileSystemException(directory.toString()) } @@ -81,7 +81,7 @@ internal class LinuxDirectoryStream( return null } val dirent = try { - Syscalls.readdir(dir) + Syscall.readdir(dir) } catch (e: SyscallException) { throw DirectoryIteratorException( e.toFileSystemException(directory.toString()) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxFileAttributes.kt index 003e9df01..a7f9c15c4 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxFileAttributes.kt @@ -6,6 +6,7 @@ package me.zhanghai.android.files.provider.linux import android.os.Parcelable +import java.time.Instant import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith @@ -18,7 +19,6 @@ import me.zhanghai.android.files.provider.common.PosixFileType import me.zhanghai.android.files.provider.common.PosixGroup import me.zhanghai.android.files.provider.common.PosixUser import me.zhanghai.android.files.provider.linux.syscall.StructStat -import org.threeten.bp.Instant @Parcelize internal class LinuxFileAttributes( diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxUserPrincipalLookupService.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxUserPrincipalLookupService.kt index e6813420f..3d1e78c27 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxUserPrincipalLookupService.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LinuxUserPrincipalLookupService.kt @@ -11,8 +11,8 @@ import me.zhanghai.android.files.provider.common.ByteString import me.zhanghai.android.files.provider.common.PosixGroup import me.zhanghai.android.files.provider.common.PosixUser import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import java.io.IOException internal object LinuxUserPrincipalLookupService : UserPrincipalLookupService() { @@ -23,7 +23,7 @@ internal object LinuxUserPrincipalLookupService : UserPrincipalLookupService() { @Throws(IOException::class) fun lookupPrincipalByName(name: ByteString): PosixUser { val passwd = try { - Syscalls.getpwnam(name) + Syscall.getpwnam(name) } catch (e: SyscallException) { throw e.toFileSystemException(null) } ?: throw UserPrincipalNotFoundException(name.toString()) @@ -40,7 +40,7 @@ internal object LinuxUserPrincipalLookupService : UserPrincipalLookupService() { @Throws(SyscallException::class) fun getUserById(uid: Int): PosixUser { - val passwd = Syscalls.getpwuid(uid) + val passwd = Syscall.getpwuid(uid) return PosixUser(uid, passwd?.pw_name) } @@ -51,7 +51,7 @@ internal object LinuxUserPrincipalLookupService : UserPrincipalLookupService() { @Throws(IOException::class) fun lookupPrincipalByGroupName(group: ByteString): PosixGroup { val groupStruct = try { - Syscalls.getgrnam(group) + Syscall.getgrnam(group) } catch (e: SyscallException) { throw e.toFileSystemException(null) } ?: throw UserPrincipalNotFoundException(group.toString()) @@ -68,7 +68,7 @@ internal object LinuxUserPrincipalLookupService : UserPrincipalLookupService() { @Throws(SyscallException::class) fun getGroupById(gid: Int): PosixGroup { - val group = Syscalls.getgrgid(gid) + val group = Syscall.getgrgid(gid) return PosixGroup(gid, group?.gr_name) } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileAttributeView.kt index 9dc6c9ad7..0f333a32d 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileAttributeView.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileAttributeView.kt @@ -15,8 +15,8 @@ import me.zhanghai.android.files.provider.common.PosixUser import me.zhanghai.android.files.provider.common.toInt import me.zhanghai.android.files.provider.linux.syscall.Constants import me.zhanghai.android.files.provider.linux.syscall.StructTimespec +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import java.io.IOException internal class LocalLinuxFileAttributeView( @@ -29,9 +29,9 @@ internal class LocalLinuxFileAttributeView( override fun readAttributes(): LinuxFileAttributes { val stat = try { if (noFollowLinks) { - Syscalls.lstat(path) + Syscall.lstat(path) } else { - Syscalls.stat(path) + Syscall.stat(path) } } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) @@ -52,9 +52,9 @@ internal class LocalLinuxFileAttributeView( } val seLinuxContext = try { if (noFollowLinks) { - Syscalls.lgetfilecon(path) + Syscall.lgetfilecon(path) } else { - Syscalls.getfilecon(path) + Syscall.getfilecon(path) } } catch (e: SyscallException) { // SELinux calls may fail with ENODATA or ENOTSUP, and there may be other errors. @@ -81,9 +81,9 @@ internal class LocalLinuxFileAttributeView( val times = arrayOf(lastAccessTime.toTimespec(), lastModifiedTime.toTimespec()) try { if (noFollowLinks) { - Syscalls.lutimens(path, times) + Syscall.lutimens(path, times) } else { - Syscalls.utimens(path, times) + Syscall.utimens(path, times) } } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) @@ -103,9 +103,9 @@ internal class LocalLinuxFileAttributeView( val uid = owner.id try { if (noFollowLinks) { - Syscalls.lchown(path, uid, -1) + Syscall.lchown(path, uid, -1) } else { - Syscalls.chown(path, uid, -1) + Syscall.chown(path, uid, -1) } } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) @@ -117,9 +117,9 @@ internal class LocalLinuxFileAttributeView( val gid = group.id try { if (noFollowLinks) { - Syscalls.lchown(path, -1, gid) + Syscall.lchown(path, -1, gid) } else { - Syscalls.chown(path, -1, gid) + Syscall.chown(path, -1, gid) } } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) @@ -133,7 +133,7 @@ internal class LocalLinuxFileAttributeView( } val modeInt = mode.toInt() try { - Syscalls.chmod(path, modeInt) + Syscall.chmod(path, modeInt) } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) } @@ -143,9 +143,9 @@ internal class LocalLinuxFileAttributeView( override fun setSeLinuxContext(context: ByteString) { try { if (noFollowLinks) { - Syscalls.lsetfilecon(path, context) + Syscall.lsetfilecon(path, context) } else { - Syscalls.setfilecon(path, context) + Syscall.setfilecon(path, context) } } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) @@ -158,13 +158,13 @@ internal class LocalLinuxFileAttributeView( path } else { try { - Syscalls.realpath(path) + Syscall.realpath(path) } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) } } try { - Syscalls.selinux_android_restorecon(path, 0) + Syscall.selinux_android_restorecon(path, 0) } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileStore.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileStore.kt index dd7c8ef0f..edd5c940c 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileStore.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileStore.kt @@ -18,8 +18,8 @@ import me.zhanghai.android.files.provider.common.toByteString import me.zhanghai.android.files.provider.linux.syscall.Constants import me.zhanghai.android.files.provider.linux.syscall.Int32Ref import me.zhanghai.android.files.provider.linux.syscall.StructMntent +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import me.zhanghai.android.files.util.andInv import me.zhanghai.android.files.util.hasBits import me.zhanghai.android.files.util.readParcelable @@ -73,7 +73,7 @@ internal class LocalLinuxFileStore : PosixFileStore, Parcelable { override fun type(): String = mntent.mnt_type.toString() - override fun isReadOnly(): Boolean = Syscalls.hasmntopt(mntent, OPTION_RO) + override fun isReadOnly(): Boolean = Syscall.hasmntopt(mntent, OPTION_RO) @Throws(IOException::class) override fun setReadOnly(readOnly: Boolean) { @@ -124,7 +124,7 @@ internal class LocalLinuxFileStore : PosixFileStore, Parcelable { ) { val mountFlags = mountFlags or Constants.MS_REMOUNT try { - Syscalls.mount(source, target, fileSystemType, mountFlags, data) + Syscall.mount(source, target, fileSystemType, mountFlags, data) } catch (e: SyscallException) { val readOnly = mountFlags.hasBits(Constants.MS_RDONLY) val isReadOnlyError = e.errno == OsConstants.EACCES || e.errno == OsConstants.EROFS @@ -132,13 +132,13 @@ internal class LocalLinuxFileStore : PosixFileStore, Parcelable { throw e } try { - val fd = Syscalls.open(source!!, OsConstants.O_RDONLY, 0) + val fd = Syscall.open(source!!, OsConstants.O_RDONLY, 0) try { - Syscalls.ioctl_int(fd, Constants.BLKROSET, Int32Ref(0)) + Syscall.ioctl_int(fd, Constants.BLKROSET, Int32Ref(0)) } finally { - Syscalls.close(fd) + Syscall.close(fd) } - Syscalls.mount(source, target, fileSystemType, mountFlags, data) + Syscall.mount(source, target, fileSystemType, mountFlags, data) } catch (e2: SyscallException) { e.addSuppressed(e2) throw e @@ -167,7 +167,7 @@ internal class LocalLinuxFileStore : PosixFileStore, Parcelable { @Throws(IOException::class) private fun getStatVfs(): StructStatVfs = try { - Syscalls.statvfs(path.toByteString()) + Syscall.statvfs(path.toByteString()) } catch (e: SyscallException) { throw e.toFileSystemException(path.toString()) } @@ -273,14 +273,14 @@ internal class LocalLinuxFileStore : PosixFileStore, Parcelable { @Throws(SyscallException::class) private fun getMountEntries(): List { val entries = mutableListOf() - val file = Syscalls.setmntent(PATH_PROC_SELF_MOUNTS, MODE_R) + val file = Syscall.setmntent(PATH_PROC_SELF_MOUNTS, MODE_R) try { while (true) { - val mntent = Syscalls.getmntent(file) ?: break + val mntent = Syscall.getmntent(file) ?: break entries += mntent } } finally { - Syscalls.endmntent(file) + Syscall.endmntent(file) } return entries } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileSystemProvider.kt index 0250228be..4964dde61 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxFileSystemProvider.kt @@ -39,8 +39,8 @@ import me.zhanghai.android.files.provider.common.toInt import me.zhanghai.android.files.provider.common.toLinkOptions import me.zhanghai.android.files.provider.common.toOpenOptions import me.zhanghai.android.files.provider.linux.media.MediaScanner +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import me.zhanghai.android.files.util.hasBits import java.io.IOException import java.net.URI @@ -86,7 +86,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst val mode = (PosixFileMode.fromAttributes(attributes) ?: PosixFileMode.CREATE_FILE_DEFAULT) .toInt() val fd = try { - Syscalls.open(fileBytes, flags, mode) + Syscall.open(fileBytes, flags, mode) } catch (e: SyscallException) { if (flags.hasBits(OsConstants.O_CREAT)) { e.maybeThrowInvalidFileNameException(fileBytes.toString()) @@ -96,7 +96,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst val fileChannel = FileChannel::class.open(fd, flags) if (openOptions.deleteOnClose) { try { - Syscalls.remove(fileBytes) + Syscall.remove(fileBytes) } catch (e: SyscallException) { e.printStackTrace() } @@ -121,7 +121,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst directory as? LinuxPath ?: throw ProviderMismatchException(directory.toString()) val directoryBytes = directory.toByteString() val dir = try { - Syscalls.opendir(directoryBytes) + Syscall.opendir(directoryBytes) } catch (e: SyscallException) { throw e.toFileSystemException(directoryBytes.toString()) } @@ -135,7 +135,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst val mode = (PosixFileMode.fromAttributes(attributes) ?: PosixFileMode.CREATE_DIRECTORY_DEFAULT).toInt() try { - Syscalls.mkdir(directoryBytes, mode) + Syscall.mkdir(directoryBytes, mode) } catch (e: SyscallException) { e.maybeThrowInvalidFileNameException(directoryBytes.toString()) throw e.toFileSystemException(directoryBytes.toString()) @@ -156,7 +156,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst } val linkBytes = link.toByteString() try { - Syscalls.symlink(targetBytes, linkBytes) + Syscall.symlink(targetBytes, linkBytes) } catch (e: SyscallException) { e.maybeThrowInvalidFileNameException(linkBytes.toString()) throw e.toFileSystemException(linkBytes.toString(), targetBytes.toString()) @@ -171,7 +171,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst val oldPathBytes = existing.toByteString() val newPathBytes = link.toByteString() try { - Syscalls.link(oldPathBytes, newPathBytes) + Syscall.link(oldPathBytes, newPathBytes) } catch (e: SyscallException) { e.maybeThrowInvalidFileNameException(newPathBytes.toString()) throw e.toFileSystemException(newPathBytes.toString(), oldPathBytes.toString()) @@ -184,7 +184,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst path as? LinuxPath ?: throw ProviderMismatchException(path.toString()) val pathBytes = path.toByteString() try { - Syscalls.remove(pathBytes) + Syscall.remove(pathBytes) } catch (e: SyscallException) { throw e.toFileSystemException(pathBytes.toString()) } @@ -196,7 +196,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst link as? LinuxPath ?: throw ProviderMismatchException(link.toString()) val linkBytes = link.toByteString() val targetBytes = try { - Syscalls.readlink(linkBytes) + Syscall.readlink(linkBytes) } catch (e: SyscallException) { e.maybeThrowNotLinkException(linkBytes.toString()) throw e.toFileSystemException(linkBytes.toString()) @@ -240,12 +240,12 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst val pathBytes = path.toByteString() val path2Bytes = path2.toByteString() val pathStat = try { - Syscalls.lstat(pathBytes) + Syscall.lstat(pathBytes) } catch (e: SyscallException) { throw e.toFileSystemException(pathBytes.toString()) } val path2Stat = try { - Syscalls.lstat(path2Bytes) + Syscall.lstat(path2Bytes) } catch (e: SyscallException) { throw e.toFileSystemException(path2Bytes.toString()) } @@ -287,7 +287,7 @@ class LocalLinuxFileSystemProvider(provider: LinuxFileSystemProvider) : FileSyst } val accessible = try { // TODO: Should use euidaccess() but that's unavailable on Android. - Syscalls.access(pathBytes, mode) + Syscall.access(pathBytes, mode) } catch (e: SyscallException) { throw e.toFileSystemException(pathBytes.toString()) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxWatchService.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxWatchService.kt index 166fe7d2c..4ccdfb397 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxWatchService.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/LocalLinuxWatchService.kt @@ -18,8 +18,8 @@ import me.zhanghai.android.files.provider.FileSystemProviders import me.zhanghai.android.files.provider.common.AbstractWatchService import me.zhanghai.android.files.provider.common.readAttributes import me.zhanghai.android.files.provider.linux.syscall.Constants +import me.zhanghai.android.files.provider.linux.syscall.Syscall import me.zhanghai.android.files.provider.linux.syscall.SyscallException -import me.zhanghai.android.files.provider.linux.syscall.Syscalls import me.zhanghai.android.files.util.hasBits import java.io.Closeable import java.io.FileDescriptor @@ -91,14 +91,14 @@ internal class LocalLinuxWatchService : AbstractWatchService init { isDaemon = true try { - socketFds = Syscalls.socketpair(OsConstants.AF_UNIX, OsConstants.SOCK_STREAM, 0) - val flags = Syscalls.fcntl(socketFds[0], OsConstants.F_GETFL) + socketFds = Syscall.socketpair(OsConstants.AF_UNIX, OsConstants.SOCK_STREAM, 0) + val flags = Syscall.fcntl(socketFds[0], OsConstants.F_GETFL) if (!flags.hasBits(OsConstants.O_NONBLOCK)) { - Syscalls.fcntl( + Syscall.fcntl( socketFds[0], OsConstants.F_SETFL, flags or OsConstants.O_NONBLOCK ) } - inotifyFd = Syscalls.inotify_init1(OsConstants.O_NONBLOCK) + inotifyFd = Syscall.inotify_init1(OsConstants.O_NONBLOCK) } catch (e: SyscallException) { throw e.toFileSystemException(null) } @@ -115,7 +115,7 @@ internal class LocalLinuxWatchService : AbstractWatchService var mask = eventKindsToMask(kinds) mask = maybeAddDontFollowMask(path, mask) val wd = try { - Syscalls.inotify_add_watch(inotifyFd, pathBytes, mask) + Syscall.inotify_add_watch(inotifyFd, pathBytes, mask) } catch (e: SyscallException) { continuation.resumeWithException( e.toFileSystemException(pathBytes.toString()) @@ -161,7 +161,7 @@ internal class LocalLinuxWatchService : AbstractWatchService if (key.isValid) { val wd = key.watchDescriptor try { - Syscalls.inotify_rm_watch(inotifyFd, wd) + Syscall.inotify_rm_watch(inotifyFd, wd) } catch (e: SyscallException) { e.toFileSystemException(key.watchable().toString()) .printStackTrace() @@ -191,7 +191,7 @@ internal class LocalLinuxWatchService : AbstractWatchService for (key in keys.values) { val wd = key.watchDescriptor try { - Syscalls.inotify_rm_watch(inotifyFd, wd) + Syscall.inotify_rm_watch(inotifyFd, wd) } catch (e: SyscallException) { continuation.resumeWithException( e.toFileSystemException(key.watchable().toString()) @@ -202,9 +202,9 @@ internal class LocalLinuxWatchService : AbstractWatchService } keys.clear() try { - Syscalls.close(inotifyFd) - Syscalls.close(socketFds[1]) - Syscalls.close(socketFds[0]) + Syscall.close(inotifyFd) + Syscall.close(socketFds[1]) + Syscall.close(socketFds[0]) } catch (e: SyscallException) { e.printStackTrace() } @@ -234,7 +234,7 @@ internal class LocalLinuxWatchService : AbstractWatchService } } try { - Syscalls.write(socketFds[1], ONE_BYTE) + Syscall.write(socketFds[1], ONE_BYTE) } catch (e: InterruptedIOException) { continuation.resumeWithException(e) } catch (e: SyscallException) { @@ -248,10 +248,10 @@ internal class LocalLinuxWatchService : AbstractWatchService while (true) { fds[0].revents = 0 fds[1].revents = 0 - Syscalls.poll(fds, -1) + Syscall.poll(fds, -1) if (fds[0].revents.toInt().hasBits(OsConstants.POLLIN)) { val size = try { - Syscalls.read(socketFds[0], ONE_BYTE) + Syscall.read(socketFds[0], ONE_BYTE) } catch (e: SyscallException) { if (e.errno != OsConstants.EAGAIN) { throw e @@ -272,7 +272,7 @@ internal class LocalLinuxWatchService : AbstractWatchService } if (fds[1].revents.toInt().hasBits(OsConstants.POLLIN)) { val size = try { - Syscalls.read(inotifyFd, inotifyBuffer) + Syscall.read(inotifyFd, inotifyBuffer) } catch (e: SyscallException) { if (e.errno != OsConstants.EAGAIN) { throw e @@ -286,7 +286,7 @@ internal class LocalLinuxWatchService : AbstractWatchService } continue } - val events = Syscalls.inotify_get_events(inotifyBuffer, 0, size) + val events = Syscall.inotify_get_events(inotifyBuffer, 0, size) for (event in events) { if (event.mask.hasBits(Constants.IN_Q_OVERFLOW)) { for (key in keys.values) { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/Syscalls.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/Syscall.kt similarity index 98% rename from app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/Syscalls.kt rename to app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/Syscall.kt index 3ee1191c5..a1d3bdefa 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/Syscalls.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/Syscall.kt @@ -20,9 +20,9 @@ import me.zhanghai.android.libselinux.SeLinux import java.io.FileDescriptor import java.io.InterruptedIOException -object Syscalls { +object Syscall { init { - System.loadLibrary("syscalls") + System.loadLibrary("syscall") } @Throws(SyscallException::class) @@ -268,8 +268,8 @@ object Syscalls { external fun sendfile( outFd: FileDescriptor, inFd: FileDescriptor, - offset: Int64Ref? - , count: Long + offset: Int64Ref?, + count: Long ): Long @Throws(SyscallException::class) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/SyscallException.kt b/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/SyscallException.kt index 95c42a79b..ada62c3c6 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/SyscallException.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/linux/syscall/SyscallException.kt @@ -70,6 +70,6 @@ class SyscallException @JvmOverloads constructor( companion object { private fun perror(errno: Int, functionName: String): String = - "$functionName: ${Syscalls.strerror(errno)}" + "$functionName: ${Syscall.strerror(errno)}" } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/remote/ParcelableDirectoryStream.kt b/app/src/main/java/me/zhanghai/android/files/provider/remote/ParcelableDirectoryStream.kt index f47b93c75..bf77e6d6a 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/remote/ParcelableDirectoryStream.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/remote/ParcelableDirectoryStream.kt @@ -7,12 +7,13 @@ package me.zhanghai.android.files.provider.remote import android.os.Parcel import android.os.Parcelable +import java.io.IOException import java8.nio.file.DirectoryIteratorException import java8.nio.file.DirectoryStream import java8.nio.file.Path import me.zhanghai.android.files.provider.common.PathListDirectoryStream import me.zhanghai.android.files.util.ParcelSlicedList -import java.io.IOException +import me.zhanghai.android.files.util.readParcelable class ParcelableDirectoryStream : Parcelable { private val paths: List @@ -38,8 +39,7 @@ class ParcelableDirectoryStream : Parcelable { private constructor(source: Parcel) { @Suppress("UNCHECKED_CAST") - paths = source.readParcelable>(Path::class.java.classLoader)!! - .list as List + paths = source.readParcelable>()!!.list as List } companion object { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileService.kt b/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileService.kt index feacf24fc..8eb5ab4b5 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileService.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileService.kt @@ -29,9 +29,4 @@ abstract class RemoteFileService(private val remoteInterface: RemoteInterface().getPath("").archiveRefresh() - } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileSystem.kt index 84eb44dea..a14da0f65 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileSystem.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/remote/RemoteFileSystem.kt @@ -18,11 +18,4 @@ abstract class RemoteFileSystem( } remoteInterface.get().call { exception -> close(exception) } } - - protected fun hasRemoteInterface(): Boolean = remoteInterface.has() - - @Throws(RemoteFileSystemException::class) - fun ensureRemoteInterface() { - remoteInterface.get() - } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/root/LibSuFileServiceLauncher.kt b/app/src/main/java/me/zhanghai/android/files/provider/root/LibSuFileServiceLauncher.kt index afa7462b6..f8931983c 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/root/LibSuFileServiceLauncher.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/root/LibSuFileServiceLauncher.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout -import me.zhanghai.android.files.BuildConfig import me.zhanghai.android.files.provider.remote.IRemoteFileService import me.zhanghai.android.files.provider.remote.RemoteFileServiceInterface import me.zhanghai.android.files.provider.remote.RemoteFileSystemException @@ -33,7 +32,7 @@ object LibSuFileServiceLauncher { private val lock = Any() init { - Shell.enableVerboseLogging = BuildConfig.DEBUG + Shell.enableVerboseLogging = true Shell.setDefaultBuilder( Shell.Builder.create() .setInitializers(LibSuShellInitializer::class.java) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/root/RootableFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/root/RootableFileSystem.kt index f1135b68d..b71b32bc9 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/root/RootableFileSystem.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/root/RootableFileSystem.kt @@ -13,7 +13,6 @@ import java8.nio.file.PathMatcher import java8.nio.file.WatchService import java8.nio.file.attribute.UserPrincipalLookupService import java8.nio.file.spi.FileSystemProvider -import me.zhanghai.android.files.provider.remote.RemoteFileSystemException import java.io.IOException abstract class RootableFileSystem( @@ -23,11 +22,6 @@ abstract class RootableFileSystem( protected open val localFileSystem: FileSystem = localFileSystemCreator(this) protected open val rootFileSystem: RootFileSystem = rootFileSystemCreator(this) - @Throws(RemoteFileSystemException::class) - fun ensureRootInterface() { - rootFileSystem.ensureRemoteInterface() - } - override fun provider(): FileSystemProvider = localFileSystem.provider() @Throws(IOException::class) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/root/SuiFileServiceLauncher.kt b/app/src/main/java/me/zhanghai/android/files/provider/root/SuiFileServiceLauncher.kt index d32ac55dd..a8a779001 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/root/SuiFileServiceLauncher.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/root/SuiFileServiceLauncher.kt @@ -11,6 +11,7 @@ import android.content.pm.PackageManager import android.os.Build import android.os.IBinder import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.Keep import androidx.annotation.RequiresApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.runBlocking @@ -145,6 +146,7 @@ object SuiFileServiceLauncher { } } +@Keep @RequiresApi(Build.VERSION_CODES.M) class SuiFileServiceInterface : RemoteFileServiceInterface() { init { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpCopyMove.kt index 99191882c..39a2339b9 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpCopyMove.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpCopyMove.kt @@ -5,6 +5,8 @@ package me.zhanghai.android.files.provider.sftp +import java.io.IOException +import java.time.Instant import java8.nio.file.FileAlreadyExistsException import java8.nio.file.FileSystemException import java8.nio.file.NoSuchFileException @@ -19,8 +21,6 @@ import me.zhanghai.android.files.util.enumSetOf import net.schmizz.sshj.sftp.FileAttributes import net.schmizz.sshj.sftp.FileMode import net.schmizz.sshj.sftp.OpenMode -import org.threeten.bp.Instant -import java.io.IOException internal object SftpCopyMove { @Throws(IOException::class) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileAttributes.kt index 6cb2d3628..9eaaabc22 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileAttributes.kt @@ -6,11 +6,13 @@ package me.zhanghai.android.files.provider.sftp import android.os.Parcelable +import java.time.Instant import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.provider.common.AbstractPosixFileAttributes import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.EPOCH import me.zhanghai.android.files.provider.common.FileTimeParceler import me.zhanghai.android.files.provider.common.PosixFileMode import me.zhanghai.android.files.provider.common.PosixFileModeBit @@ -18,7 +20,6 @@ import me.zhanghai.android.files.provider.common.PosixFileType import me.zhanghai.android.files.provider.common.PosixGroup import me.zhanghai.android.files.provider.common.PosixUser import net.schmizz.sshj.sftp.FileAttributes -import org.threeten.bp.Instant @Parcelize internal data class SftpFileAttributes( @@ -41,7 +42,7 @@ internal data class SftpFileAttributes( lastModifiedTime = FileTime.from(Instant.ofEpochSecond(attributes.mtime)) lastAccessTime = FileTime.from(Instant.ofEpochSecond(attributes.atime)) } else { - lastModifiedTime = FileTime.from(Instant.EPOCH) + lastModifiedTime = FileTime::class.EPOCH lastAccessTime = lastModifiedTime } val creationTime = lastModifiedTime diff --git a/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileSystemProvider.kt index 30ef9801b..cf0724329 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/sftp/SftpFileSystemProvider.kt @@ -108,7 +108,7 @@ object SftpFileSystemProvider : FileSystemProvider(), PathObservableProvider, Se private val URI.sftpAuthority: Authority get() { val port = if (port != -1) port else Authority.DEFAULT_PORT - val username = userInfo ?: "" + val username = userInfo.orEmpty() return Authority(host, port, username) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/sftp/client/FileByteChannel.kt b/app/src/main/java/me/zhanghai/android/files/provider/sftp/client/FileByteChannel.kt index 8ef1f7a1b..6077752ff 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/sftp/client/FileByteChannel.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/sftp/client/FileByteChannel.kt @@ -5,11 +5,12 @@ package me.zhanghai.android.files.provider.sftp.client -import java8.nio.channels.SeekableByteChannel -import me.zhanghai.android.files.provider.common.ForceableChannel +import me.zhanghai.android.files.provider.common.AbstractFileByteChannel +import me.zhanghai.android.files.provider.common.EMPTY +import me.zhanghai.android.files.provider.common.asFuture +import me.zhanghai.android.files.provider.common.map import me.zhanghai.android.files.util.closeSafe import me.zhanghai.android.files.util.findCauseByClass -import net.schmizz.concurrent.Promise import net.schmizz.sshj.sftp.PacketType import net.schmizz.sshj.sftp.RemoteFile import net.schmizz.sshj.sftp.RemoteFileAccessor @@ -19,139 +20,79 @@ import java.io.IOException import java.nio.ByteBuffer import java.nio.channels.AsynchronousCloseException import java.nio.channels.ClosedByInterruptException -import java.nio.channels.ClosedChannelException -import java.nio.channels.NonReadableChannelException -import java.util.concurrent.TimeUnit +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future class FileByteChannel( private val file: RemoteFile, - private val isAppend: Boolean -) : ForceableChannel, SeekableByteChannel { - private var position = 0L - private val readBuffer = ReadBuffer() - private val ioLock = Any() - - private var isOpen = true - private val closeLock = Any() - - @Throws(IOException::class) - override fun read(destination: ByteBuffer): Int { - ensureOpen() - if (isAppend) { - throw NonReadableChannelException() - } - val remaining = destination.remaining() - if (remaining == 0) { - return 0 + isAppend: Boolean +) : AbstractFileByteChannel(isAppend) { + override fun onReadAsync(position: Long, size: Int, timeoutMillis: Long): Future = + try { + RemoteFileAccessor.asyncRead(file, position, size) + } catch (e: IOException) { + throw e.maybeToSpecificException() } - return synchronized(ioLock) { - readBuffer.read(destination).also { - if (it != -1) { - position += it + .asFuture() + .map( + { response -> + val dataLength: Int + when (response.type) { + PacketType.STATUS -> { + response.ensureStatusIs(Response.StatusCode.EOF) + return@map ByteBuffer::class.EMPTY + } + PacketType.DATA -> { + dataLength = response.readUInt32AsInt() + } + else -> throw SFTPException("Unexpected packet type ${response.type}") + } + if (dataLength == 0) { + return@map ByteBuffer::class.EMPTY + } + val length = dataLength.coerceAtMost(size) + ByteBuffer.wrap(response.array(), response.rpos(), length) + }, { e -> + ((e as? ExecutionException)?.cause as? IOException)?.maybeToSpecificException() + ?.let { ExecutionException(it) } ?: e } - } - } - } + ) @Throws(IOException::class) - override fun write(source: ByteBuffer): Int { - ensureOpen() - val remaining = source.remaining() - if (remaining == 0) { - return 0 - } - synchronized(ioLock) { - if (isAppend) { - position = getSize() - } - // I don't think we are using native or read-only ByteBuffer, so just call array() here. - try { - file.write(position, source.array(), source.position(), remaining) - } catch (e: IOException) { - throw e.maybeToSpecificException() - } - source.position(source.limit()) - position += remaining - return remaining - } - } - - @Throws(IOException::class) - override fun position(): Long { - ensureOpen() - synchronized(ioLock) { - if (isAppend) { - position = getSize() - } - return position - } - } - - override fun position(newPosition: Long): SeekableByteChannel { - ensureOpen() - if (isAppend) { - // Ignored. - return this - } - synchronized(ioLock) { - readBuffer.reposition(position, newPosition) - position = newPosition + override fun onWrite(position: Long, source: ByteBuffer) { + // I don't think we are using native or read-only ByteBuffer, so just call array() here. + try { + file.write( + position, source.array(), source.arrayOffset() + source.position(), + source.remaining() + ) + } catch (e: IOException) { + throw e.maybeToSpecificException() } - return this - } - - @Throws(IOException::class) - override fun size(): Long { - ensureOpen() - return getSize() + source.position(source.limit()) } @Throws(IOException::class) - override fun truncate(size: Long): SeekableByteChannel { - ensureOpen() - require(size >= 0) - synchronized(ioLock) { - val currentSize = getSize() - if (size >= currentSize) { - return this - } - try { - file.setLength(size) - } catch (e: IOException) { - throw e.maybeToSpecificException() - } - position = position.coerceAtMost(size) + override fun onTruncate(size: Long) { + try { + file.setLength(size) + } catch (e: IOException) { + throw e.maybeToSpecificException() } - return this } @Throws(IOException::class) - private fun getSize(): Long = + override fun onSize(): Long = try{ file.length() } catch (e: IOException) { throw e.maybeToSpecificException() } - @Throws(IOException::class) - override fun force(metaData: Boolean) { - ensureOpen() - // Unsupported. - } - - @Throws(ClosedChannelException::class) - private fun ensureOpen() { - synchronized(closeLock) { - if (!isOpen) { - throw ClosedChannelException() - } - } - } - private fun IOException.maybeToSpecificException(): IOException = when { this is SFTPException && statusCode == Response.StatusCode.INVALID_HANDLE -> { - synchronized(closeLock) { isOpen = false } + setClosed() AsynchronousCloseException().apply { initCause(this@maybeToSpecificException) } } findCauseByClass() != null -> { @@ -161,122 +102,15 @@ class FileByteChannel( else -> this } - override fun isOpen(): Boolean = synchronized(closeLock) { isOpen } - @Throws(IOException::class) - override fun close() { - synchronized(closeLock) { - if (!isOpen) { - return - } - isOpen = false - try { - file.close() - } catch (e: SFTPException) { - // NO_SUCH_FILE is returned when canceling an in-progress copy to SFTP server. - if (e.statusCode != Response.StatusCode.NO_SUCH_FILE) { - throw e - } + override fun onClose() { + try { + file.close() + } catch (e: SFTPException) { + // NO_SUCH_FILE is returned when canceling an in-progress copy to SFTP server. + if (e.statusCode != Response.StatusCode.NO_SUCH_FILE) { + throw e } } } - - private inner class ReadBuffer { - private val bufferSize: Int = DEFAULT_BUFFER_SIZE - private val timeout: Long - - init { - val engine = RemoteFileAccessor.getRequester(file) - timeout = engine.timeoutMs.toLong() - } - - private val buffer = ByteBuffer.allocate(bufferSize).apply { limit(0) } - private var bufferedPosition = 0L - - private var pendingPromise: Promise? = null - private val pendingPromiseLock = Any() - - @Throws(IOException::class) - fun read(destination: ByteBuffer): Int { - if (!buffer.hasRemaining()) { - readIntoBuffer() - if (!buffer.hasRemaining()) { - return -1 - } - } - val length = destination.remaining().coerceAtMost(buffer.remaining()) - val bufferLimit = buffer.limit() - buffer.limit(buffer.position() + length) - destination.put(buffer) - buffer.limit(bufferLimit) - return length - } - - @Throws(IOException::class) - private fun readIntoBuffer() { - val promise = synchronized(pendingPromiseLock) { - pendingPromise?.also { pendingPromise = null } - } ?: readIntoBufferAsync() - val response = try { - promise.retrieve(timeout, TimeUnit.MILLISECONDS) - } catch (e: IOException) { - throw e.maybeToSpecificException() - } - val dataLength: Int - when (response.type) { - PacketType.STATUS -> { - response.ensureStatusIs(Response.StatusCode.EOF) - buffer.limit(0) - return - } - PacketType.DATA -> { - dataLength = response.readUInt32AsInt() - } - else -> throw SFTPException("Unexpected packet type ${response.type}") - } - if (dataLength == 0) { - buffer.limit(0) - return - } - buffer.clear() - val length = dataLength.coerceAtMost(buffer.remaining()) - buffer.put(response.array(), response.rpos(), length) - buffer.flip() - bufferedPosition += length - synchronized(pendingPromiseLock) { - try { - pendingPromise = readIntoBufferAsync() - } catch (e: IOException) { - e.printStackTrace() - } - } - } - - @Throws(IOException::class) - private fun readIntoBufferAsync(): Promise = - try { - RemoteFileAccessor.asyncRead(file, bufferedPosition, bufferSize) - } catch (e: IOException) { - throw e.maybeToSpecificException() - } - - fun reposition(oldPosition: Long, newPosition: Long) { - if (newPosition == oldPosition) { - return - } - val newBufferPosition = buffer.position() + (newPosition - oldPosition) - if (newBufferPosition in 0..buffer.limit()) { - buffer.position(newBufferPosition.toInt()) - } else { - synchronized(pendingPromiseLock) { pendingPromise = null } - buffer.limit(0) - bufferedPosition = newPosition - } - } - } - - companion object { - // @see SmbConfig.DEFAULT_BUFFER_SIZE - private const val DEFAULT_BUFFER_SIZE = 1024 * 1024 - } } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileAttributes.kt index ddd592b8b..48d62e78b 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileAttributes.kt @@ -7,6 +7,7 @@ package me.zhanghai.android.files.provider.smb import android.os.Parcelable import com.hierynomus.msfscc.FileAttributes +import java.time.Instant import java8.nio.file.attribute.FileTime import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith @@ -15,7 +16,6 @@ import me.zhanghai.android.files.provider.common.BasicFileType import me.zhanghai.android.files.provider.common.FileTimeParceler import me.zhanghai.android.files.provider.smb.client.FileInformation import me.zhanghai.android.files.util.hasBits -import org.threeten.bp.Instant @Parcelize internal class SmbFileAttributes( diff --git a/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileSystemProvider.kt index 7e8b29810..261be974a 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbFileSystemProvider.kt @@ -107,7 +107,7 @@ object SmbFileSystemProvider : FileSystemProvider(), PathObservableProvider, Sea private val URI.smbAuthority: Authority get() { val port = if (port != -1) port else Authority.DEFAULT_PORT - val userInfo = userInfo ?: "" + val userInfo = userInfo.orEmpty() val domainSeparatorIndex = userInfo.indexOf('\\') val username: String val domain: String? diff --git a/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbShareFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbShareFileAttributes.kt index 3b7f5f38b..d95680c9c 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbShareFileAttributes.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/smb/SmbShareFileAttributes.kt @@ -11,10 +11,10 @@ import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import me.zhanghai.android.files.provider.common.AbstractBasicFileAttributes import me.zhanghai.android.files.provider.common.BasicFileType +import me.zhanghai.android.files.provider.common.EPOCH import me.zhanghai.android.files.provider.common.FileTimeParceler import me.zhanghai.android.files.provider.smb.client.ShareInformation import me.zhanghai.android.files.provider.smb.client.ShareType -import org.threeten.bp.Instant @Parcelize internal class SmbShareFileAttributes( @@ -36,7 +36,7 @@ internal class SmbShareFileAttributes( companion object { fun from(shareInformation: ShareInformation, path: SmbPath): SmbShareFileAttributes { - val lastModifiedTime = FileTime.from(Instant.EPOCH) + val lastModifiedTime = FileTime::class.EPOCH val lastAccessTime = lastModifiedTime val creationTime = lastModifiedTime val type = when (shareInformation.type) { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/smb/client/FileByteChannel.kt b/app/src/main/java/me/zhanghai/android/files/provider/smb/client/FileByteChannel.kt index dccf755d6..ab7339881 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/smb/client/FileByteChannel.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/smb/client/FileByteChannel.kt @@ -8,129 +8,78 @@ package me.zhanghai.android.files.provider.smb.client import com.hierynomus.mserref.NtStatus import com.hierynomus.msfscc.fileinformation.FileStandardInformation import com.hierynomus.mssmb2.SMBApiException -import com.hierynomus.mssmb2.messages.SMB2ReadResponse -import com.hierynomus.protocol.commons.concurrent.Futures -import com.hierynomus.protocol.transport.TransportException import com.hierynomus.smbj.common.SMBRuntimeException import com.hierynomus.smbj.io.ByteChunkProvider import com.hierynomus.smbj.share.File import com.hierynomus.smbj.share.FileAccessor -import java8.nio.channels.SeekableByteChannel -import me.zhanghai.android.files.provider.common.ForceableChannel +import me.zhanghai.android.files.provider.common.AbstractFileByteChannel +import me.zhanghai.android.files.provider.common.EMPTY +import me.zhanghai.android.files.provider.common.map import me.zhanghai.android.files.util.closeSafe import me.zhanghai.android.files.util.findCauseByClass -import java.io.Closeable import java.io.IOException import java.io.InterruptedIOException import java.nio.ByteBuffer import java.nio.channels.AsynchronousCloseException import java.nio.channels.ClosedByInterruptException -import java.nio.channels.ClosedChannelException -import java.nio.channels.NonReadableChannelException +import java.util.concurrent.ExecutionException import java.util.concurrent.Future -import java.util.concurrent.TimeUnit class FileByteChannel( private val file: File, - private val isAppend: Boolean -) : ForceableChannel, SeekableByteChannel { - private var position = 0L - private val readBuffer = ReadBuffer() - private val ioLock = Any() - - private var isOpen = true - private val closeLock = Any() - + isAppend: Boolean +// Cancelling reads leads to TransportException: Received response with unknown sequence number +) : AbstractFileByteChannel(isAppend, shouldCancelRead = false) { @Throws(IOException::class) - override fun read(destination: ByteBuffer): Int { - ensureOpen() - if (isAppend) { - throw NonReadableChannelException() - } - val remaining = destination.remaining() - if (remaining == 0) { - return 0 + override fun onReadAsync(position: Long, size: Int, timeoutMillis: Long): Future = + try { + FileAccessor.readAsync(file, position, size) + } catch (e: SMBRuntimeException) { + throw e.toIOException() } - return synchronized(ioLock) { - readBuffer.read(destination).also { - if (it != -1) { - position += it + .map( + { response -> + when (response.header.statusCode) { + NtStatus.STATUS_END_OF_FILE.value -> { + return@map ByteBuffer::class.EMPTY + } + NtStatus.STATUS_SUCCESS.value -> {} + else -> throw SMBApiException(response.header, "Read failed for $this") + .toIOException() + } + val data = response.data + if (data.isEmpty()) { + return@map ByteBuffer::class.EMPTY + } + val length = data.size.coerceAtMost(size) + ByteBuffer.wrap(data, 0, length) + }, { e -> + ExecutionException(SMBRuntimeException(e).toIOException()) } - } - } - } + ) @Throws(IOException::class) - override fun write(source: ByteBuffer): Int { - ensureOpen() - if (!source.hasRemaining()) { - return 0 - } - synchronized(ioLock) { - if (isAppend) { - position = getSize() - } - return try { - file.write(ByteBufferChunkProvider(source, position)) - } catch (e: SMBRuntimeException) { - throw e.toIOException() - }.also { - position += it - } - } - } - - @Throws(IOException::class) - override fun position(): Long { - ensureOpen() - synchronized(ioLock) { - if (isAppend) { - position = getSize() - } - return position - } - } - - override fun position(newPosition: Long): SeekableByteChannel { - ensureOpen() - if (isAppend) { - // Ignored. - return this - } - synchronized(ioLock) { - readBuffer.reposition(position, newPosition) - position = newPosition + override fun onWrite(position: Long, source: ByteBuffer) { + val sourcePosition = source.position() + val bytesWritten = try { + file.write(ByteBufferChunkProvider(source, position)) + } catch (e: SMBRuntimeException) { + throw e.toIOException() } - return this + source.position(sourcePosition + bytesWritten) } @Throws(IOException::class) - override fun size(): Long { - ensureOpen() - return getSize() - } - - @Throws(IOException::class) - override fun truncate(size: Long): SeekableByteChannel { - ensureOpen() - require(size >= 0) - synchronized(ioLock) { - val currentSize = getSize() - if (size >= currentSize) { - return this - } - try { - file.setLength(size) - } catch (e: SMBRuntimeException) { - throw e.toIOException() - } - position = position.coerceAtMost(size) + override fun onTruncate(size: Long) { + try { + file.setLength(size) + } catch (e: SMBRuntimeException) { + throw e.toIOException() } - return this } @Throws(IOException::class) - private fun getSize(): Long = + override fun onSize(): Long = try { file.getFileInformation(FileStandardInformation::class.java).endOfFile } catch (e: SMBRuntimeException) { @@ -138,8 +87,7 @@ class FileByteChannel( } @Throws(IOException::class) - override fun force(metaData: Boolean) { - ensureOpen() + override fun onForce(metaData: Boolean) { try { file.flush() } catch (e: SMBRuntimeException) { @@ -147,20 +95,11 @@ class FileByteChannel( } } - @Throws(ClosedChannelException::class) - private fun ensureOpen() { - synchronized(closeLock) { - if (!isOpen) { - throw ClosedChannelException() - } - } - } - private fun SMBRuntimeException.toIOException(): IOException = when { findCauseByClass() .let { it != null && it.status == NtStatus.STATUS_FILE_CLOSED } -> { - synchronized(closeLock) { isOpen = false } + setClosed() AsynchronousCloseException().apply { initCause(this@toIOException) } } findCauseByClass() != null -> { @@ -170,162 +109,37 @@ class FileByteChannel( else -> IOException(this) } - override fun isOpen(): Boolean = synchronized(closeLock) { isOpen } - @Throws(IOException::class) - override fun close() { - synchronized(closeLock) { - if (!isOpen) { - return - } - isOpen = false - readBuffer.closeSafe() - try { - file.close() - } catch (e: SMBRuntimeException) { - throw when { - e.findCauseByClass() != null -> - InterruptedIOException().apply { initCause(e) } - else -> IOException(e) - } + override fun onClose() { + try { + file.close() + } catch (e: SMBRuntimeException) { + throw when { + e.findCauseByClass() != null -> + InterruptedIOException().apply { initCause(e) } + else -> IOException(e) } } } - private inner class ReadBuffer : Closeable { - private val bufferSize: Int - private val timeout: Long - + private class ByteBufferChunkProvider( + private val buffer: ByteBuffer, + offset: Long + ) : ByteChunkProvider() { init { - val treeConnect = file.diskShare.treeConnect - val config = treeConnect.config - bufferSize = config.readBufferSize - .coerceAtMost(treeConnect.session.connection.negotiatedProtocol.maxReadSize) - timeout = config.readTimeout - } - - private val buffer = ByteBuffer.allocate(bufferSize).apply { limit(0) } - private var bufferedPosition = 0L - - private var pendingFuture: Future? = null - private val pendingFutureLock = Any() - - @Throws(IOException::class) - fun read(destination: ByteBuffer): Int { - if (!buffer.hasRemaining()) { - readIntoBuffer() - if (!buffer.hasRemaining()) { - return -1 - } - } - val length = destination.remaining().coerceAtMost(buffer.remaining()) - val bufferLimit = buffer.limit() - buffer.limit(buffer.position() + length) - destination.put(buffer) - buffer.limit(bufferLimit) - return length - } - - @Throws(IOException::class) - private fun readIntoBuffer() { - val future = synchronized(pendingFutureLock) { - pendingFuture?.also { pendingFuture = null } - } ?: readIntoBufferAsync() - val response = try { - receive(future, timeout) - } catch (e: SMBRuntimeException) { - throw e.toIOException() - } - when (response.header.statusCode) { - NtStatus.STATUS_END_OF_FILE.value -> { - buffer.limit(0) - return - } - NtStatus.STATUS_SUCCESS.value -> {} - else -> throw SMBApiException(response.header, "Read failed for $this") - .toIOException() - } - val data = response.data - if (data.isEmpty()) { - buffer.limit(0) - return - } - buffer.clear() - val length = data.size.coerceAtMost(buffer.remaining()) - buffer.put(data, 0, length) - buffer.flip() - bufferedPosition += length - synchronized(pendingFutureLock) { - try { - pendingFuture = readIntoBufferAsync() - } catch (e: IOException) { - e.printStackTrace() - } - } + this.offset = offset } - // @see com.hierynomus.smbj.share.Share.receive - @Throws(SMBRuntimeException::class) - private fun receive(future: Future, timeout: Long): T = - try { - Futures.get(future, timeout, TimeUnit.MILLISECONDS, TransportException.Wrapper) - } catch (e: TransportException) { - throw SMBRuntimeException(e) - } + override fun isAvailable(): Boolean = buffer.hasRemaining() - @Throws(IOException::class) - private fun readIntoBufferAsync(): Future = - try { - FileAccessor.readAsync(file, bufferedPosition, bufferSize) - } catch (e: SMBRuntimeException) { - throw e.toIOException() - } + override fun bytesLeft(): Int = buffer.remaining() - fun reposition(oldPosition: Long, newPosition: Long) { - if (newPosition == oldPosition) { - return - } - val newBufferPosition = buffer.position() + (newPosition - oldPosition) - if (newBufferPosition in 0..buffer.limit()) { - buffer.position(newBufferPosition.toInt()) - } else { - synchronized(pendingFutureLock) { - // TransportException: Received response with unknown sequence number - //pendingFuture?.cancel(true)?.also { pendingFuture = null } - pendingFuture = null - } - buffer.limit(0) - bufferedPosition = newPosition - } - } + override fun prepareWrite(maxBytesToPrepare: Int) {} - override fun close() { - synchronized(pendingFutureLock) { - // TransportException: Received response with unknown sequence number - //pendingFuture?.cancel(true)?.also { pendingFuture = null } - pendingFuture = null - } + override fun getChunk(chunk: ByteArray): Int { + val length = chunk.size.coerceAtMost(buffer.remaining()) + buffer.get(chunk, 0, length) + return length } } } - -private class ByteBufferChunkProvider( - private val buffer: ByteBuffer, - offset: Long -) : ByteChunkProvider() { - init { - this.offset = offset - } - - override fun isAvailable(): Boolean = buffer.hasRemaining() - - override fun bytesLeft(): Int = buffer.remaining() - - override fun prepareWrite(maxBytesToPrepare: Int) {} - - override fun getChunk(chunk: ByteArray): Int { - val length = chunk.size.coerceAtMost(buffer.remaining()) - buffer.get(chunk, 0, length) - return length - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt new file mode 100644 index 000000000..a74b3878c --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt @@ -0,0 +1,27 @@ +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.ConflictException +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.ForbiddenException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.exception.UnauthorizedException +import java8.nio.file.AccessDeniedException +import java8.nio.file.FileAlreadyExistsException +import java8.nio.file.FileSystemException +import java8.nio.file.NoSuchFileException +import me.zhanghai.android.files.provider.webdav.client.DavIOException + +fun DavException.toFileSystemException( + file: String?, + other: String? = null +): FileSystemException { + return when (this) { + is DavIOException -> + return FileSystemException(file, other, message).apply { initCause(cause) } + is UnauthorizedException, is ForbiddenException -> + AccessDeniedException(file, other, message) + is NotFoundException -> NoSuchFileException(file, other, message) + is ConflictException -> FileAlreadyExistsException(file, other, message) + else -> FileSystemException(file, other, message) + }.apply { initCause(this@toFileSystemException) } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt new file mode 100644 index 000000000..dc03fbcb6 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import java8.nio.file.StandardOpenOption +import me.zhanghai.android.files.provider.common.OpenOptions + +internal fun OpenOptions.checkForWebDav() { + if (deleteOnClose) { + throw UnsupportedOperationException(StandardOpenOption.DELETE_ON_CLOSE.toString()) + } + if (sync) { + throw UnsupportedOperationException(StandardOpenOption.SYNC.toString()) + } + if (dsync) { + throw UnsupportedOperationException(StandardOpenOption.DSYNC.toString()) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt new file mode 100644 index 000000000..5f3fe5311 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import java8.nio.file.Path +import me.zhanghai.android.files.provider.webdav.client.Authority + +fun Authority.createWebDavRootPath(): Path = + WebDavFileSystemProvider.getOrNewFileSystem(this).rootDirectory diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt new file mode 100644 index 000000000..9aab7a92f --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.DavException +import java8.nio.file.FileAlreadyExistsException +import java8.nio.file.NoSuchFileException +import java8.nio.file.StandardCopyOption +import me.zhanghai.android.files.provider.common.CopyOptions +import me.zhanghai.android.files.provider.common.copyTo +import me.zhanghai.android.files.provider.webdav.client.Client +import me.zhanghai.android.files.provider.webdav.client.isDirectory +import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink +import me.zhanghai.android.files.provider.webdav.client.lastModifiedTime +import me.zhanghai.android.files.provider.webdav.client.size +import java.io.IOException + +internal object WebDavCopyMove { + @Throws(IOException::class) + fun copy(source: WebDavPath, target: WebDavPath, copyOptions: CopyOptions) { + if (copyOptions.atomicMove) { + throw UnsupportedOperationException(StandardCopyOption.ATOMIC_MOVE.toString()) + } + val sourceResponse = try { + Client.findProperties(source, copyOptions.noFollowLinks) + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + val targetFile = try { + Client.findPropertiesOrNull(target, true) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + val sourceSize = sourceResponse.size + if (targetFile != null) { + if (source == target) { + copyOptions.progressListener?.invoke(sourceSize) + return + } + if (!copyOptions.replaceExisting) { + throw FileAlreadyExistsException(source.toString(), target.toString(), null) + } + try { + Client.delete(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + } + when { + sourceResponse.isDirectory -> { + try { + Client.makeCollection(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + copyOptions.progressListener?.invoke(sourceSize) + } + sourceResponse.isSymbolicLink -> + throw UnsupportedOperationException("Cannot copy symbolic links") + else -> { + val sourceInputStream = try { + Client.get(source) + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + try { + val targetOutputStream = try { + Client.put(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + var successful = false + try { + sourceInputStream.copyTo( + targetOutputStream, copyOptions.progressIntervalMillis, + copyOptions.progressListener + ) + successful = true + } finally { + try { + targetOutputStream.close() + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } finally { + if (!successful) { + try { + Client.delete(target) + } catch (e: DavException) { + e.printStackTrace() + } + } + } + } + } finally { + try { + sourceInputStream.close() + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + } + } + } + // We don't take error when copying attribute fatal, so errors will only be logged from now + // on. + if (!sourceResponse.isSymbolicLink) { + val lastModifiedTime = sourceResponse.lastModifiedTime + if (lastModifiedTime != null) { + try { + Client.setLastModifiedTime(target, lastModifiedTime) + } catch (e: DavException) { + e.printStackTrace() + } + } + } + } + + @Throws(IOException::class) + fun move(source: WebDavPath, target: WebDavPath, copyOptions: CopyOptions) { + val sourceResponse = try { + Client.findProperties(source, copyOptions.noFollowLinks) + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + val targetResponse = try { + Client.findPropertiesOrNull(target, true) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + val sourceSize = sourceResponse.size + if (targetResponse != null) { + if (source == target) { + copyOptions.progressListener?.invoke(sourceSize) + return + } + if (!copyOptions.replaceExisting) { + throw FileAlreadyExistsException(source.toString(), target.toString(), null) + } + try { + Client.delete(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + } + var renameSuccessful = false + try { + Client.move(source, target) + renameSuccessful = true + } catch (e: DavException) { + if (copyOptions.atomicMove) { + throw e.toFileSystemException(source.toString(), target.toString()) + } + // Ignored. + } + if (renameSuccessful) { + copyOptions.progressListener?.invoke(sourceSize) + return + } + if (copyOptions.atomicMove) { + throw AssertionError() + } + var copyOptions = copyOptions + if (!copyOptions.copyAttributes || !copyOptions.noFollowLinks) { + copyOptions = CopyOptions( + copyOptions.replaceExisting, true, false, true, copyOptions.progressIntervalMillis, + copyOptions.progressListener + ) + } + copy(source, target, copyOptions) + try { + Client.delete(source) + } catch (e: DavException) { + if (e.toFileSystemException(source.toString()) !is NoSuchFileException) { + try { + Client.delete(target) + } catch (e2: DavException) { + e.addSuppressed(e2.toFileSystemException(target.toString())) + } + } + throw e.toFileSystemException(source.toString()) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt new file mode 100644 index 000000000..5da2cdb85 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.DavException +import java8.nio.file.LinkOption +import java8.nio.file.attribute.BasicFileAttributeView +import java8.nio.file.attribute.FileTime +import me.zhanghai.android.files.provider.webdav.client.Client +import java.io.IOException + +internal class WebDavFileAttributeView( + private val path: WebDavPath, + private val noFollowLinks: Boolean +) : BasicFileAttributeView { + override fun name(): String = NAME + + @Throws(IOException::class) + override fun readAttributes(): WebDavFileAttributes { + val file = try { + Client.findProperties(path, noFollowLinks) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + return WebDavFileAttributes.from(file, path) + } + + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + if (lastModifiedTime == null) { + // Only throw if caller is trying to set only last access time and/or create time, so + // that foreign copy move can still set last modified time. + if (lastAccessTime != null) { + throw UnsupportedOperationException("lastAccessTime") + } + if (createTime != null) { + throw UnsupportedOperationException("createTime") + } + return + } + if (noFollowLinks) { + throw UnsupportedOperationException(LinkOption.NOFOLLOW_LINKS.toString()) + } + try { + Client.setLastModifiedTime(path, lastModifiedTime.toInstant()) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + } + + companion object { + private val NAME = WebDavFileSystemProvider.scheme + + val SUPPORTED_NAMES = setOf("basic", NAME) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt new file mode 100644 index 000000000..5e6bcbf38 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import android.os.Parcelable +import at.bitfire.dav4jvm.Response +import java.time.Instant +import java8.nio.file.attribute.FileTime +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith +import me.zhanghai.android.files.provider.common.AbstractBasicFileAttributes +import me.zhanghai.android.files.provider.common.BasicFileType +import me.zhanghai.android.files.provider.common.EPOCH +import me.zhanghai.android.files.provider.common.FileTimeParceler +import me.zhanghai.android.files.provider.webdav.client.creationTime +import me.zhanghai.android.files.provider.webdav.client.isDirectory +import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink +import me.zhanghai.android.files.provider.webdav.client.lastModifiedTime +import me.zhanghai.android.files.provider.webdav.client.size + +@Parcelize +internal data class WebDavFileAttributes( + override val lastModifiedTime: @WriteWith FileTime, + override val lastAccessTime: @WriteWith FileTime, + override val creationTime: @WriteWith FileTime, + override val type: BasicFileType, + override val size: Long, + override val fileKey: Parcelable +) : AbstractBasicFileAttributes() { + companion object { + fun from(response: Response, path: WebDavPath): WebDavFileAttributes = + when { + response.isSuccess() -> { + val lastModifiedTime = FileTime.from(response.lastModifiedTime ?: Instant.EPOCH) + val lastAccessTime = lastModifiedTime + val creationTime = + response.creationTime?.let { FileTime.from(it) } ?: lastModifiedTime + val type = if (response.isDirectory) { + BasicFileType.DIRECTORY + } else { + BasicFileType.REGULAR_FILE + } + val size = response.size + val fileKey = path + WebDavFileAttributes( + lastModifiedTime, lastAccessTime, creationTime, type, size, fileKey + ) + } + response.isSymbolicLink -> { + val lastModifiedTime = FileTime::class.EPOCH + val lastAccessTime = lastModifiedTime + val creationTime = lastModifiedTime + val type = BasicFileType.SYMBOLIC_LINK + val size = 0L + val fileKey = path + WebDavFileAttributes( + lastModifiedTime, lastAccessTime, creationTime, type, size, fileKey + ) + } + else -> error(response) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt new file mode 100644 index 000000000..dbaedb886 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import android.os.Parcel +import android.os.Parcelable +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.Path +import java8.nio.file.PathMatcher +import java8.nio.file.WatchService +import java8.nio.file.attribute.UserPrincipalLookupService +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringBuilder +import me.zhanghai.android.files.provider.common.ByteStringListPathCreator +import me.zhanghai.android.files.provider.common.LocalWatchService +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.util.readParcelable +import java.io.IOException + +internal class WebDavFileSystem( + private val provider: WebDavFileSystemProvider, + val authority: Authority +) : FileSystem(), ByteStringListPathCreator, Parcelable { + val rootDirectory = WebDavPath(this, SEPARATOR_BYTE_STRING) + + init { + if (!rootDirectory.isAbsolute) { + throw AssertionError("Root directory must be absolute") + } + if (rootDirectory.nameCount != 0) { + throw AssertionError("Root directory must contain no names") + } + } + + private val lock = Any() + + private var isOpen = true + + val defaultDirectory: WebDavPath + get() = rootDirectory + + override fun provider(): FileSystemProvider = provider + + override fun close() { + synchronized(lock) { + if (!isOpen) { + return + } + provider.removeFileSystem(this) + isOpen = false + } + } + + override fun isOpen(): Boolean = synchronized(lock) { isOpen } + + override fun isReadOnly(): Boolean = false + + override fun getSeparator(): String = SEPARATOR_STRING + + override fun getRootDirectories(): Iterable = listOf(rootDirectory) + + override fun getFileStores(): Iterable { + // TODO + throw UnsupportedOperationException() + } + + override fun supportedFileAttributeViews(): Set = + WebDavFileAttributeView.SUPPORTED_NAMES + + override fun getPath(first: String, vararg more: String): WebDavPath { + val path = ByteStringBuilder(first.toByteString()) + .apply { more.forEach { append(SEPARATOR).append(it.toByteString()) } } + .toByteString() + return WebDavPath(this, path) + } + + override fun getPath(first: ByteString, vararg more: ByteString): WebDavPath { + val path = ByteStringBuilder(first) + .apply { more.forEach { append(SEPARATOR).append(it) } } + .toByteString() + return WebDavPath(this, path) + } + + override fun getPathMatcher(syntaxAndPattern: String): PathMatcher { + throw UnsupportedOperationException() + } + + override fun getUserPrincipalLookupService(): UserPrincipalLookupService { + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newWatchService(): WatchService = LocalWatchService() + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (javaClass != other?.javaClass) { + return false + } + other as WebDavFileSystem + return authority == other.authority + } + + override fun hashCode(): Int = authority.hashCode() + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(authority, flags) + } + + companion object { + const val SEPARATOR = '/'.code.toByte() + private val SEPARATOR_BYTE_STRING = SEPARATOR.toByteString() + private const val SEPARATOR_STRING = SEPARATOR.toInt().toChar().toString() + + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): WebDavFileSystem { + val authority = source.readParcelable()!! + return WebDavFileSystemProvider.getOrNewFileSystem(authority) + } + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt new file mode 100644 index 000000000..233f0458d --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt @@ -0,0 +1,445 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.DavException +import java8.nio.channels.FileChannel +import java8.nio.channels.SeekableByteChannel +import java8.nio.file.AccessMode +import java8.nio.file.CopyOption +import java8.nio.file.DirectoryStream +import java8.nio.file.FileAlreadyExistsException +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.FileSystemAlreadyExistsException +import java8.nio.file.FileSystemException +import java8.nio.file.FileSystemNotFoundException +import java8.nio.file.LinkOption +import java8.nio.file.NoSuchFileException +import java8.nio.file.NotLinkException +import java8.nio.file.OpenOption +import java8.nio.file.Path +import java8.nio.file.ProviderMismatchException +import java8.nio.file.StandardOpenOption +import java8.nio.file.attribute.BasicFileAttributes +import java8.nio.file.attribute.FileAttribute +import java8.nio.file.attribute.FileAttributeView +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringPath +import me.zhanghai.android.files.provider.common.DelegateSchemeFileSystemProvider +import me.zhanghai.android.files.provider.common.PathListDirectoryStream +import me.zhanghai.android.files.provider.common.PathObservable +import me.zhanghai.android.files.provider.common.PathObservableProvider +import me.zhanghai.android.files.provider.common.Searchable +import me.zhanghai.android.files.provider.common.WalkFileTreeSearchable +import me.zhanghai.android.files.provider.common.WatchServicePathObservable +import me.zhanghai.android.files.provider.common.decodedPathByteString +import me.zhanghai.android.files.provider.common.toAccessModes +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.common.toCopyOptions +import me.zhanghai.android.files.provider.common.toLinkOptions +import me.zhanghai.android.files.provider.common.toOpenOptions +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.provider.webdav.client.Client +import me.zhanghai.android.files.provider.webdav.client.Protocol +import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URI + +object WebDavFileSystemProvider : FileSystemProvider(), PathObservableProvider, Searchable { + private val HIDDEN_FILE_NAME_PREFIX = ".".toByteString() + + private val fileSystems = mutableMapOf() + + private val lock = Any() + + override fun getScheme(): String = Protocol.DAV.scheme + + override fun newFileSystem(uri: URI, env: Map): FileSystem { + uri.requireSameScheme() + val authority = uri.webDavAuthority + synchronized(lock) { + if (fileSystems[authority] != null) { + throw FileSystemAlreadyExistsException(authority.toString()) + } + return newFileSystemLocked(authority) + } + } + + internal fun getOrNewFileSystem(authority: Authority): WebDavFileSystem = + synchronized(lock) { fileSystems[authority] ?: newFileSystemLocked(authority) } + + private fun newFileSystemLocked(authority: Authority): WebDavFileSystem { + val fileSystem = WebDavFileSystem(this, authority) + fileSystems[authority] = fileSystem + return fileSystem + } + + override fun getFileSystem(uri: URI): FileSystem { + uri.requireSameScheme() + val authority = uri.webDavAuthority + return synchronized(lock) { fileSystems[authority] } + ?: throw FileSystemNotFoundException(authority.toString()) + } + + internal fun removeFileSystem(fileSystem: WebDavFileSystem) { + val authority = fileSystem.authority + synchronized(lock) { fileSystems.remove(authority) } + } + + override fun getPath(uri: URI): Path { + uri.requireSameScheme() + val authority = uri.webDavAuthority + val path = uri.decodedPathByteString + ?: throw IllegalArgumentException("URI must have a path") + return getOrNewFileSystem(authority).getPath(path) + } + + private fun URI.requireSameScheme() { + val scheme = scheme + require(scheme in Protocol.SCHEMES) { "URI scheme $scheme must be in ${Protocol.SCHEMES}" } + } + + private val URI.webDavAuthority: Authority + get() { + val protocol = Protocol.fromScheme(scheme) + val port = if (port != -1) port else protocol.defaultPort + val username = userInfo.orEmpty() + return Authority(protocol, host, port, username) + } + + @Throws(IOException::class) + override fun newInputStream(file: Path, vararg options: OpenOption): InputStream { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + val openOptions = options.toOpenOptions() + openOptions.checkForWebDav() + if (openOptions.write) { + throw UnsupportedOperationException(StandardOpenOption.WRITE.toString()) + } + if (openOptions.append) { + throw UnsupportedOperationException(StandardOpenOption.APPEND.toString()) + } + if (openOptions.truncateExisting) { + throw UnsupportedOperationException(StandardOpenOption.TRUNCATE_EXISTING.toString()) + } + if (openOptions.create || openOptions.createNew || openOptions.noFollowLinks) { + val fileResponse = try { + Client.findPropertiesOrNull(file, true) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + if (openOptions.noFollowLinks && fileResponse != null && fileResponse.isSymbolicLink) { + throw FileSystemException( + file.toString(), null, "File is a symbolic link: $fileResponse" + ) + } + if (openOptions.createNew && fileResponse != null) { + throw FileAlreadyExistsException(file.toString()) + } + if ((openOptions.create || openOptions.createNew) && fileResponse == null) { + try { + Client.makeFile(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + } + try { + return Client.get(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + + @Throws(IOException::class) + override fun newOutputStream(file: Path, vararg options: OpenOption): OutputStream { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + val optionsSet = mutableSetOf(*options) + if (optionsSet.isEmpty()) { + optionsSet += StandardOpenOption.CREATE + optionsSet += StandardOpenOption.TRUNCATE_EXISTING + } + optionsSet += StandardOpenOption.WRITE + val openOptions = optionsSet.toOpenOptions() + openOptions.checkForWebDav() + if (!openOptions.truncateExisting && !openOptions.createNew) { + throw UnsupportedOperationException("Missing ${StandardOpenOption.TRUNCATE_EXISTING}") + } + val fileResponse = try { + Client.findPropertiesOrNull(file, true) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + if (openOptions.createNew && fileResponse != null) { + throw FileAlreadyExistsException(file.toString()) + } + if (!(openOptions.create || openOptions.createNew) && fileResponse == null) { + throw NoSuchFileException(file.toString()) + } + try { + return Client.put(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + + @Throws(IOException::class) + override fun newFileChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): FileChannel { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + options.toOpenOptions().checkForWebDav() + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newByteChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): SeekableByteChannel { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + val openOptions = options.toOpenOptions() + openOptions.checkForWebDav() + if (openOptions.write && !openOptions.truncateExisting) { + throw UnsupportedOperationException("Missing ${StandardOpenOption.TRUNCATE_EXISTING}") + } + if (openOptions.write || openOptions.create || openOptions.createNew || + openOptions.noFollowLinks) { + val fileResponse = try { + Client.findPropertiesOrNull(file, true) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + if (openOptions.createNew && fileResponse != null) { + throw FileAlreadyExistsException(file.toString()) + } + if (openOptions.noFollowLinks && fileResponse != null && fileResponse.isSymbolicLink) { + throw FileSystemException( + file.toString(), null, "File is a symbolic link: $fileResponse" + ) + } + if (fileResponse == null) { + if (!(openOptions.create || openOptions.createNew)) { + throw NoSuchFileException(file.toString()) + } + try { + Client.makeFile(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + } + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + try { + return Client.openByteChannel(file, openOptions.append) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + + @Throws(IOException::class) + override fun newDirectoryStream( + directory: Path, + filter: DirectoryStream.Filter + ): DirectoryStream { + directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString()) + val paths = try { + @Suppress("UNCHECKED_CAST") + Client.findCollectionMembers(directory) as List + } catch (e: DavException) { + throw e.toFileSystemException(directory.toString()) + } + return PathListDirectoryStream(paths, filter) + } + + @Throws(IOException::class) + override fun createDirectory(directory: Path, vararg attributes: FileAttribute<*>) { + directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString()) + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + try { + Client.makeCollection(directory) + } catch (e: DavException) { + throw e.toFileSystemException(directory.toString()) + } + } + + override fun createSymbolicLink(link: Path, target: Path, vararg attributes: FileAttribute<*>) { + link as? WebDavPath ?: throw ProviderMismatchException(link.toString()) + when (target) { + is WebDavPath, is ByteStringPath -> {} + else -> throw ProviderMismatchException(target.toString()) + } + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + throw UnsupportedOperationException() + } + + override fun createLink(link: Path, existing: Path) { + link as? WebDavPath ?: throw ProviderMismatchException(link.toString()) + existing as? WebDavPath ?: throw ProviderMismatchException(existing.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun delete(path: Path) { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + try { + Client.delete(path) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + } + + override fun readSymbolicLink(link: Path): Path { + link as? WebDavPath ?: throw ProviderMismatchException(link.toString()) + val linkResponse = try { + Client.findProperties(link, true) + } catch (e: DavException) { + throw e.toFileSystemException(link.toString()) + } + val target = linkResponse.newLocation?.toString() + ?: throw NotLinkException(link.toString(), null, linkResponse.toString()) + // TODO: Convert to webdav(s) scheme? + return ByteStringPath(ByteString.fromString(target)) + } + + @Throws(IOException::class) + override fun copy(source: Path, target: Path, vararg options: CopyOption) { + source as? WebDavPath ?: throw ProviderMismatchException(source.toString()) + target as? WebDavPath ?: throw ProviderMismatchException(target.toString()) + val copyOptions = options.toCopyOptions() + WebDavCopyMove.copy(source, target, copyOptions) + } + + @Throws(IOException::class) + override fun move(source: Path, target: Path, vararg options: CopyOption) { + source as? WebDavPath ?: throw ProviderMismatchException(source.toString()) + target as? WebDavPath ?: throw ProviderMismatchException(target.toString()) + val copyOptions = options.toCopyOptions() + WebDavCopyMove.move(source, target, copyOptions) + } + + override fun isSameFile(path: Path, path2: Path): Boolean { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + return path == path2 + } + + override fun isHidden(path: Path): Boolean { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val fileName = path.fileNameByteString ?: return false + return fileName.startsWith(HIDDEN_FILE_NAME_PREFIX) + } + + override fun getFileStore(path: Path): FileStore { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun checkAccess(path: Path, vararg modes: AccessMode) { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val accessModes = modes.toAccessModes() + if (accessModes.write) { + throw UnsupportedOperationException(AccessMode.WRITE.toString()) + } + if (accessModes.execute) { + throw UnsupportedOperationException(AccessMode.EXECUTE.toString()) + } + // Assume the file can be read if it can be listed. + try { + Client.findProperties(path, false) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + } + + override fun getFileAttributeView( + path: Path, + type: Class, + vararg options: LinkOption + ): V? { + if (!supportsFileAttributeView(type)) { + return null + } + @Suppress("UNCHECKED_CAST") + return getFileAttributeView(path, *options) as V + } + + internal fun supportsFileAttributeView(type: Class): Boolean = + type.isAssignableFrom(WebDavFileAttributeView::class.java) + + @Throws(IOException::class) + override fun readAttributes( + path: Path, + type: Class, + vararg options: LinkOption + ): A { + if (!type.isAssignableFrom(BasicFileAttributes::class.java)) { + throw UnsupportedOperationException(type.toString()) + } + @Suppress("UNCHECKED_CAST") + return getFileAttributeView(path, *options).readAttributes() as A + } + + private fun getFileAttributeView(path: Path, vararg options: LinkOption): WebDavFileAttributeView { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val linkOptions = options.toLinkOptions() + return WebDavFileAttributeView(path, linkOptions.noFollowLinks) + } + + override fun readAttributes( + path: Path, + attributes: String, + vararg options: LinkOption + ): Map { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + override fun setAttribute( + path: Path, + attribute: String, + value: Any, + vararg options: LinkOption + ) { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun observe(path: Path, intervalMillis: Long): PathObservable { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + return WatchServicePathObservable(path, intervalMillis) + } + + @Throws(IOException::class) + override fun search( + directory: Path, + query: String, + intervalMillis: Long, + listener: (List) -> Unit + ) { + directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString()) + WalkFileTreeSearchable.search(directory, query, intervalMillis, listener) + } +} + +val WebDavsFileSystemProvider = + DelegateSchemeFileSystemProvider(Protocol.DAVS.scheme, WebDavFileSystemProvider) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt new file mode 100644 index 000000000..f1493a3b6 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import android.os.Parcel +import android.os.Parcelable +import java8.nio.file.FileSystem +import java8.nio.file.LinkOption +import java8.nio.file.Path +import java8.nio.file.ProviderMismatchException +import java8.nio.file.WatchEvent +import java8.nio.file.WatchKey +import java8.nio.file.WatchService +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringListPath +import me.zhanghai.android.files.provider.common.LocalWatchService +import me.zhanghai.android.files.provider.common.UriAuthority +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.provider.webdav.client.Client +import me.zhanghai.android.files.util.readParcelable +import okhttp3.HttpUrl +import java.io.File +import java.io.IOException + +internal class WebDavPath : ByteStringListPath, Client.Path { + private val fileSystem: WebDavFileSystem + + constructor( + fileSystem: WebDavFileSystem, + path: ByteString + ) : super(WebDavFileSystem.SEPARATOR, path) { + this.fileSystem = fileSystem + } + + private constructor( + fileSystem: WebDavFileSystem, + absolute: Boolean, + segments: List + ) : super(WebDavFileSystem.SEPARATOR, absolute, segments) { + this.fileSystem = fileSystem + } + + override fun isPathAbsolute(path: ByteString): Boolean = + path.isNotEmpty() && path[0] == WebDavFileSystem.SEPARATOR + + override fun createPath(path: ByteString): WebDavPath = WebDavPath(fileSystem, path) + + override fun createPath(absolute: Boolean, segments: List): WebDavPath = + WebDavPath(fileSystem, absolute, segments) + + override val uriScheme: String + get() = fileSystem.authority.protocol.scheme + + override val uriAuthority: UriAuthority + get() = fileSystem.authority.toUriAuthority() + + override val defaultDirectory: WebDavPath + get() = fileSystem.defaultDirectory + + override fun getFileSystem(): FileSystem = fileSystem + + override fun getRoot(): WebDavPath? = if (isAbsolute) fileSystem.rootDirectory else null + + @Throws(IOException::class) + override fun toRealPath(vararg options: LinkOption): WebDavPath { + throw UnsupportedOperationException() + } + + override fun toFile(): File { + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun register( + watcher: WatchService, + events: Array>, + vararg modifiers: WatchEvent.Modifier + ): WatchKey { + if (watcher !is LocalWatchService) { + throw ProviderMismatchException(watcher.toString()) + } + return watcher.register(this, events, *modifiers) + } + + override val authority: Authority + get() = fileSystem.authority + + override val url: HttpUrl + get() = HttpUrl.Builder() + .scheme(authority.protocol.httpScheme) + .host(authority.host) + .apply { + val port = authority.port + if (port != authority.protocol.defaultPort) { + port(port) + } + } + .addPathSegments(toString().removePrefix("/")) + .build() + + private constructor(source: Parcel) : super(source) { + fileSystem = source.readParcelable()!! + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + super.writeToParcel(dest, flags) + + dest.writeParcelable(fileSystem, flags) + } + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): WebDavPath = WebDavPath(source) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} + +val Path.isWebDavPath: Boolean + get() = this is WebDavPath diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt new file mode 100644 index 000000000..a8d1a8a64 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import android.os.Parcelable +import android.util.Log +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.UrlUtils +import kotlinx.parcelize.Parcelize +import okhttp3.Authenticator +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +sealed class Authentication : Parcelable { + abstract fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor +} + +interface AuthenticatorInterceptor : Authenticator, Interceptor + +@Parcelize +data object NoneAuthentication : Authentication() { + override fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor = + object : AuthenticatorInterceptor { + override fun authenticate(route: Route?, response: Response): Request? = null + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request()) + } +} + +@Parcelize +data class PasswordAuthentication( + val password: String +) : Authentication() { + override fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor = + object : AuthenticatorInterceptor { + private val basicDigestAuthHandler = BasicDigestAuthHandler( + UrlUtils.hostToDomain(authority.host), authority.username, password + ) + + override fun authenticate(route: Route?, response: Response): Request? = + basicDigestAuthHandler.authenticate(route, response) + + override fun intercept(chain: Interceptor.Chain): Response = + basicDigestAuthHandler.intercept(chain) + } +} + +@Parcelize +data class AccessTokenAuthentication( + val accessToken: String +) : Authentication() { + override fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor = + object : AuthenticatorInterceptor { + override fun authenticate(route: Route?, response: Response): Request? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestHost = request.url.host + val domain = UrlUtils.hostToDomain(authority.host) + if (!UrlUtils.hostToDomain(requestHost).equals(domain, true)) { + Log.w( + LOG_TAG, + "Not authenticating against $requestHost because it doesn't belong to " + + domain + ) + return chain.proceed(request) + } + val newRequest = request.newBuilder() + .header("Authorization", "Bearer $accessToken") + .build() + return chain.proceed(newRequest) + } + } + + companion object { + private val LOG_TAG = AccessTokenAuthentication::class.java.simpleName + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt new file mode 100644 index 000000000..24ab16158 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +interface Authenticator { + fun getAuthentication(authority: Authority): Authentication? +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt new file mode 100644 index 000000000..e77ec8937 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.provider.common.UriAuthority +import me.zhanghai.android.files.util.takeIfNotEmpty + +@Parcelize +data class Authority( + val protocol: Protocol, + val host: String, + val port: Int, + val username: String +) : Parcelable { + fun toUriAuthority(): UriAuthority { + val userInfo = username.takeIfNotEmpty() + val uriPort = port.takeIf { it != protocol.defaultPort } + return UriAuthority(userInfo, host, uriPort) + } + + override fun toString(): String = toUriAuthority().toString() +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt new file mode 100644 index 000000000..682f5244e --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import java8.nio.file.Path as Java8Path +import okhttp3.Response as OkHttpResponse +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.ConflictException +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.ForbiddenException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.exception.PreconditionFailedException +import at.bitfire.dav4jvm.exception.ServiceUnavailableException +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.dav4jvm.property.webdav.CreationDate +import at.bitfire.dav4jvm.property.webdav.GetContentLength +import at.bitfire.dav4jvm.property.webdav.GetLastModified +import at.bitfire.dav4jvm.property.webdav.ResourceType +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.time.Instant +import java.util.Collections +import java.util.WeakHashMap +import java8.nio.channels.SeekableByteChannel +import me.zhanghai.android.files.app.okHttpClient +import me.zhanghai.android.files.provider.common.LocalWatchService +import me.zhanghai.android.files.provider.common.NotifyEntryModifiedOutputStream +import me.zhanghai.android.files.provider.common.NotifyEntryModifiedSeekableByteChannel +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Route + +// See also https://github.com/miquels/webdavfs/blob/master/fuse.go +object Client { + private val FILE_PROPERTIES = arrayOf( + ResourceType.NAME, + CreationDate.NAME, + GetContentLength.NAME, + GetLastModified.NAME + ) + + @Volatile + lateinit var authenticator: Authenticator + + private val clients = mutableMapOf() + + private val collectionMemberCache = Collections.synchronizedMap(WeakHashMap()) + + @Throws(IOException::class) + private fun getClient(authority: Authority): OkHttpClient { + synchronized(clients) { + var client = clients[authority] + if (client == null) { + val authenticatorInterceptor = + OkHttpAuthenticatorInterceptor(authenticator, authority) + client = okHttpClient.newBuilder() + // Turn off follow redirects for PROPFIND. + .followRedirects(false) + .cookieJar(MemoryCookieJar()) + .addNetworkInterceptor(authenticatorInterceptor) + .authenticator(authenticatorInterceptor) + .build() + clients[authority] = client + } + return client + } + } + + @Throws(DavException::class) + fun makeCollection(path: Path) { + try { + DavResource(getClient(path.authority), path.url).mkCol(null) {} + } catch (e: IOException) { + throw e.toDavException() + } + LocalWatchService.onEntryCreated(path as Java8Path) + } + + @Throws(DavException::class) + fun makeFile(path: Path) { + try { + put(path).close() + } catch (e: IOException) { + throw e.toDavException() + } + LocalWatchService.onEntryCreated(path as Java8Path) + } + + @Throws(DavException::class) + fun delete(path: Path) { + try { + DavResource(getClient(path.authority), path.url).delete {} + } catch (e: IOException) { + throw e.toDavException() + } + collectionMemberCache -= path + LocalWatchService.onEntryDeleted(path as Java8Path) + } + + @Throws(DavException::class) + fun move(source: Path, target: Path) { + if (source.authority != target.authority) { + throw IOException("Paths aren't on the same authority") + } + try { + DavResource(getClient(source.authority), source.url).move(target.url, false) {} + } catch (e: IOException) { + throw e.toDavException() + } + collectionMemberCache -= source + collectionMemberCache -= target + LocalWatchService.onEntryDeleted(source as Java8Path) + LocalWatchService.onEntryCreated(target as Java8Path) + } + + @Throws(DavException::class) + fun get(path: Path): InputStream = + try { + DavResource(getClient(path.authority), path.url).getCompat("*/*", null) + } catch (e: IOException) { + throw e.toDavException() + } + + @Throws(DavException::class) + fun findCollectionMembers(path: Path): List = + buildList { + try { + DavCollection(getClient(path.authority), path.url) + .propfind(1, *FILE_PROPERTIES) { response, relation -> + if (relation != Response.HrefRelation.MEMBER) { + return@propfind + } + this += path.resolve(response.hrefName()) + .also { + if (response.isSuccess()) { + collectionMemberCache[it] = response + } + } + } + } catch (e: IOException) { + throw e.toDavException() + } + } + + @Throws(DavException::class) + fun findPropertiesOrNull(path: Path, noFollowLinks: Boolean): Response? = + try { + findProperties(path, noFollowLinks) + } catch (e: NotFoundException) { + null + } catch (e: IOException) { + throw e.toDavException() + } + + // TODO: Support noFollowLinks. + @Throws(DavException::class) + fun findProperties(path: Path, noFollowLinks: Boolean): Response { + synchronized(collectionMemberCache) { + collectionMemberCache.remove(path)?.let { return it } + } + try { + return findProperties( + DavResource(getClient(path.authority), path.url), *FILE_PROPERTIES + ) + } catch (e: IOException) { + throw e.toDavException() + } + } + + @Throws(DavException::class, IOException::class) + internal fun findProperties(resource: DavResource, vararg properties: Property.Name): Response { + var responseRef: Response? = null + resource.propfind(0, *properties) { response, relation -> + if (relation != Response.HrefRelation.SELF) { + return@propfind + } + if (responseRef != null) { + throw DavException("Duplicate response for self") + } + responseRef = response + } + val response = responseRef ?: throw DavException("Couldn't find a response for self") + response.checkSuccess() + return response + } + + @Throws(DavException::class) + fun openByteChannel(path: Path, isAppend: Boolean): SeekableByteChannel { + try { + val client = getClient(path.authority) + val resource = DavResource(client, path.url) + val patchSupport = resource.getPatchSupport() + return NotifyEntryModifiedSeekableByteChannel( + FileByteChannel(resource, patchSupport, isAppend), path as Java8Path + ) + } catch (e: IOException) { + throw e.toDavException() + } + } + + @Throws(DavException::class) + fun setLastModifiedTime(path: Path, lastModifiedTime: Instant) { + if (true) { + return + } + // The following doesn't work on most servers. See also + // https://github.com/sabre-io/dav/issues/1277 + try { + DavResource(getClient(path.authority), path.url).proppatch( + mapOf(GetLastModified.NAME to HttpUtils.formatDate(lastModifiedTime)), emptyList() + ) { response, _ -> response.checkSuccess() } + } catch (e: IOException) { + throw e.toDavException() + } + LocalWatchService.onEntryModified(path as Java8Path) + } + + @Throws(DavException::class) + fun put(path: Path): OutputStream = + try { + NotifyEntryModifiedOutputStream( + DavResource(getClient(path.authority), path.url).putCompat(), path as Java8Path + ) + } catch (e: IOException) { + throw e.toDavException() + } + + // @see DavResource.checkStatus + private fun Response.checkSuccess() { + if (isSuccess()) { + return + } + val status = status!! + throw when (status.code) { + HttpURLConnection.HTTP_UNAUTHORIZED -> UnauthorizedException(status.message) + HttpURLConnection.HTTP_FORBIDDEN -> ForbiddenException(status.message) + HttpURLConnection.HTTP_NOT_FOUND -> NotFoundException(status.message) + HttpURLConnection.HTTP_CONFLICT -> ConflictException(status.message) + HttpURLConnection.HTTP_PRECON_FAILED -> PreconditionFailedException(status.message) + HttpURLConnection.HTTP_UNAVAILABLE -> ServiceUnavailableException(status.message) + else -> HttpException(status.code, status.message) + } + } + + interface Path { + val authority: Authority + val url: HttpUrl + fun resolve(other: String): Path + } + + private class OkHttpAuthenticatorInterceptor( + private val authenticator: Authenticator, + private val authority: Authority + ) : AuthenticatorInterceptor { + private var authenticatorInterceptorCache: Pair? = + null + + private fun getAuthenticatorInterceptor(): AuthenticatorInterceptor { + val authentication = authenticator.getAuthentication(authority) + ?: throw IOException("No authentication found for $authority") + authenticatorInterceptorCache?.let { + (cachedAuthentication, cachedAuthenticatorInterceptor) -> + if (cachedAuthentication == authentication) { + return cachedAuthenticatorInterceptor + } + } + return authentication.createAuthenticatorInterceptor(authority).also { + authenticatorInterceptorCache = authentication to it + } + } + + override fun authenticate(route: Route?, response: OkHttpResponse): Request? = + getAuthenticatorInterceptor().authenticate(route, response) + + override fun intercept(chain: Interceptor.Chain): OkHttpResponse = + getAuthenticatorInterceptor().intercept(chain) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt new file mode 100644 index 000000000..9daa8f043 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.exception.DavException +import java.io.IOException + +class DavIOException(cause: IOException) : DavException(cause.message ?: "", cause) { + override val cause: Throwable + get() = super.cause!! +} + +fun IOException.toDavException(): DavIOException = DavIOException(this) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt new file mode 100644 index 000000000..82f540e6b --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.DavResourceAccessor +import at.bitfire.dav4jvm.QuotedStringUtils +import at.bitfire.dav4jvm.ResponseCallback +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import me.zhanghai.android.files.provider.common.DelegateOutputStream +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okio.BufferedSink +import okio.Pipe +import okio.buffer +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch + +@Throws(DavException::class, IOException::class) +fun DavResource.getCompat(accept: String, headers: Headers?): InputStream = + get(accept, headers).also { checkStatus(it) }.body!!.byteStream() + +@Throws(DavException::class, IOException::class) +fun DavResource.getRangeCompat( + accept: String, + offset: Long, + size: Int, + headers: Headers? +): InputStream = + followRedirects { + val request = Request.Builder().get().url(http://github.com/location) + if (headers != null) { + request.headers(headers) + } + request.header("Accept", accept) + val lastIndex = offset + size - 1 + request.header("Range", "bytes=$offset-$lastIndex") + httpClient.newCall(request.build()).execute() + } + .also { + checkStatus(it) + if (it.code != HttpURLConnection.HTTP_PARTIAL) { + throw HttpException(it) + } + } + .body!!.byteStream() + +// This doesn't follow redirects since the request body is one-shot anyway. +@Throws(DavException::class, IOException::class) +fun DavResource.putCompat( + ifETag: String? = null, + ifScheduleTag: String? = null, + ifNoneMatch: Boolean = false, +): OutputStream { + val pipe = Pipe(DEFAULT_BUFFER_SIZE.toLong()) + val body = object : RequestBody() { + override fun contentType(): MediaType? = null + override fun isOneShot() = true + override fun writeTo(sink: BufferedSink) { + sink.writeAll(pipe.source) + } + } + val builder = Request.Builder().put(body).url(http://github.com/location) + if (ifETag != null) { + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + } + if (ifScheduleTag != null) { + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + } + if (ifNoneMatch) { + // don't overwrite anything existing + builder.header("If-None-Match", "*") + } + var exceptionRef: IOException? = null + var responseRef: Response? = null + val callbackLatch = CountDownLatch(1) + httpClient.newCall(builder.build()).enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + exceptionRef = e + callbackLatch.countDown() + } + + override fun onResponse(call: Call, response: Response) { + responseRef = response + callbackLatch.countDown() + } + } + ) + return object : DelegateOutputStream(pipe.sink.buffer().outputStream()) { + override fun close() { + super.close() + callbackLatch.await() + exceptionRef?.let { throw it } + checkStatus(responseRef!!) + } + } +} + +enum class PatchSupport { + NONE, + APACHE, + SABRE +} + +@Throws(DavException::class, IOException::class) +fun DavResource.getPatchSupport(): PatchSupport { + lateinit var patchSupport: PatchSupport + options { davCapabilities, response -> + patchSupport = when { + response.headers["Server"]?.contains("Apache") == true && + "" in davCapabilities -> + PatchSupport.APACHE + + "sabredav-partialupdate" in davCapabilities -> PatchSupport.SABRE + else -> PatchSupport.NONE + } + } + return patchSupport +} + +// https://sabre.io/dav/http-patch/ +@Throws(DavException::class, IOException::class) +fun DavResource.patchCompat( + buffer: ByteBuffer, + offset: Long, + ifETag: String? = null, + ifScheduleTag: String? = null, + ifNoneMatch: Boolean = false, + callback: ResponseCallback +) { + followRedirects { + val builder = Request.Builder() + .patch(buffer.toRequestBody("application/x-sabredav-partialupdate".toMediaType())) + .url(http://github.com/location) + val lastIndex = offset + buffer.remaining() - 1 + builder.header("X-Update-Range", "bytes=$offset-$lastIndex") + if (ifETag != null) { + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + } + if (ifScheduleTag != null) { + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + } + if (ifNoneMatch) { + // don't overwrite anything existing + builder.header("If-None-Match", "*") + } + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } +} + +@Throws(DavException::class, IOException::class) +fun DavResource.putRangeCompat( + buffer: ByteBuffer, + offset: Long, + ifETag: String? = null, + ifScheduleTag: String? = null, + ifNoneMatch: Boolean = false, + callback: ResponseCallback +) { + followRedirects { + val builder = Request.Builder() + .put(buffer.toRequestBody()) + .url(http://github.com/location) + val lastIndex = offset + buffer.remaining() - 1 + builder.header("Range", "bytes=$offset-$lastIndex/*") + if (ifETag != null) { + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + } + if (ifScheduleTag != null) { + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + } + if (ifNoneMatch) { + // don't overwrite anything existing + builder.header("If-None-Match", "*") + } + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } +} + +@Throws(HttpException::class) +private fun DavResource.checkStatus(response: Response) { + DavResourceAccessor.checkStatus(this, response) +} + +private fun DavResource.followRedirects(sendRequest: () -> Response): Response = + DavResourceAccessor.followRedirects(this, sendRequest) + +private fun ByteBuffer.toRequestBody(contentType: MediaType? = null): RequestBody { + val contentLength = remaining().toLong() + mark() + return object : RequestBody() { + override fun contentType() = contentType + + override fun contentLength(): Long = contentLength + + override fun writeTo(sink: BufferedSink) { + reset() + sink.write(this@toRequestBody) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt new file mode 100644 index 000000000..83359d7ad --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.webdav.GetContentLength +import me.zhanghai.android.files.provider.common.AbstractFileByteChannel +import me.zhanghai.android.files.provider.common.EMPTY +import me.zhanghai.android.files.provider.common.readFully +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +// https://blog.sphere.chronosempire.org.uk/2012/11/21/webdav-and-the-http-patch-nightmare +class FileByteChannel( + private val resource: DavResource, + private val patchSupport: PatchSupport, + isAppend: Boolean +) : AbstractFileByteChannel(isAppend) { + private var nextSequentialWritePosition = 0L + private var sequentialWriteOutputStream: OutputStream? = null + + @Throws(IOException::class) + override fun onRead(position: Long, size: Int): ByteBuffer { + val inputStream = try { + resource.getRangeCompat("*/*", position, size, null) + } catch (e: HttpException) { + if (e.code == HTTP_RANGE_NOT_SATISFIABLE) { + // We were reading at/past end of file + return ByteBuffer::class.EMPTY + } + throw e + } + val destination = ByteBuffer.allocate(size) + val limit = inputStream.use { + it.readFully(destination.array(), destination.arrayOffset(), size) + } + destination.limit(limit) + return destination + } + + @Throws(IOException::class) + override fun onWrite(position: Long, source: ByteBuffer) { + when (patchSupport) { + PatchSupport.APACHE -> + resource.putRangeCompat(source, position) {} + PatchSupport.SABRE -> + resource.patchCompat(source, position) {} + PatchSupport.NONE -> { + if (position != nextSequentialWritePosition) { + throw IOException("Unsupported non-sequential write") + } + val outputStream = sequentialWriteOutputStream + ?: resource.putCompat().also { sequentialWriteOutputStream = it } + val remaining = source.remaining() + // I don't think we are using native or read-only ByteBuffer, so just call array() + // here. + outputStream.write( + source.array(), source.arrayOffset() + source.position(), remaining + ) + nextSequentialWritePosition += remaining + } + } + } + + @Throws(IOException::class) + override fun onTruncate(size: Long) { + if (size == 0L) { + resource.put(byteArrayOf().toRequestBody()) {} + } else { + throw IOException("Unsupported truncate to non-zero size") + } + } + + @Throws(IOException::class) + override fun onSize(): Long { + val getContentLength = + Client.findProperties(resource, GetContentLength.NAME)[GetContentLength::class.java] + ?: throw IOException("Missing GetContentLength") + return getContentLength.contentLength ?: throw IOException("Invalid GetContentLength") + } + + @Throws(IOException::class) + override fun onClose() { + sequentialWriteOutputStream?.close() + } + + companion object { + private const val HTTP_RANGE_NOT_SATISFIABLE = 416 + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt new file mode 100644 index 000000000..bd6fb328a --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class MemoryCookieJar : CookieJar { + private val cookieMap = mutableMapOf, Cookie>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + synchronized(cookieMap) { + for (cookie in cookies) { + cookieMap[Triple(cookie.domain, cookie.path, cookie.name)] = cookie + } + } + } + + override fun loadForRequest(url: HttpUrl): List = + buildList { + synchronized(cookieMap) { + val iterator = cookieMap.values.iterator() + val currentTimeMillis = System.currentTimeMillis() + while (iterator.hasNext()) { + val cookie = iterator.next() + if (cookie.expiresAt <= currentTimeMillis) { + iterator.remove() + continue + } + if (cookie.matches(url)) { + this += cookie + } + } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt new file mode 100644 index 000000000..0264f1293 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +enum class Protocol(val scheme: String, val httpScheme: String, val defaultPort: Int) { + DAV("dav", "http", 80), + DAVS("davs", "https", 443); + + companion object { + val SCHEMES = entries.map { it.scheme } + + fun fromScheme(scheme: String): Protocol = + entries.firstOrNull { it.scheme == scheme } ?: throw IllegalArgumentException(scheme) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt new file mode 100644 index 000000000..6f1dd8ddd --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.webdav.CreationDate +import at.bitfire.dav4jvm.property.webdav.GetContentLength +import at.bitfire.dav4jvm.property.webdav.GetLastModified +import at.bitfire.dav4jvm.property.webdav.ResourceType +import java.time.Instant + +val Response.creationTime: Instant? + get() = this[CreationDate::class.java]?.creationDate?.let { HttpUtils.parseDate(it) } + +val Response.isDirectory: Boolean + get() = this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true + +val Response.isSymbolicLink: Boolean + get() = newLocation != null + +val Response.lastModifiedTime: Instant? + get() = this[GetLastModified::class.java]?.lastModified + +val Response.size: Long + get() = this[GetContentLength::class.java]?.contentLength ?: 0 diff --git a/app/src/main/java/me/zhanghai/android/files/settings/BookmarkDirectoryListFragment.kt b/app/src/main/java/me/zhanghai/android/files/settings/BookmarkDirectoryListFragment.kt index 77de929b6..912a159dc 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/BookmarkDirectoryListFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/BookmarkDirectoryListFragment.kt @@ -18,13 +18,13 @@ import com.h6ah4i.android.widget.advrecyclerview.animator.DraggableItemAnimator import com.h6ah4i.android.widget.advrecyclerview.draggable.RecyclerViewDragDropManager import com.h6ah4i.android.widget.advrecyclerview.utils.WrapperAdapterUtils import java8.nio.file.Path -import me.zhanghai.android.files.R import me.zhanghai.android.files.databinding.BookmarkDirectoryListFragmentBinding import me.zhanghai.android.files.filelist.FileListActivity import me.zhanghai.android.files.navigation.BookmarkDirectories import me.zhanghai.android.files.navigation.BookmarkDirectory import me.zhanghai.android.files.navigation.EditBookmarkDirectoryDialogActivity import me.zhanghai.android.files.navigation.EditBookmarkDirectoryDialogFragment +import me.zhanghai.android.files.ui.ScrollingViewOnApplyWindowInsetsListener import me.zhanghai.android.files.util.createIntent import me.zhanghai.android.files.util.fadeToVisibilityUnsafe import me.zhanghai.android.files.util.getDrawable @@ -33,9 +33,8 @@ import me.zhanghai.android.files.util.putArgs import me.zhanghai.android.files.util.startActivitySafe class BookmarkDirectoryListFragment : Fragment(), BookmarkDirectoryListAdapter.Listener { - private val pickPathLauncher = registerForActivityResult( - FileListActivity.PickDirectoryContract(), this::onPickPathResult - ) + private val openPathLauncher = + registerForActivityResult(FileListActivity.OpenDirectoryContract(), ::onOpenPathResult) private lateinit var binding: BookmarkDirectoryListFragmentBinding @@ -64,13 +63,18 @@ class BookmarkDirectoryListFragment : Fragment(), BookmarkDirectoryListAdapter.L adapter = BookmarkDirectoryListAdapter(this) dragDropManager = RecyclerViewDragDropManager().apply { setDraggingItemShadowDrawable( - getDrawable(R.drawable.ms9_composite_shadow_z2) as NinePatchDrawable + getDrawable( + com.h6ah4i.android.materialshadowninepatch.R.drawable.ms9_composite_shadow_z2 + ) as NinePatchDrawable ) } wrappedAdapter = dragDropManager.createWrappedAdapter(adapter) binding.recyclerView.adapter = wrappedAdapter binding.recyclerView.itemAnimator = DraggableItemAnimator() dragDropManager.attachRecyclerView(binding.recyclerView) + binding.recyclerView.setOnApplyWindowInsetsListener( + ScrollingViewOnApplyWindowInsetsListener(binding.recyclerView) + ) binding.fab.setOnClickListener { onAddBookmarkDirectory() } Settings.BOOKMARK_DIRECTORIES.observe(viewLifecycleOwner) { @@ -97,10 +101,10 @@ class BookmarkDirectoryListFragment : Fragment(), BookmarkDirectoryListAdapter.L } private fun onAddBookmarkDirectory() { - pickPathLauncher.launchSafe(null, this) + openPathLauncher.launchSafe(null, this) } - private fun onPickPathResult(result: Path?) { + private fun onOpenPathResult(result: Path?) { result ?: return BookmarkDirectories.add(BookmarkDirectory(null, result)) } diff --git a/app/src/main/java/me/zhanghai/android/files/settings/LocalePreference.kt b/app/src/main/java/me/zhanghai/android/files/settings/LocalePreference.kt new file mode 100644 index 000000000..8374146ab --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/settings/LocalePreference.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.AttributeSet +import androidx.annotation.AttrRes +import androidx.annotation.StyleRes +import androidx.core.app.LocaleManagerCompat +import androidx.core.os.LocaleListCompat +import androidx.preference.ListPreference +import androidx.preference.Preference.SummaryProvider +import me.zhanghai.android.files.R +import me.zhanghai.android.files.app.application +import me.zhanghai.android.files.compat.LocaleConfigCompat +import me.zhanghai.android.files.util.toList +import java.util.Locale + +class LocalePreference : ListPreference { + lateinit var setApplicationLocalesPre33: (LocaleListCompat) -> Unit + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int) : super( + context, attrs, defStyleAttr + ) + + constructor( + context: Context, + attrs: AttributeSet?, + @AttrRes defStyleAttr: Int, + @StyleRes defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + init { + val context = context + val systemDefaultEntry = context.getString(R.string.system_default) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Prefer using the system setting because it has better support for locales. + intent = Intent( + Settings.ACTION_APP_LOCALE_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ) + summaryProvider = SummaryProvider { + applicationLocale?.sentenceCasedLocalizedDisplayName ?: systemDefaultEntry + } + } else { + setDefaultValue(VALUE_SYSTEM_DEFAULT) + val supportedLocales = LocaleConfigCompat(context).supportedLocales!!.toList() + .sortedBy { it.toLanguageTag() } + entries = supportedLocales.mapTo(mutableListOf(systemDefaultEntry)) { + it.sentenceCasedLocalizedDisplayName + }.toTypedArray() + entryValues = + supportedLocales + .mapTo(mutableListOf(VALUE_SYSTEM_DEFAULT)) { it.toLanguageTag() } + .toTypedArray() + summaryProvider = SimpleSummaryProvider.getInstance() + } + } + + private val Locale.sentenceCasedLocalizedDisplayName: String + // See com.android.internal.app.LocaleHelper.toSentenceCase() for a proper case conversion + // implementation which requires android.icu.text.CaseMap that's only available on API 29+. + @Suppress("DEPRECATION") + get() = getDisplayName(this).capitalize(this) + + override fun getPersistedString(defaultReturnValue: String?): String = + applicationLocale?.toLanguageTag() ?: VALUE_SYSTEM_DEFAULT + + override fun persistString(value: String?): Boolean { + applicationLocale = if (value != null && value != VALUE_SYSTEM_DEFAULT) { + Locale.forLanguageTag(value) + } else { + null + } + return true + } + + private var applicationLocale: Locale? + get() = LocaleManagerCompat.getApplicationLocales(application).toList().firstOrNull() + set(value) { + check(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + if (value == applicationLocale) { + return + } + val locales = if (value != null) { + LocaleListCompat.create(value) + } else { + LocaleListCompat.getEmptyLocaleList() + } + setApplicationLocalesPre33(locales) + } + + override fun onClick() { + // Don't show dialog if we have an intent. + if (intent != null) { + return + } + + super.onClick() + } + + // Exposed for SettingsPreferenceFragment.onResume(). + public override fun notifyChanged() { + super.notifyChanged() + } + + companion object { + private const val VALUE_SYSTEM_DEFAULT = "" + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/settings/PathPreference.kt b/app/src/main/java/me/zhanghai/android/files/settings/PathPreference.kt index 3d402aa44..50d841a0f 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/PathPreference.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/PathPreference.kt @@ -17,7 +17,6 @@ import androidx.preference.Preference import com.takisoft.preferencex.PreferenceActivityResultListener import com.takisoft.preferencex.PreferenceFragmentCompat import java8.nio.file.Path -import me.zhanghai.android.files.R import me.zhanghai.android.files.filelist.FileListActivity import me.zhanghai.android.files.filelist.toUserFriendlyString import me.zhanghai.android.files.navigation.NavigationRootMapLiveData @@ -25,7 +24,7 @@ import me.zhanghai.android.files.util.startActivityForResultSafe import me.zhanghai.android.files.util.valueCompat abstract class PathPreference : Preference, PreferenceActivityResultListener { - private val pickDirectoryContract = FileListActivity.PickDirectoryContract() + private val openPathContract = FileListActivity.OpenDirectoryContract() var path: Path = persistedPath set(value) { @@ -64,12 +63,12 @@ abstract class PathPreference : Preference, PreferenceActivityResultListener { private fun init(attrs: AttributeSet?, @AttrRes defStyleAttr: Int, @StyleRes defStyleRes: Int) { isPersistent = false context.obtainStyledAttributes( - attrs, R.styleable.EditTextPreference, defStyleAttr, defStyleRes + attrs, androidx.preference.R.styleable.EditTextPreference, defStyleAttr, defStyleRes ).use { if (TypedArrayUtils.getBoolean( - it, R.styleable.EditTextPreference_useSimpleSummaryProvider, - R.styleable.EditTextPreference_useSimpleSummaryProvider, false - )) { + it, androidx.preference.R.styleable.EditTextPreference_useSimpleSummaryProvider, + androidx.preference.R.styleable.EditTextPreference_useSimpleSummaryProvider, false + )) { summaryProvider = SimpleSummaryProvider } } @@ -77,13 +76,13 @@ abstract class PathPreference : Preference, PreferenceActivityResultListener { override fun onPreferenceClick(fragment: PreferenceFragmentCompat, preference: Preference) { fragment.startActivityForResultSafe( - pickDirectoryContract.createIntent(fragment.requireContext(), path), requestCode + openPathContract.createIntent(fragment.requireContext(), path), requestCode ) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == this.requestCode) { - val result = pickDirectoryContract.parseResult(resultCode, data) + val result = openPathContract.parseResult(resultCode, data) if (result != null) { path = result } diff --git a/app/src/main/java/me/zhanghai/android/files/settings/PathSettings.kt b/app/src/main/java/me/zhanghai/android/files/settings/PathSettings.kt index 3ba0395c1..1435df515 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/PathSettings.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/PathSettings.kt @@ -5,14 +5,22 @@ package me.zhanghai.android.files.settings +import androidx.core.content.res.ResourcesCompat import java8.nio.file.Path import me.zhanghai.android.files.R import me.zhanghai.android.files.filelist.FileSortOptions +import me.zhanghai.android.files.filelist.FileViewType object PathSettings { private const val NAME_SUFFIX = "path" @Suppress("UNCHECKED_CAST") + fun getFileListViewType(path: Path): SettingLiveData = + EnumSettingLiveData( + NAME_SUFFIX, R.string.pref_key_file_list_view_type, path.toString(), + ResourcesCompat.ID_NULL, FileViewType::class.java + ) as SettingLiveData + fun getFileListSortOptions(path: Path): SettingLiveData = ParcelValueSettingLiveData( NAME_SUFFIX, R.string.pref_key_file_list_sort_options, path.toString(), null diff --git a/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveData.kt b/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveData.kt index bae4a0872..33758dfbc 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveData.kt @@ -63,7 +63,7 @@ abstract class SettingLiveData( defaultValue: T ): T - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { if (key == this.key) { loadValue() } diff --git a/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveDatas.kt b/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveDatas.kt index 18ebd9f71..11f902c84 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveDatas.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/SettingLiveDatas.kt @@ -14,6 +14,7 @@ import androidx.annotation.DimenRes import androidx.annotation.IntegerRes import androidx.annotation.StringRes import androidx.core.content.edit +import androidx.core.content.res.ResourcesCompat import me.zhanghai.android.files.app.appClassLoader import me.zhanghai.android.files.app.application import me.zhanghai.android.files.util.Base64 @@ -196,7 +197,10 @@ class BooleanSettingLiveData( } // Use string resource for default value so that we can support ListPreference. -class EnumSettingLiveData>( +// TODO: kotlinc: Type argument is not within its bounds: should be subtype of 'Enum' +// https://youtrack.jetbrains.com/issue/KT-60985 +//class EnumSettingLiveData?>( +class EnumSettingLiveData?>( nameSuffix: String?, @StringRes keyRes: Int, keySuffix: String?, @@ -216,7 +220,12 @@ class EnumSettingLiveData>( } override fun getDefaultValue(@StringRes defaultValueRes: Int): E = - enumValues[application.getString(defaultValueRes).toInt()] + if (defaultValueRes != ResourcesCompat.ID_NULL) { + enumValues[application.getString(defaultValueRes).toInt()] + } else { + @Suppress("UNCHECKED_CAST") + null as E + } override fun getValue( sharedPreferences: SharedPreferences, @@ -228,7 +237,7 @@ class EnumSettingLiveData>( } override fun putValue(sharedPreferences: SharedPreferences, key: String, value: E) { - sharedPreferences.edit { putString(key, value.ordinal.toString()) } + sharedPreferences.edit { putString(key, value?.ordinal?.toString()) } } } diff --git a/app/src/main/java/me/zhanghai/android/files/settings/Settings.kt b/app/src/main/java/me/zhanghai/android/files/settings/Settings.kt index ec5924ce8..d2a8e2035 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/Settings.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/Settings.kt @@ -13,6 +13,7 @@ import me.zhanghai.android.files.R import me.zhanghai.android.files.app.application import me.zhanghai.android.files.compat.EnvironmentCompat2 import me.zhanghai.android.files.filelist.FileSortOptions +import me.zhanghai.android.files.filelist.FileViewType import me.zhanghai.android.files.filelist.OpenApkDefaultAction import me.zhanghai.android.files.navigation.BookmarkDirectory import me.zhanghai.android.files.navigation.StandardDirectorySettings @@ -50,6 +51,12 @@ object Settings { R.bool.pref_default_value_file_list_show_hidden_files ) + val FILE_LIST_VIEW_TYPE: SettingLiveData = + EnumSettingLiveData( + R.string.pref_key_file_list_view_type, R.string.pref_default_value_file_list_view_type, + FileViewType::class.java + ) + val FILE_LIST_SORT_OPTIONS: SettingLiveData = ParcelValueSettingLiveData( R.string.pref_key_file_list_sort_options, diff --git a/app/src/main/java/me/zhanghai/android/files/settings/SettingsActivity.kt b/app/src/main/java/me/zhanghai/android/files/settings/SettingsActivity.kt index 07e18f9f8..587544693 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/SettingsActivity.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/SettingsActivity.kt @@ -11,6 +11,8 @@ import android.view.KeyEvent import android.view.MotionEvent import android.view.View import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat import androidx.fragment.app.add import androidx.fragment.app.commit import kotlinx.parcelize.Parcelize @@ -40,6 +42,13 @@ class SettingsActivity : AppActivity(), OnThemeChangedListener, OnNightModeChang } } + fun setApplicationLocalesPre33(locales: LocaleListCompat) { + // HACK: Prevent this activity from being recreated due to locale change. + delegate.onDestroy() + AppCompatDelegate.setApplicationLocales(locales) + restart() + } + override fun onThemeChanged(@StyleRes theme: Int) { // ActivityCompat.recreate() may call ActivityRecreator.recreate() without calling // Activity.recreate(), so we cannot simply override it. To work around this, we just diff --git a/app/src/main/java/me/zhanghai/android/files/settings/SettingsPreferenceFragment.kt b/app/src/main/java/me/zhanghai/android/files/settings/SettingsPreferenceFragment.kt index 22a380316..569b4abc2 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/SettingsPreferenceFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/SettingsPreferenceFragment.kt @@ -5,6 +5,7 @@ package me.zhanghai.android.files.settings +import android.os.Build import android.os.Bundle import me.zhanghai.android.files.R import me.zhanghai.android.files.theme.custom.CustomThemeHelper @@ -14,6 +15,20 @@ import me.zhanghai.android.files.theme.night.NightModeHelper import me.zhanghai.android.files.ui.PreferenceFragmentCompat class SettingsPreferenceFragment : PreferenceFragmentCompat() { + private lateinit var localePreference: LocalePreference + + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.settings) + + localePreference = preferenceScreen.findPreference(getString(R.string.pref_key_locale))!! + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + localePreference.setApplicationLocalesPre33 = { locales -> + val activity = requireActivity() as SettingsActivity + activity.setApplicationLocalesPre33(locales) + } + } + } + override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) @@ -33,10 +48,6 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { Settings.BLACK_NIGHT_MODE.observe(viewLifecycleOwner, this::onBlackNightModeChanged) } - override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.settings) - } - private fun onThemeColorChanged(themeColor: ThemeColor) { CustomThemeHelper.sync() } @@ -52,4 +63,14 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { private fun onBlackNightModeChanged(blackNightMode: Boolean) { CustomThemeHelper.sync() } + + override fun onResume() { + super.onResume() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Refresh locale preference summary because we aren't notified for an external change + // between system default and the locale that's the current system default. + localePreference.notifyChanged() + } + } } diff --git a/app/src/main/java/me/zhanghai/android/files/settings/StandardDirectoryListPreferenceFragment.kt b/app/src/main/java/me/zhanghai/android/files/settings/StandardDirectoryListPreferenceFragment.kt index 55de84bc0..a9f520080 100644 --- a/app/src/main/java/me/zhanghai/android/files/settings/StandardDirectoryListPreferenceFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/settings/StandardDirectoryListPreferenceFragment.kt @@ -33,9 +33,8 @@ class StandardDirectoryListPreferenceFragment : PreferenceFragmentCompat(), private fun onStandardDirectoriesChanged( standardDirectories: List ) { - val preferenceManager = preferenceManager - val context = preferenceManager.context var preferenceScreen = preferenceScreen + val context = requireContext() val oldPreferences = mutableMapOf() if (preferenceScreen == null) { preferenceScreen = preferenceManager.createPreferenceScreen(context) diff --git a/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeActivity.kt b/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeActivity.kt index 9d5b378f7..45c4074f7 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeActivity.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeActivity.kt @@ -9,22 +9,15 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.commit import me.zhanghai.android.files.app.AppActivity -import me.zhanghai.android.files.util.args -import me.zhanghai.android.files.util.putArgs class AddDocumentTreeActivity : AppActivity() { - private val args by args() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Calls ensureSubDecor(). findViewById(android.R.id.content) if (savedInstanceState == null) { - val fragment = AddDocumentTreeFragment().putArgs(args) - supportFragmentManager.commit { - add(fragment, AddDocumentTreeFragment::class.java.name) - } + supportFragmentManager.commit { add(android.R.id.content, AddDocumentTreeFragment()) } } } } diff --git a/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeFragment.kt index d95630a8a..83b6a1bf4 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/AddDocumentTreeFragment.kt @@ -8,14 +8,10 @@ package me.zhanghai.android.files.storage import android.net.Uri import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.StringRes import androidx.fragment.app.Fragment -import kotlinx.parcelize.Parcelize import me.zhanghai.android.files.file.DocumentTreeUri import me.zhanghai.android.files.file.asDocumentTreeUriOrNull import me.zhanghai.android.files.file.takePersistablePermission -import me.zhanghai.android.files.util.ParcelableArgs -import me.zhanghai.android.files.util.args import me.zhanghai.android.files.util.finish import me.zhanghai.android.files.util.launchSafe @@ -24,13 +20,11 @@ class AddDocumentTreeFragment : Fragment() { ActivityResultContracts.OpenDocumentTree(), this::onOpenDocumentTreeResult ) - private val args by args() - override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) if (savedInstanceState == null) { - openDocumentTreeLauncher.launchSafe(args.treeUri?.value, this) + openDocumentTreeLauncher.launchSafe(null, this) } } @@ -44,13 +38,7 @@ class AddDocumentTreeFragment : Fragment() { private fun addDocumentTree(treeUri: DocumentTreeUri) { treeUri.takePersistablePermission() - val documentTree = DocumentTree(null, args.customNameRes?.let { getString(it) }, treeUri) + val documentTree = DocumentTree(null, null, treeUri) Storages.addOrReplace(documentTree) } - - @Parcelize - class Args( - @StringRes val customNameRes: Int?, - val treeUri: DocumentTreeUri? - ) : ParcelableArgs } diff --git a/app/src/main/java/me/zhanghai/android/files/storage/AddExternalStorageShortcutActivity.kt b/app/src/main/java/me/zhanghai/android/files/storage/AddExternalStorageShortcutActivity.kt new file mode 100644 index 000000000..0f8a68db1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/AddExternalStorageShortcutActivity.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.commit +import me.zhanghai.android.files.app.AppActivity +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.putArgs + +class AddExternalStorageShortcutActivity : AppActivity() { + private val args by args() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Calls ensureSubDecor(). + findViewById(android.R.id.content) + if (savedInstanceState == null) { + val fragment = AddExternalStorageShortcutFragment().putArgs(args) + supportFragmentManager.commit { + add(fragment, AddExternalStorageShortcutFragment::class.java.name) + } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/AddExternalStorageShortcutFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/AddExternalStorageShortcutFragment.kt new file mode 100644 index 000000000..6674ab050 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/AddExternalStorageShortcutFragment.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.R +import me.zhanghai.android.files.app.packageManager +import me.zhanghai.android.files.file.ExternalStorageUri +import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.createDocumentsUiViewDirectoryIntent +import me.zhanghai.android.files.util.finish +import me.zhanghai.android.files.util.showToast + +class AddExternalStorageShortcutFragment : Fragment() { + private val args by args() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + val uri = args.uri + val hasDocumentsUi = uri.value.createDocumentsUiViewDirectoryIntent() + .resolveActivity(packageManager) != null + if (hasDocumentsUi) { + val externalStorageShortcut = ExternalStorageShortcut( + null, args.customNameRes?.let { getString(it) }, uri + ) + Storages.addOrReplace(externalStorageShortcut) + } else { + showToast(R.string.activity_not_found) + } + finish() + } + + @Parcelize + class Args( + @StringRes val customNameRes: Int?, + val uri: ExternalStorageUri + ) : ParcelableArgs +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/AddStorageDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/AddStorageDialogFragment.kt index 3baf24178..74a3d568f 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/AddStorageDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/AddStorageDialogFragment.kt @@ -12,7 +12,7 @@ import android.os.Bundle import androidx.appcompat.app.AppCompatDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import me.zhanghai.android.files.R -import me.zhanghai.android.files.file.asDocumentTreeUri +import me.zhanghai.android.files.file.asExternalStorageUri import me.zhanghai.android.files.provider.document.resolver.ExternalStorageProviderHacks import me.zhanghai.android.files.util.createIntent import me.zhanghai.android.files.util.finish @@ -41,35 +41,36 @@ class AddStorageDialogFragment : AppCompatDialogFragment() { companion object { private val STORAGE_TYPES = listOfNotNull( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - (R.string.storage_add_storage_android_data - to AddDocumentTreeActivity ::class.createIntent() - .putArgs( - AddDocumentTreeFragment.Args( + R.string.storage_add_storage_android_data to + AddExternalStorageShortcutActivity ::class.createIntent().putArgs( + AddExternalStorageShortcutFragment.Args( R.string.storage_add_storage_android_data, ExternalStorageProviderHacks.DOCUMENT_URI_ANDROID_DATA - .asDocumentTreeUri() + .asExternalStorageUri() ) - )) + ) } else null, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - (R.string.storage_add_storage_android_obb - to AddDocumentTreeActivity ::class.createIntent() - .putArgs( - AddDocumentTreeFragment.Args( + R.string.storage_add_storage_android_obb to + AddExternalStorageShortcutActivity ::class.createIntent().putArgs( + AddExternalStorageShortcutFragment.Args( R.string.storage_add_storage_android_obb, ExternalStorageProviderHacks.DOCUMENT_URI_ANDROID_OBB - .asDocumentTreeUri() + .asExternalStorageUri() ) - )) + ) } else null, - R.string.storage_add_storage_document_tree - to AddDocumentTreeActivity::class.createIntent() - .putArgs(AddDocumentTreeFragment.Args(null, null)), - R.string.storage_add_storage_ftp_server to EditFtpServerActivity::class.createIntent() - .putArgs(EditFtpServerFragment.Args()), - R.string.storage_add_storage_sftp_server to EditSftpServerActivity::class.createIntent() - .putArgs(EditSftpServerFragment.Args()), - R.string.storage_add_storage_smb_server to AddLanSmbServerActivity::class.createIntent() + R.string.storage_add_storage_document_tree to + AddDocumentTreeActivity::class.createIntent(), + R.string.storage_add_storage_ftp_server to + EditFtpServerActivity::class.createIntent().putArgs(EditFtpServerFragment.Args()), + R.string.storage_add_storage_sftp_server to + EditSftpServerActivity::class.createIntent().putArgs(EditSftpServerFragment.Args()), + R.string.storage_add_storage_smb_server to + AddLanSmbServerActivity::class.createIntent(), + R.string.storage_add_storage_webdav_server to + EditWebDavServerActivity::class.createIntent() + .putArgs(EditWebDavServerFragment.Args()), ) } } diff --git a/app/src/main/java/me/zhanghai/android/files/storage/DocumentTree.kt b/app/src/main/java/me/zhanghai/android/files/storage/DocumentTree.kt index 973a7930c..019d44789 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/DocumentTree.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/DocumentTree.kt @@ -8,7 +8,7 @@ package me.zhanghai.android.files.storage import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.os.Build +import android.os.Environment import androidx.annotation.DrawableRes import java8.nio.file.Path import kotlinx.parcelize.Parcelize @@ -22,6 +22,7 @@ import me.zhanghai.android.files.file.storageVolume import me.zhanghai.android.files.provider.document.createDocumentTreeRootPath import me.zhanghai.android.files.util.createIntent import me.zhanghai.android.files.util.putArgs +import me.zhanghai.android.files.util.supportsExternalStorageManager import kotlin.random.Random @Parcelize @@ -42,8 +43,8 @@ data class DocumentTree( // android.os.storage.StorageVolume#equals [NewApi] @SuppressLint("NewApi") get() = - // We are using MANAGE_EXTERNAL_STORAGE to access all storage volumes since R. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R + // We are using MANAGE_EXTERNAL_STORAGE to access all storage volumes when supported. + if (!Environment::class.supportsExternalStorageManager() && uri.storageVolume.let { it != null && !it.isPrimaryCompat }) { R.drawable.sd_card_icon_white_24dp } else { @@ -51,7 +52,8 @@ data class DocumentTree( } override fun getDefaultName(context: Context): String = - uri.storageVolume?.getDescriptionCompat(context) ?: uri.displayName ?: uri.value.toString() + uri.storageVolume?.getDescriptionCompat(context) ?: uri.displayName + ?: uri.value.lastPathSegment ?: uri.value.toString() override val description: String get() = uri.value.toString() diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditDocumentTreeDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditDocumentTreeDialogFragment.kt index ef9152a43..1b467b583 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/EditDocumentTreeDialogFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditDocumentTreeDialogFragment.kt @@ -40,8 +40,7 @@ class EditDocumentTreeDialogFragment : AppCompatDialogFragment() { documentTree.getName(binding.nameEdit.context) ) } - val uri = documentTree.uri.value - binding.uriText.setText(uri.toString()) + binding.uriText.setText(documentTree.uri.value.toString()) val linuxPath = documentTree.linuxPath binding.pathLayout.isVisible = linuxPath != null binding.pathText.setText(linuxPath) diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditExternalStorageShortcutDialogActivity.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditExternalStorageShortcutDialogActivity.kt new file mode 100644 index 000000000..30da5915c --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditExternalStorageShortcutDialogActivity.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.commit +import me.zhanghai.android.files.app.AppActivity +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.putArgs + +class EditExternalStorageShortcutDialogActivity : AppActivity() { + private val args by args() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Calls ensureSubDecor(). + findViewById(android.R.id.content) + if (savedInstanceState == null) { + val fragment = EditExternalStorageShortcutDialogFragment().putArgs(args) + supportFragmentManager.commit { + add(fragment, EditExternalStorageShortcutDialogFragment::class.java.name) + } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditExternalStorageShortcutDialogFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditExternalStorageShortcutDialogFragment.kt new file mode 100644 index 000000000..37cd7cf66 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditExternalStorageShortcutDialogFragment.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.R +import me.zhanghai.android.files.compat.DocumentsContractCompat +import me.zhanghai.android.files.databinding.EditExternalStorageShortcutDialogBinding +import me.zhanghai.android.files.file.ExternalStorageUri +import me.zhanghai.android.files.file.displayName +import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.finish +import me.zhanghai.android.files.util.hideTextInputLayoutErrorOnTextChange +import me.zhanghai.android.files.util.layoutInflater +import me.zhanghai.android.files.util.setTextWithSelection +import me.zhanghai.android.files.util.takeIfNotEmpty + +class EditExternalStorageShortcutDialogFragment : AppCompatDialogFragment() { + private val args by args() + + private lateinit var binding: EditExternalStorageShortcutDialogBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + MaterialAlertDialogBuilder(requireContext(), theme) + .setTitle(R.string.storage_edit_external_storage_shortcut_title) + .apply { + binding = EditExternalStorageShortcutDialogBinding.inflate(context.layoutInflater) + val externalStorageShortcut = args.externalStorageShortcut + binding.rootIdEdit.hideTextInputLayoutErrorOnTextChange(binding.rootIdLayout) + binding.rootIdEdit.doAfterTextChanged { updateNamePlaceholder() } + binding.pathEdit.doAfterTextChanged { updateNamePlaceholder() } + if (savedInstanceState == null) { + binding.nameEdit.setTextWithSelection( + externalStorageShortcut.getName(binding.nameEdit.context) + ) + val uri = externalStorageShortcut.uri + binding.rootIdEdit.setText(uri.rootId) + binding.pathEdit.setText(uri.path) + } + setView(binding.root) + } + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + .setNeutralButton(R.string.remove) { _, _ -> remove() } + .create() + .apply { + window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + // Override the listener here so that we have control over when to close the dialog. + setOnShowListener { + getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { save() } + } + } + + private fun updateNamePlaceholder() { + val rootId = binding.rootIdEdit.text.toString().takeIfNotEmpty() + ?: DocumentsContractCompat.EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID + val path = binding.pathEdit.text.toString().dropWhile { it == '/' } + binding.nameLayout.placeholderText = ExternalStorageUri(rootId, path).displayName + } + + private fun save() { + val externalStorageShortcut = getExternalStorageShortcutOrSetError() ?: return + Storages.replace(externalStorageShortcut) + finish() + } + + private fun getExternalStorageShortcutOrSetError(): ExternalStorageShortcut? { + var errorEdit: TextInputEditText? = null + val customName = binding.nameEdit.text.toString() + .takeIf { it.isNotEmpty() && it != binding.nameLayout.placeholderText } + val rootId = binding.rootIdEdit.text.toString().takeIfNotEmpty() + if (rootId == null) { + binding.rootIdLayout.error = + getString(R.string.storage_edit_external_storage_shortcut_root_id_error_empty) + if (errorEdit == null) { + errorEdit = binding.rootIdEdit + } + } + val path = binding.pathEdit.text.toString().dropWhile { it == '/' } + val uri = rootId?.let { ExternalStorageUri(rootId, path) } + if (errorEdit != null) { + errorEdit.requestFocus() + return null + } + return ExternalStorageShortcut(args.externalStorageShortcut.id, customName, uri!!) + } + + private fun remove() { + Storages.remove(args.externalStorageShortcut) + finish() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + finish() + } + + @Parcelize + class Args(val externalStorageShortcut: ExternalStorageShortcut) : ParcelableArgs +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditFtpServerFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditFtpServerFragment.kt index b8982c5a3..9a99887e2 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/EditFtpServerFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditFtpServerFragment.kt @@ -207,7 +207,7 @@ class EditFtpServerFragment : Fragment() { val items = List(adapter.count) { adapter.getItem(it) as CharSequence } val selectedItem = binding.protocolEdit.text val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } - return Protocol.values()[selectedIndex] + return Protocol.entries[selectedIndex] } set(value) { val adapter = binding.protocolEdit.adapter @@ -221,7 +221,7 @@ class EditFtpServerFragment : Fragment() { val items = List(adapter.count) { adapter.getItem(it) as CharSequence } val selectedItem = binding.authenticationTypeEdit.text val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } - return AuthenticationType.values()[selectedIndex] + return AuthenticationType.entries[selectedIndex] } set(value) { val adapter = binding.authenticationTypeEdit.adapter @@ -241,7 +241,7 @@ class EditFtpServerFragment : Fragment() { val items = List(adapter.count) { adapter.getItem(it) as CharSequence } val selectedItem = binding.modeEdit.text val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } - return Mode.values()[selectedIndex] + return Mode.entries[selectedIndex] } set(value) { val adapter = binding.modeEdit.adapter @@ -311,6 +311,7 @@ class EditFtpServerFragment : Fragment() { private fun getServerOrSetError(): FtpServer? { var errorEdit: TextInputEditText? = null val host = binding.hostEdit.text.toString().takeIfNotEmpty() + ?.let { URI::class.canonicalizeHost(it) } if (host == null) { binding.hostLayout.error = getString(R.string.storage_edit_ftp_server_host_error_empty) if (errorEdit == null) { diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditSftpServerFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditSftpServerFragment.kt index 079fc2a18..3626fc48e 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/EditSftpServerFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditSftpServerFragment.kt @@ -43,8 +43,8 @@ import me.zhanghai.android.files.util.viewModels import java.net.URI class EditSftpServerFragment : Fragment() { - private val pickPrivateKeyFileLauncher = registerForActivityResult( - FileListActivity.PickFileContract(), this::onPickPrivateKeyFileResult + private val openPrivateKeyFileLauncher = registerForActivityResult( + FileListActivity.OpenFileContract(), this::onOpenPrivateKeyFileResult ) private val args by args() @@ -147,9 +147,8 @@ class EditSftpServerFragment : Fragment() { if (authority.port != Authority.DEFAULT_PORT) { binding.portEdit.setText(authority.port.toString()) } - val authentication = server.authentication binding.usernameEdit.setText(authority.username) - when (authentication) { + when (val authentication = server.authentication) { is PasswordAuthentication -> { authenticationType = AuthenticationType.PASSWORD binding.passwordEdit.setText(authentication.password) @@ -186,7 +185,7 @@ class EditSftpServerFragment : Fragment() { val items = List(adapter.count) { adapter.getItem(it) as CharSequence } val selectedItem = binding.authenticationTypeEdit.text val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } - return AuthenticationType.values()[selectedIndex] + return AuthenticationType.entries[selectedIndex] } set(value) { val adapter = binding.authenticationTypeEdit.adapter @@ -205,10 +204,10 @@ class EditSftpServerFragment : Fragment() { if (!viewModel.readPrivateKeyFileState.value.isReady) { return } - pickPrivateKeyFileLauncher.launchSafe(listOf(MimeType.ANY), this) + openPrivateKeyFileLauncher.launchSafe(listOf(MimeType.ANY), this) } - private fun onPickPrivateKeyFileResult(result: Path?) { + private fun onOpenPrivateKeyFileResult(result: Path?) { result ?: return viewModel.readPrivateKeyFile(result) } @@ -280,6 +279,7 @@ class EditSftpServerFragment : Fragment() { private fun getServerOrSetError(): SftpServer? { var errorEdit: TextInputEditText? = null val host = binding.hostEdit.text.toString().takeIfNotEmpty() + ?.let { URI::class.canonicalizeHost(it) } if (host == null) { binding.hostLayout.error = getString(R.string.storage_edit_sftp_server_host_error_empty) @@ -314,7 +314,7 @@ class EditSftpServerFragment : Fragment() { val authentication = when (authenticationType) { AuthenticationType.PASSWORD -> { val password = binding.passwordEdit.text.toString() - if (errorEdit == null) PasswordAuthentication(password) else null + PasswordAuthentication(password) } AuthenticationType.PUBLIC_KEY -> { val privateKey = binding.privateKeyEdit.text.toString().takeIfNotEmpty() diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditSmbServerFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditSmbServerFragment.kt index bd81ed376..d6a71864f 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/EditSmbServerFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditSmbServerFragment.kt @@ -197,7 +197,7 @@ class EditSmbServerFragment : Fragment() { val items = List(adapter.count) { adapter.getItem(it) as CharSequence } val selectedItem = binding.authenticationTypeEdit.text val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } - return AuthenticationType.values()[selectedIndex] + return AuthenticationType.entries[selectedIndex] } set(value) { val adapter = binding.authenticationTypeEdit.adapter @@ -258,6 +258,7 @@ class EditSmbServerFragment : Fragment() { private fun getServerOrSetError(): SmbServer? { var errorEdit: TextInputEditText? = null val host = binding.hostEdit.text.toString().takeIfNotEmpty() + ?.let { URI::class.canonicalizeHost(it) } if (host == null) { binding.hostLayout.error = getString(R.string.storage_edit_smb_server_host_error_empty) if (errorEdit == null) { diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerActivity.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerActivity.kt new file mode 100644 index 000000000..566ac2abe --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerActivity.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.commit +import me.zhanghai.android.files.app.AppActivity +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.putArgs + +class EditWebDavServerActivity : AppActivity() { + private val args by args() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Calls ensureSubDecor(). + findViewById(android.R.id.content) + if (savedInstanceState == null) { + val fragment = EditWebDavServerFragment().putArgs(args) + supportFragmentManager.commit { add(android.R.id.content, fragment) } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerFragment.kt new file mode 100644 index 000000000..43a7025e1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerFragment.kt @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.app.Activity +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.textfield.TextInputEditText +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.R +import me.zhanghai.android.files.databinding.EditWebdavServerFragmentBinding +import me.zhanghai.android.files.provider.webdav.client.AccessTokenAuthentication +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.provider.webdav.client.NoneAuthentication +import me.zhanghai.android.files.provider.webdav.client.PasswordAuthentication +import me.zhanghai.android.files.provider.webdav.client.Protocol +import me.zhanghai.android.files.ui.UnfilteredArrayAdapter +import me.zhanghai.android.files.util.ActionState +import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.args +import me.zhanghai.android.files.util.fadeToVisibilityUnsafe +import me.zhanghai.android.files.util.finish +import me.zhanghai.android.files.util.getTextArray +import me.zhanghai.android.files.util.hideTextInputLayoutErrorOnTextChange +import me.zhanghai.android.files.util.isReady +import me.zhanghai.android.files.util.setResult +import me.zhanghai.android.files.util.showToast +import me.zhanghai.android.files.util.takeIfNotEmpty +import me.zhanghai.android.files.util.viewModels +import java.net.URI + +class EditWebDavServerFragment : Fragment() { + private val args by args() + + private val viewModel by viewModels { { EditWebDavServerViewModel() } } + + private lateinit var binding: EditWebdavServerFragmentBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launchWhenStarted { + launch { viewModel.connectState.collect { onConnectStateChanged(it) } } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = + EditWebdavServerFragmentBinding.inflate(inflater, container, false) + .also { binding = it } + .root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val activity = requireActivity() as AppCompatActivity + activity.lifecycleScope.launchWhenCreated { + activity.setSupportActionBar(binding.toolbar) + activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true) + activity.setTitle( + if (args.server != null) { + R.string.storage_edit_webdav_server_title_edit + } else { + R.string.storage_edit_webdav_server_title_add + } + ) + } + + binding.hostEdit.hideTextInputLayoutErrorOnTextChange(binding.hostLayout) + binding.hostEdit.doAfterTextChanged { updateNamePlaceholder() } + binding.portEdit.hideTextInputLayoutErrorOnTextChange(binding.portLayout) + binding.portEdit.doAfterTextChanged { updateNamePlaceholder() } + binding.pathEdit.doAfterTextChanged { updateNamePlaceholder() } + binding.protocolEdit.setAdapter( + UnfilteredArrayAdapter( + binding.protocolEdit.context, R.layout.dropdown_item, + objects = getTextArray(R.array.storage_edit_webdav_server_protocol_entries) + ) + ) + protocol = Protocol.DAVS + binding.protocolEdit.doAfterTextChanged { + updateNamePlaceholder() + updatePortPlaceholder() + } + binding.authenticationTypeEdit.setAdapter( + UnfilteredArrayAdapter( + binding.authenticationTypeEdit.context, R.layout.dropdown_item, + objects = + getTextArray(R.array.storage_edit_webdav_server_authentication_type_entries) + ) + ) + authenticationType = AuthenticationType.PASSWORD + binding.authenticationTypeEdit.doAfterTextChanged { + onAuthenticationTypeChanged(authenticationType) + updateNamePlaceholder() + } + binding.usernameEdit.hideTextInputLayoutErrorOnTextChange(binding.usernameLayout) + binding.usernameEdit.doAfterTextChanged { updateNamePlaceholder() } + binding.saveOrConnectAndAddButton.setText( + if (args.server != null) { + R.string.save + } else { + R.string.storage_edit_webdav_server_connect_and_add + } + ) + binding.saveOrConnectAndAddButton.setOnClickListener { + if (args.server != null) { + saveOrAdd() + } else { + connectAndAdd() + } + } + binding.cancelButton.setOnClickListener { finish() } + binding.removeOrAddButton.setText( + if (args.server != null) R.string.remove else R.string.storage_edit_webdav_server_add + ) + binding.removeOrAddButton.setOnClickListener { + if (args.server != null) { + remove() + } else { + saveOrAdd() + } + } + + if (savedInstanceState == null) { + val server = args.server + if (server != null) { + val authority = server.authority + binding.hostEdit.setText(authority.host) + protocol = authority.protocol + if (authority.port != protocol.defaultPort) { + binding.portEdit.setText(authority.port.toString()) + } + when (val authentication = server.authentication) { + is PasswordAuthentication -> { + authenticationType = AuthenticationType.PASSWORD + binding.usernameEdit.setText(authority.username) + binding.passwordEdit.setText(authentication.password) + } + is AccessTokenAuthentication -> { + authenticationType = AuthenticationType.ACCESS_TOKEN + binding.accessTokenEdit.setText(authentication.accessToken) + } + is NoneAuthentication -> authenticationType = AuthenticationType.NONE + } + binding.pathEdit.setText(server.relativePath) + binding.nameEdit.setText(server.customName) + } else { + val host = args.host + if (host != null) { + binding.hostEdit.setText(host) + } + } + } + } + + private fun updateNamePlaceholder() { + val host = binding.hostEdit.text.toString().takeIfNotEmpty() + val port = binding.portEdit.text.toString().takeIfNotEmpty()?.toIntOrNull() + ?: protocol.defaultPort + val path = binding.pathEdit.text.toString().trim() + val username = if (authenticationType == AuthenticationType.PASSWORD) { + binding.usernameEdit.text.toString() + } else { + "" + } + binding.nameLayout.placeholderText = if (host != null) { + val authority = Authority(protocol, host, port, username) + if (path.isNotEmpty()) "$authority/$path" else authority.toString() + } else { + getString(R.string.storage_edit_webdav_server_name_placeholder) + } + } + + private fun updatePortPlaceholder() { + binding.portLayout.placeholderText = protocol.defaultPort.toString() + } + + private var protocol: Protocol + get() { + val adapter = binding.protocolEdit.adapter + val items = List(adapter.count) { adapter.getItem(it) as CharSequence } + val selectedItem = binding.protocolEdit.text + val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } + return Protocol.entries[selectedIndex] + } + set(value) { + val adapter = binding.protocolEdit.adapter + val item = adapter.getItem(value.ordinal) as CharSequence + binding.protocolEdit.setText(item, false) + } + + private var authenticationType: AuthenticationType + get() { + val adapter = binding.authenticationTypeEdit.adapter + val items = List(adapter.count) { adapter.getItem(it) as CharSequence } + val selectedItem = binding.authenticationTypeEdit.text + val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) } + return AuthenticationType.entries[selectedIndex] + } + set(value) { + val adapter = binding.authenticationTypeEdit.adapter + val item = adapter.getItem(value.ordinal) as CharSequence + binding.authenticationTypeEdit.setText(item, false) + onAuthenticationTypeChanged(value) + } + + private fun onAuthenticationTypeChanged(authenticationType: AuthenticationType) { + binding.passwordAuthenticationLayout.isVisible = + authenticationType == AuthenticationType.PASSWORD + binding.accessTokenLayout.isVisible = authenticationType == AuthenticationType.ACCESS_TOKEN + } + + private fun saveOrAdd() { + val server = getServerOrSetError() ?: return + Storages.addOrReplace(server) + setResult(Activity.RESULT_OK) + finish() + } + + private fun connectAndAdd() { + if (!viewModel.connectState.value.isReady) { + return + } + val server = getServerOrSetError() ?: return + viewModel.connect(server) + } + + private fun onConnectStateChanged(state: ActionState) { + when (state) { + is ActionState.Ready, is ActionState.Running -> { + val isConnecting = state is ActionState.Running + binding.progress.fadeToVisibilityUnsafe(isConnecting) + binding.scrollView.fadeToVisibilityUnsafe(!isConnecting) + binding.saveOrConnectAndAddButton.isEnabled = !isConnecting + binding.removeOrAddButton.isEnabled = !isConnecting + } + is ActionState.Success -> { + Storages.addOrReplace(state.argument) + setResult(Activity.RESULT_OK) + finish() + } + is ActionState.Error -> { + val throwable = state.throwable + throwable.printStackTrace() + showToast(throwable.toString()) + viewModel.finishConnecting() + } + } + } + + private fun remove() { + Storages.remove(args.server!!) + setResult(Activity.RESULT_OK) + finish() + } + + private fun getServerOrSetError(): WebDavServer? { + var errorEdit: TextInputEditText? = null + val host = binding.hostEdit.text.toString().takeIfNotEmpty() + ?.let { URI::class.canonicalizeHost(it) } + if (host == null) { + binding.hostLayout.error = getString(R.string.storage_edit_webdav_server_host_error_empty) + if (errorEdit == null) { + errorEdit = binding.hostEdit + } + } else if (!URI::class.isValidHost(host)) { + binding.hostLayout.error = + getString(R.string.storage_edit_webdav_server_host_error_invalid) + if (errorEdit == null) { + errorEdit = binding.hostEdit + } + } + val port = binding.portEdit.text.toString().takeIfNotEmpty() + .let { if (it != null) it.toIntOrNull() else protocol.defaultPort } + if (port == null) { + binding.portLayout.error = + getString(R.string.storage_edit_webdav_server_port_error_invalid) + if (errorEdit == null) { + errorEdit = binding.portEdit + } + } + val path = binding.pathEdit.text.toString().trim() + val name = binding.nameEdit.text.toString().takeIfNotEmpty() + val (username, authentication) = when (authenticationType) { + AuthenticationType.PASSWORD -> { + val username = binding.usernameEdit.text.toString().takeIfNotEmpty() + if (username == null) { + binding.usernameLayout.error = + getString(R.string.storage_edit_webdav_server_username_error_empty) + if (errorEdit == null) { + errorEdit = binding.usernameEdit + } + } + val password = binding.passwordEdit.text.toString() + username to PasswordAuthentication(password) + } + AuthenticationType.ACCESS_TOKEN -> { + val accessToken = binding.accessTokenEdit.text.toString().takeIfNotEmpty() + if (accessToken == null) { + binding.accessTokenLayout.error = + getString(R.string.storage_edit_webdav_server_access_token_error_empty) + if (errorEdit == null) { + errorEdit = binding.accessTokenEdit + } + } + "" to accessToken?.let { AccessTokenAuthentication(it) } + } + AuthenticationType.NONE -> "" to NoneAuthentication + } + if (errorEdit != null) { + errorEdit.requestFocus() + return null + } + val authority = Authority(protocol, host!!, port!!, username!!) + return WebDavServer(args.server?.id, name, authority, authentication!!, path) + } + + @Parcelize + class Args( + val server: WebDavServer? = null, + val host: String? = null + ) : ParcelableArgs + + private enum class AuthenticationType { + PASSWORD, + ACCESS_TOKEN, + NONE + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerViewModel.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerViewModel.kt new file mode 100644 index 000000000..54924576d --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerViewModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import me.zhanghai.android.files.provider.common.newDirectoryStream +import me.zhanghai.android.files.util.ActionState +import me.zhanghai.android.files.util.isFinished +import me.zhanghai.android.files.util.isReady + +class EditWebDavServerViewModel : ViewModel() { + private val _connectState = + MutableStateFlow>(ActionState.Ready()) + val connectState = _connectState.asStateFlow() + + fun connect(server: WebDavServer) { + viewModelScope.launch { + check(_connectState.value.isReady) + _connectState.value = ActionState.Running(server) + _connectState.value = try { + runInterruptible(Dispatchers.IO) { + WebDavServerAuthenticator.addTransientServer(server) + try { + val path = server.path + path.fileSystem.use { + path.newDirectoryStream().toList() + } + } finally { + WebDavServerAuthenticator.removeTransientServer(server) + } + } + ActionState.Success(server, Unit) + } catch (e: Exception) { + ActionState.Error(server, e) + } + } + } + + fun finishConnecting() { + viewModelScope.launch { + check(_connectState.value.isFinished) + _connectState.value = ActionState.Ready() + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/ExternalStorageShortcut.kt b/app/src/main/java/me/zhanghai/android/files/storage/ExternalStorageShortcut.kt new file mode 100644 index 000000000..0aee55cd8 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/ExternalStorageShortcut.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.content.Context +import android.content.Intent +import java8.nio.file.Path +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.file.ExternalStorageUri +import me.zhanghai.android.files.file.displayName +import me.zhanghai.android.files.util.createDocumentsUiViewDirectoryIntent +import me.zhanghai.android.files.util.createIntent +import me.zhanghai.android.files.util.putArgs +import kotlin.random.Random + +@Parcelize +data class ExternalStorageShortcut( + override val id: Long, + override val customName: String?, + val uri: ExternalStorageUri +) : Storage() { + constructor( + id: Long?, + customName: String?, + uri: ExternalStorageUri + ) : this(id ?: Random.nextLong(), customName, uri) + + override fun getDefaultName(context: Context): String = uri.displayName + + override val description: String + get() = uri.value.toString() + + override val path: Path? + get() = null + + override fun createIntent(): Intent = uri.value.createDocumentsUiViewDirectoryIntent() + + override fun createEditIntent(): Intent = + EditExternalStorageShortcutDialogActivity::class.createIntent() + .putArgs(EditExternalStorageShortcutDialogFragment.Args(this)) +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/Storage.kt b/app/src/main/java/me/zhanghai/android/files/storage/Storage.kt index 1bb8e8f9b..4bb08c4d0 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/Storage.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/Storage.kt @@ -28,11 +28,13 @@ abstract class Storage : Parcelable { abstract val description: String - abstract val path: Path + abstract val path: Path? open val linuxPath: String? = null open val isVisible: Boolean = true + open fun createIntent(): Intent? = null + abstract fun createEditIntent(): Intent } diff --git a/app/src/main/java/me/zhanghai/android/files/storage/StorageListFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/StorageListFragment.kt index 29ecd36b0..efec2f453 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/StorageListFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/StorageListFragment.kt @@ -17,9 +17,9 @@ import androidx.recyclerview.widget.RecyclerView import com.h6ah4i.android.widget.advrecyclerview.animator.DraggableItemAnimator import com.h6ah4i.android.widget.advrecyclerview.draggable.RecyclerViewDragDropManager import com.h6ah4i.android.widget.advrecyclerview.utils.WrapperAdapterUtils -import me.zhanghai.android.files.R import me.zhanghai.android.files.databinding.StorageListFragmentBinding import me.zhanghai.android.files.settings.Settings +import me.zhanghai.android.files.ui.ScrollingViewOnApplyWindowInsetsListener import me.zhanghai.android.files.util.createIntent import me.zhanghai.android.files.util.fadeToVisibilityUnsafe import me.zhanghai.android.files.util.getDrawable @@ -53,13 +53,18 @@ class StorageListFragment : Fragment(), StorageListAdapter.Listener { adapter = StorageListAdapter(this) dragDropManager = RecyclerViewDragDropManager().apply { setDraggingItemShadowDrawable( - getDrawable(R.drawable.ms9_composite_shadow_z2) as NinePatchDrawable + getDrawable( + com.h6ah4i.android.materialshadowninepatch.R.drawable.ms9_composite_shadow_z2 + ) as NinePatchDrawable ) } wrappedAdapter = dragDropManager.createWrappedAdapter(adapter) binding.recyclerView.adapter = wrappedAdapter binding.recyclerView.itemAnimator = DraggableItemAnimator() dragDropManager.attachRecyclerView(binding.recyclerView) + binding.recyclerView.setOnApplyWindowInsetsListener( + ScrollingViewOnApplyWindowInsetsListener(binding.recyclerView) + ) binding.fab.setOnClickListener { onAddStorage() } Settings.STORAGES.observe(viewLifecycleOwner) { onStorageListChanged(it) } diff --git a/app/src/main/java/me/zhanghai/android/files/storage/StorageVolumeListLiveData.kt b/app/src/main/java/me/zhanghai/android/files/storage/StorageVolumeListLiveData.kt index 222169a8c..ab20841c9 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/StorageVolumeListLiveData.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/StorageVolumeListLiveData.kt @@ -11,15 +11,17 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.storage.StorageVolume +import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import me.zhanghai.android.files.app.application import me.zhanghai.android.files.app.storageManager +import me.zhanghai.android.files.compat.registerReceiverCompat import me.zhanghai.android.files.compat.storageVolumesCompat object StorageVolumeListLiveData : LiveData>() { init { loadValue() - application.registerReceiver( + application.registerReceiverCompat( object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { loadValue() @@ -36,7 +38,7 @@ object StorageVolumeListLiveData : LiveData>() { // The "file" data scheme is required to receive these broadcasts. // @see https://stackoverflow.com/a/7143298 addDataScheme(ContentResolver.SCHEME_FILE) - } + }, ContextCompat.RECEIVER_NOT_EXPORTED ) } diff --git a/app/src/main/java/me/zhanghai/android/files/storage/URIExtensions.kt b/app/src/main/java/me/zhanghai/android/files/storage/URIExtensions.kt index 3d2147ac0..706f6d6be 100644 --- a/app/src/main/java/me/zhanghai/android/files/storage/URIExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/storage/URIExtensions.kt @@ -4,6 +4,10 @@ import java.net.URI import java.net.URISyntaxException import kotlin.reflect.KClass +// @see URI.appendAuthority +fun KClass.canonicalizeHost(host: String): String = + if (host.contains(':') && !host.startsWith('[') && !host.endsWith(']')) "[$host]" else host + fun KClass.createOrLog(uri: String): URI? = try { URI(uri) diff --git a/app/src/main/java/me/zhanghai/android/files/storage/WebDavServer.kt b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServer.kt new file mode 100644 index 000000000..f15719bac --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServer.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import android.content.Context +import android.content.Intent +import androidx.annotation.DrawableRes +import java8.nio.file.Path +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.R +import me.zhanghai.android.files.provider.webdav.client.Authentication +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.provider.webdav.createWebDavRootPath +import me.zhanghai.android.files.util.createIntent +import me.zhanghai.android.files.util.putArgs +import kotlin.random.Random + +@Parcelize +class WebDavServer( + override val id: Long, + override val customName: String?, + val authority: Authority, + val authentication: Authentication, + val relativePath: String +) : Storage() { + constructor( + id: Long?, + customName: String?, + authority: Authority, + authentication: Authentication, + relativePath: String + ) : this(id ?: Random.nextLong(), customName, authority, authentication, relativePath) + + override val iconRes: Int + @DrawableRes + get() = R.drawable.computer_icon_white_24dp + + override fun getDefaultName(context: Context): String = + if (relativePath.isNotEmpty()) "$authority/$relativePath" else authority.toString() + + override val description: String + get() = authority.toString() + + override val path: Path + get() = authority.createWebDavRootPath().resolve(relativePath) + + override fun createEditIntent(): Intent = + EditWebDavServerActivity::class.createIntent().putArgs(EditWebDavServerFragment.Args(this)) +} diff --git a/app/src/main/java/me/zhanghai/android/files/storage/WebDavServerAuthenticator.kt b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServerAuthenticator.kt new file mode 100644 index 000000000..93c59199d --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServerAuthenticator.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.storage + +import me.zhanghai.android.files.provider.webdav.client.Authentication +import me.zhanghai.android.files.provider.webdav.client.Authenticator +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.settings.Settings +import me.zhanghai.android.files.util.valueCompat + +object WebDavServerAuthenticator : Authenticator { + private val transientServers = mutableSetOf() + + override fun getAuthentication(authority: Authority): Authentication? { + val server = synchronized(transientServers) { + transientServers.find { it.authority == authority } + } ?: Settings.STORAGES.valueCompat.find { + it is WebDavServer && it.authority == authority + } as WebDavServer? + return server?.authentication + } + + fun addTransientServer(server: WebDavServer) { + synchronized(transientServers) { transientServers += server } + } + + fun removeTransientServer(server: WebDavServer) { + synchronized(transientServers) { transientServers -= server } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/terminal/Terminal.kt b/app/src/main/java/me/zhanghai/android/files/terminal/Terminal.kt index f27eb4ae1..ce5548321 100644 --- a/app/src/main/java/me/zhanghai/android/files/terminal/Terminal.kt +++ b/app/src/main/java/me/zhanghai/android/files/terminal/Terminal.kt @@ -9,12 +9,18 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.Uri +import me.zhanghai.android.files.app.packageManager import me.zhanghai.android.files.util.startActivitySafe object Terminal { fun open(path: String, context: Context) { + val componentName = + packageManager.queryIntentActivities(Intent(Intent.ACTION_SEND).setType("*/*"), 0) + .firstOrNull { it.activityInfo.name.endsWith(".TermHere") }?.activityInfo + ?.let { ComponentName(it.packageName, it.name) } + ?: ComponentName("jackpal.androidterm", "jackpal.androidterm.TermHere") val intent = Intent() - .setComponent(ComponentName("jackpal.androidterm", "jackpal.androidterm.TermHere")) + .setComponent(componentName) .setAction(Intent.ACTION_SEND) .putExtra(Intent.EXTRA_STREAM, Uri.parse(path)) context.startActivitySafe(intent) diff --git a/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColor.kt b/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColor.kt index fb9727903..410cd098d 100644 --- a/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColor.kt +++ b/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColor.kt @@ -23,5 +23,5 @@ enum class ThemeColor(@ColorRes val resourceId: Int) { MATERIAL_DEEP_ORANGE(R.color.material_deep_orange), MATERIAL_BROWN(R.color.material_brown), MATERIAL_GREY(R.color.material_grey), - MATERIAL_BLUE_GREY(R.color.material_blue_grey); + MATERIAL_BLUE_GREY(R.color.material_blue_grey) } diff --git a/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColorPreference.kt b/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColorPreference.kt index 973437030..07db243a5 100644 --- a/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColorPreference.kt +++ b/app/src/main/java/me/zhanghai/android/files/theme/custom/ThemeColorPreference.kt @@ -70,8 +70,7 @@ class ThemeColorPreference : BaseColorPreference { init { val context = context - entryValues = ThemeColor.values().map { context.getColorCompat(it.resourceId) } - .toIntArray() + entryValues = ThemeColor.entries.map { context.getColorCompat(it.resourceId) }.toIntArray() } override fun onGetDefaultValue(a: TypedArray, index: Int): Any? = diff --git a/app/src/main/java/me/zhanghai/android/files/theme/night/NightMode.kt b/app/src/main/java/me/zhanghai/android/files/theme/night/NightMode.kt index b7260cd6a..4194ae4c6 100644 --- a/app/src/main/java/me/zhanghai/android/files/theme/night/NightMode.kt +++ b/app/src/main/java/me/zhanghai/android/files/theme/night/NightMode.kt @@ -12,5 +12,5 @@ enum class NightMode(val value: Int) { OFF(AppCompatDelegate.MODE_NIGHT_NO), ON(AppCompatDelegate.MODE_NIGHT_YES), AUTO_TIME(AppCompatDelegate.MODE_NIGHT_AUTO_TIME), - AUTO_BATTERY(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY); + AUTO_BATTERY(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY) } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/AllowSoftInputHackAlertDialogCustomView.kt b/app/src/main/java/me/zhanghai/android/files/ui/AllowSoftInputHackAlertDialogCustomView.kt new file mode 100644 index 000000000..3a5f27ef5 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/ui/AllowSoftInputHackAlertDialogCustomView.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.annotation.AttrRes +import androidx.annotation.StyleRes +import androidx.core.view.isGone + +class AllowSoftInputHackAlertDialogCustomView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, + @StyleRes defStyleRes: Int = 0 +) : View(context, attrs, defStyleAttr, defStyleRes) { + override fun onCheckIsTextEditor(): Boolean = true + + // Called once during ViewGroup.addView(). + override fun hasFocus(): Boolean { + // Makes hasCustomPanel false in AlertController.setupView(). + (parent.parent as View).isGone = true + return super.hasFocus() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/ui/AnimatedListAdapter.kt b/app/src/main/java/me/zhanghai/android/files/ui/AnimatedListAdapter.kt index 4288a8158..1b2117fca 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/AnimatedListAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/AnimatedListAdapter.kt @@ -86,7 +86,7 @@ abstract class AnimatedListAdapter( private fun clearAnimation() { stopAnimation() recyclerView?.let { - for (index in 0 until it.childCount) { + for (index in 0.. + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.annotation.AttrRes +import androidx.annotation.StyleRes +import me.zhanghai.android.files.R +import me.zhanghai.android.files.compat.obtainStyledAttributesCompat +import me.zhanghai.android.files.compat.use +import kotlin.math.roundToInt + +class AspectRatioFrameLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, + @StyleRes defStyleRes: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) { + @SuppressLint("RestrictedApi") + var ratio: Float = context.obtainStyledAttributesCompat( + attrs, R.styleable.AspectRatioFrameLayout, defStyleAttr, defStyleRes + ).use { it.getFloat(R.styleable.AspectRatioFrameLayout_aspectRatio, 0f) } + set(value) { + if (field == value) { + return + } + field = value + requestLayout() + invalidate() + } + + fun setRatio(width: Float, height: Float) { + ratio = width / height + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val newWidthMeasureSpec: Int + val newHeightMeasureSpec: Int + if (ratio > 0) { + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + if (widthMode == MeasureSpec.EXACTLY) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = (width / ratio).roundToInt().coerceAtLeast(minimumHeight) + newWidthMeasureSpec = widthMeasureSpec + newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + } else { + val height = MeasureSpec.getSize(heightMeasureSpec) + val width = (ratio * height).roundToInt().coerceAtLeast(minimumWidth) + newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + newHeightMeasureSpec = heightMeasureSpec + } + } else { + newWidthMeasureSpec = widthMeasureSpec + newHeightMeasureSpec = heightMeasureSpec + } + super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/ui/AspectRatioImageView.kt b/app/src/main/java/me/zhanghai/android/files/ui/AspectRatioImageView.kt new file mode 100644 index 000000000..ec3c7e21a --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/ui/AspectRatioImageView.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.AttrRes +import com.google.android.material.imageview.ShapeableImageView +import me.zhanghai.android.files.R +import me.zhanghai.android.files.compat.obtainStyledAttributesCompat +import me.zhanghai.android.files.compat.use +import kotlin.math.roundToInt + +open class AspectRatioImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0 +) : ShapeableImageView(context, attrs, defStyleAttr) { + @SuppressLint("RestrictedApi") + var ratio: Float = context.obtainStyledAttributesCompat( + attrs, R.styleable.AspectRatioFrameLayout, defStyleAttr + ).use { it.getFloat(R.styleable.AspectRatioFrameLayout_aspectRatio, 0f) } + set(value) { + if (field == value) { + return + } + field = value + requestLayout() + invalidate() + } + + fun setRatio(width: Float, height: Float) { + ratio = width / height + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val newWidthMeasureSpec: Int + val newHeightMeasureSpec: Int + if (ratio > 0) { + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + if (widthMode == MeasureSpec.EXACTLY) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = (width / ratio).roundToInt().coerceIn(minimumHeight, maxHeight) + newWidthMeasureSpec = widthMeasureSpec + newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + } else { + val height = MeasureSpec.getSize(heightMeasureSpec) + val width = (ratio * height).roundToInt().coerceIn(minimumWidth, maxWidth) + newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + newHeightMeasureSpec = heightMeasureSpec + } + } else { + newWidthMeasureSpec = widthMeasureSpec + newHeightMeasureSpec = heightMeasureSpec + } + super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/ui/AutoMirrorDrawable.kt b/app/src/main/java/me/zhanghai/android/files/ui/AutoMirrorDrawable.kt index 18cd65402..49888ce75 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/AutoMirrorDrawable.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/AutoMirrorDrawable.kt @@ -2,19 +2,18 @@ * Copyright (c) 2020 Hai Zhang * All Rights Reserved. */ + package me.zhanghai.android.files.ui -import android.annotation.SuppressLint import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.Drawable import android.view.View -import androidx.appcompat.graphics.drawable.DrawableWrapper +import androidx.appcompat.graphics.drawable.DrawableWrapperCompat import me.zhanghai.android.files.compat.layoutDirectionCompat -@SuppressLint("RestrictedApi") -class AutoMirrorDrawable(drawable: Drawable) : DrawableWrapper(drawable) { +class AutoMirrorDrawable(drawable: Drawable) : DrawableWrapperCompat(drawable) { override fun draw(canvas: Canvas) { if (needMirroring()) { val centerX = bounds.exactCenterX() @@ -48,7 +47,7 @@ class AutoMirrorDrawable(drawable: Drawable) : DrawableWrapper(drawable) { } override fun getConstantState(): ConstantState? = - wrappedDrawable.constantState?.let { DelegateConstantState(it) } + drawable!!.constantState?.let { DelegateConstantState(it) } private class DelegateConstantState( private val constantState: ConstantState diff --git a/app/src/main/java/me/zhanghai/android/files/ui/CheckableForegroundLinearLayout.kt b/app/src/main/java/me/zhanghai/android/files/ui/CheckableForegroundLinearLayout.kt index e8d5ae7c2..340674d0b 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/CheckableForegroundLinearLayout.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/CheckableForegroundLinearLayout.kt @@ -7,7 +7,6 @@ package me.zhanghai.android.files.ui import android.content.Context import android.util.AttributeSet -import android.view.View import android.widget.Checkable import androidx.annotation.AttrRes import androidx.annotation.StyleRes @@ -42,16 +41,18 @@ class CheckableForegroundLinearLayout : ForegroundLinearLayout, Checkable { override fun setChecked(checked: Boolean) { _isChecked = checked + refreshDrawableState() } override fun toggle() { _isChecked = !_isChecked + refreshDrawableState() } override fun onCreateDrawableState(extraSpace: Int): IntArray = super.onCreateDrawableState(extraSpace + 1).apply { if (_isChecked) { - View.mergeDrawableStates(this, CHECKED_STATE_SET) + mergeDrawableStates(this, CHECKED_STATE_SET) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/CheckableFrameLayout.kt b/app/src/main/java/me/zhanghai/android/files/ui/CheckableFrameLayout.kt index a1591a802..82c4ef0d9 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/CheckableFrameLayout.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/CheckableFrameLayout.kt @@ -7,7 +7,6 @@ package me.zhanghai.android.files.ui import android.content.Context import android.util.AttributeSet -import android.view.View import android.widget.Checkable import android.widget.FrameLayout import androidx.annotation.AttrRes @@ -42,16 +41,18 @@ class CheckableFrameLayout : FrameLayout, Checkable { override fun setChecked(checked: Boolean) { _isChecked = checked + refreshDrawableState() } override fun toggle() { _isChecked = !_isChecked + refreshDrawableState() } override fun onCreateDrawableState(extraSpace: Int): IntArray = super.onCreateDrawableState(extraSpace + 1).apply { if (_isChecked) { - View.mergeDrawableStates(this, CHECKED_STATE_SET) + mergeDrawableStates(this, CHECKED_STATE_SET) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/CheckableItemBackground.kt b/app/src/main/java/me/zhanghai/android/files/ui/CheckableItemBackground.kt index 971cdd8ff..455d2f9a6 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/CheckableItemBackground.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/CheckableItemBackground.kt @@ -10,9 +10,12 @@ import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import androidx.annotation.Dimension import androidx.appcompat.graphics.drawable.AnimatedStateListDrawableCompat -import me.zhanghai.android.files.R import me.zhanghai.android.files.util.asColor +import me.zhanghai.android.files.util.dpToDimension +import me.zhanghai.android.files.util.dpToDimensionPixelOffset import me.zhanghai.android.files.util.getColorByAttr import me.zhanghai.android.files.util.shortAnimTime import me.zhanghai.android.files.util.withModulatedAlpha @@ -25,14 +28,22 @@ object CheckableItemBackground { // Note that the s used in Material Components are color resources, so they are // inflated as ColorStateList instead of StateListDrawable and don't have this problem. @SuppressLint("RestrictedApi") - fun create(context: Context): Drawable = + fun create( + @Dimension(unit = Dimension.DP) insetDp: Float, + @Dimension(unit = Dimension.DP) cornerSizeDp: Float, + context: Context + ): Drawable = AnimatedStateListDrawableCompat().apply { val shortAnimTime = context.shortAnimTime setEnterFadeDuration(shortAnimTime) setExitFadeDuration(shortAnimTime) - val primaryColor = context.getColorByAttr(R.attr.colorPrimary) - val checkedColor = primaryColor.asColor().withModulatedAlpha(0.12f).value - addState(intArrayOf(android.R.attr.state_checked), ColorDrawable(checkedColor)) + val checkedDrawable = GradientDrawable().apply { + cornerRadius = context.dpToDimension(cornerSizeDp) + val primaryColor = context.getColorByAttr(androidx.appcompat.R.attr.colorPrimary) + setColor(primaryColor.asColor().withModulatedAlpha(0.12f).value) + setStroke(2 * context.dpToDimensionPixelOffset(insetDp), Color.TRANSPARENT) + } + addState(intArrayOf(android.R.attr.state_checked), checkedDrawable) addState(intArrayOf(), ColorDrawable(Color.TRANSPARENT)) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/CheckableView.kt b/app/src/main/java/me/zhanghai/android/files/ui/CheckableView.kt index 7c48dd805..4a5d464b9 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/CheckableView.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/CheckableView.kt @@ -41,16 +41,18 @@ open class CheckableView : View, Checkable { override fun setChecked(checked: Boolean) { _isChecked = checked + refreshDrawableState() } override fun toggle() { _isChecked = !_isChecked + refreshDrawableState() } override fun onCreateDrawableState(extraSpace: Int): IntArray = super.onCreateDrawableState(extraSpace + 1).apply { if (_isChecked) { - View.mergeDrawableStates(this, CHECKED_STATE_SET) + mergeDrawableStates(this, CHECKED_STATE_SET) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorAppBarLayout.kt b/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorAppBarLayout.kt index cbf72d64f..cad43b00d 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorAppBarLayout.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorAppBarLayout.kt @@ -6,28 +6,23 @@ package me.zhanghai.android.files.ui import android.content.Context +import android.content.res.ColorStateList import android.graphics.Color import android.graphics.Rect import android.os.Build import android.util.AttributeSet import android.view.View import androidx.annotation.AttrRes -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.graphics.ColorUtils -import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener import com.google.android.material.shape.MaterialShapeDrawable -import com.google.android.material.shape.MaterialShapeDrawableAccessor -import com.google.android.material.shape.MaterialShapeUtils import me.zhanghai.android.files.util.activity class CoordinatorAppBarLayout : FitsSystemWindowsAppBarLayout { - private val tempConsumed = IntArray(2) + private val syncBackgroundColorViews = mutableListOf() private var offset = 0 private val tempClipBounds = Rect() - private val syncBackgroundElevationViews = mutableListOf() - constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) @@ -39,32 +34,24 @@ class CoordinatorAppBarLayout : FitsSystemWindowsAppBarLayout { ) : super(context, attrs, defStyleAttr) init { - val background = background - val backgroundColor = (background as? MaterialShapeDrawable)?.fillColor?.defaultColor - if (backgroundColor != null) { + val defaultBackgroundColor = (background as? MaterialShapeDrawable)?.fillColor?.defaultColor + if (defaultBackgroundColor != null) { val window = context.activity!!.window val statusBarColor = window.statusBarColor - if (backgroundColor == statusBarColor - || backgroundColor == ColorUtils.setAlphaComponent(statusBarColor, 0xFF)) { + if (defaultBackgroundColor == statusBarColor + || defaultBackgroundColor == ColorUtils.setAlphaComponent(statusBarColor, 0xFF)) { window.statusBarColor = Color.TRANSPARENT } } - viewTreeObserver.addOnPreDrawListener { - updateLiftedState() - true - } - - if (background is MaterialShapeDrawable) { - this.background = OnElevationChangedMaterialShapeDrawable( - background, this::onBackgroundElevationChanged - ) + addLiftOnScrollListener { _, backgroundColor -> + onBackgroundColorChanged(backgroundColor) } - addOnOffsetChangedListener(OnOffsetChangedListener { _, offset -> + addOnOffsetChangedListener { _, offset -> this.offset = offset updateFirstChildClipBounds() - }) + } } override fun onFinishInflate() { @@ -77,24 +64,15 @@ class CoordinatorAppBarLayout : FitsSystemWindowsAppBarLayout { } } - fun syncBackgroundElevationTo(view: View) { - syncBackgroundElevationViews += view - } - - private fun onBackgroundElevationChanged(elevation: Float) { - syncBackgroundElevationViews.forEach { MaterialShapeUtils.setElevation(it, elevation) } + fun syncBackgroundColorTo(view: View) { + syncBackgroundColorViews += view } - private fun updateLiftedState() { - if (!isLiftOnScroll) { - return + private fun onBackgroundColorChanged(backgroundColor: Int) { + syncBackgroundColorViews.forEach { + (it.background as? MaterialShapeDrawable)?.fillColor = + ColorStateList.valueOf(backgroundColor) } - val coordinatorLayout = parent as? CoordinatorLayout ?: return - // Call AppBarLayout.Behavior.onNestedPreScroll() with dy == 0 to update lifted state. - val behavior = (layoutParams as CoordinatorLayout.LayoutParams).behavior ?: return - behavior.onNestedPreScroll( - coordinatorLayout, this, coordinatorLayout, 0, 0, tempConsumed, 0 - ) } private fun updateFirstChildClipBounds() { @@ -110,23 +88,4 @@ class CoordinatorAppBarLayout : FitsSystemWindowsAppBarLayout { } firstChild.clipBounds = tempClipBounds } - - private class OnElevationChangedMaterialShapeDrawable( - drawable: MaterialShapeDrawable, - private val onElevationChanged: (Float) -> Unit - ) : MaterialShapeDrawable() { - init { - fillColor = drawable.fillColor - MaterialShapeDrawableAccessor.setElevationOverlayProvider( - this, MaterialShapeDrawableAccessor.getElevationOverlayProvider(drawable) - ) - MaterialShapeDrawableAccessor.updateZ(this) - } - - override fun setElevation(elevation: Float) { - super.setElevation(elevation) - - onElevationChanged(elevation) - } - } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorScrollingFrameLayout.kt b/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorScrollingFrameLayout.kt index b2db4ae90..65655a21f 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorScrollingFrameLayout.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/CoordinatorScrollingFrameLayout.kt @@ -84,7 +84,7 @@ class CoordinatorScrollingFrameLayout : FrameLayout, AttachedBehavior { } private fun findScrollingView(viewGroup: ViewGroup = this): View? { - for (index in 0 until viewGroup.childCount) { + for (index in 0.. + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ui + +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener + +class DrawerLayoutOnBackPressedCallback( + private val drawerLayout: DrawerLayout, + private val gravity: Int = GravityCompat.START +) : OnBackPressedCallback(drawerLayout.isDrawerVisibleAndUnlocked(gravity)) { + init { + drawerLayout.addDrawerListener( + object : SimpleDrawerListener() { + override fun onDrawerOpened(drawerView: View) { + isEnabled = drawerLayout.isDrawerVisibleAndUnlocked(gravity) + } + + override fun onDrawerClosed(drawerView: View) { + isEnabled = drawerLayout.isDrawerVisibleAndUnlocked(gravity) + } + } + ) + } + + override fun handleOnBackPressed() { + drawerLayout.closeDrawer(gravity) + } +} + +private fun DrawerLayout.isDrawerVisibleAndUnlocked(gravity: Int): Boolean = + isDrawerVisible(gravity) && getDrawerLockMode(gravity) == DrawerLayout.LOCK_MODE_UNLOCKED diff --git a/app/src/main/java/me/zhanghai/android/files/ui/DropDownView.kt b/app/src/main/java/me/zhanghai/android/files/ui/DropDownView.kt index d60d51751..e72920eac 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/DropDownView.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/DropDownView.kt @@ -18,7 +18,6 @@ import androidx.appcompat.widget.ListPopupWindow import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import kotlinx.parcelize.Parcelize -import me.zhanghai.android.files.R import me.zhanghai.android.files.util.ParcelableState import me.zhanghai.android.files.util.doOnGlobalLayout import me.zhanghai.android.files.util.getBooleanByAttr @@ -44,17 +43,18 @@ class DropDownView @JvmOverloads constructor( private fun maybeSimulateElevationOverlay() { val context = context - val elevationOverlayEnabled = context.getBooleanByAttr(R.attr.elevationOverlayEnabled) + val elevationOverlayEnabled = + context.getBooleanByAttr(com.google.android.material.R.attr.elevationOverlayEnabled) if (!elevationOverlayEnabled) { return } val elevation = context.getDimensionPixelOffset( - R.dimen.mtrl_exposed_dropdown_menu_popup_elevation + com.google.android.material.R.dimen.mtrl_exposed_dropdown_menu_popup_elevation ).toFloat() val background = MaterialShapeDrawable.createWithElevationOverlay(context, elevation) .apply { val cornerSize = context.getDimensionPixelOffset( - R.dimen.mtrl_shape_corner_size_small_component + com.google.android.material.R.dimen.mtrl_shape_corner_size_small_component ).toFloat() shapeAppearanceModel = ShapeAppearanceModel.builder() .setAllCornerSizes(cornerSize) diff --git a/app/src/main/java/me/zhanghai/android/files/ui/FixLayoutSearchView.kt b/app/src/main/java/me/zhanghai/android/files/ui/FixLayoutSearchView.kt index ca2c4d1c3..5e96cef28 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/FixLayoutSearchView.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/FixLayoutSearchView.kt @@ -12,7 +12,6 @@ import androidx.annotation.AttrRes import androidx.appcompat.widget.SearchView import androidx.core.view.updateLayoutParams import androidx.core.view.updatePaddingRelative -import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.requireViewByIdCompat import me.zhanghai.android.files.util.dpToDimensionPixelSize import me.zhanghai.android.files.util.getDrawableByAttr @@ -30,19 +29,19 @@ open class FixLayoutSearchView : SearchView { // A negative value won't work here because SearchView will use its preferred width as max // width instead. maxWidth = Int.MAX_VALUE - val searchEditFrame = requireViewByIdCompat(R.id.search_edit_frame) + val searchEditFrame = requireViewByIdCompat(androidx.appcompat.R.id.search_edit_frame) searchEditFrame.updateLayoutParams { leftMargin = 0 rightMargin = 0 } - val searchSrcText = requireViewByIdCompat(R.id.search_src_text) + val searchSrcText = requireViewByIdCompat(androidx.appcompat.R.id.search_src_text) searchSrcText.updatePaddingRelative(start = 0, end = 0) - val searchCloseBtn = requireViewByIdCompat(R.id.search_close_btn) + val searchCloseBtn = requireViewByIdCompat(androidx.appcompat.R.id.search_close_btn) val searchCloseBtnPaddingHorizontal = searchCloseBtn.context.dpToDimensionPixelSize(12) searchCloseBtn.updatePaddingRelative( start = searchCloseBtnPaddingHorizontal, end = searchCloseBtnPaddingHorizontal ) searchCloseBtn.background = searchCloseBtn.context - .getDrawableByAttr(R.attr.actionBarItemBackground) + .getDrawableByAttr(androidx.appcompat.R.attr.actionBarItemBackground) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/LicensesDialogExtensions.kt b/app/src/main/java/me/zhanghai/android/files/ui/LicensesDialogExtensions.kt index b06c2b0f3..b98ac68f0 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/LicensesDialogExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/LicensesDialogExtensions.kt @@ -17,7 +17,6 @@ import androidx.appcompat.app.AlertDialog import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat import de.psdev.licensesdialog.model.Notices -import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.scrollIndicatorsCompat import me.zhanghai.android.files.util.createViewIntent import me.zhanghai.android.files.util.getColorByAttr @@ -69,7 +68,8 @@ private fun createHtml(notices: Notices, context: Context): String = private fun createStyle(context: Context): String { val primaryTextColor = context.getColorByAttr(android.R.attr.textColorPrimary).toCssColor() val preformattedTextBackgroundColor = ColorUtils.setAlphaComponent( - context.getColorByAttr(R.attr.colorOnSurface), (0.08f * 0xFF).roundToInt() + context.getColorByAttr(com.google.android.material.R.attr.colorOnSurface), + (0.08f * 0xFF).roundToInt() ).toCssColor() val linkTextColor = context.getColorByAttr(android.R.attr.textColorLink).toCssColor() val textHighlightColor = context.getColorByAttr(android.R.attr.textColorHighlight).toCssColor() @@ -139,7 +139,9 @@ private fun createView(html: String, context: Context): View { } return FrameLayout(context).apply { setPaddingRelative( - 0, context.getDimensionPixelSize(R.dimen.abc_dialog_title_divider_material), 0, 0 + 0, context.getDimensionPixelSize( + androidx.appcompat.R.dimen.abc_dialog_title_divider_material + ), 0, 0 ) addView(webView) } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/PreferenceFragmentCompat.kt b/app/src/main/java/me/zhanghai/android/files/ui/PreferenceFragmentCompat.kt index 4843472fe..9f8d7fea5 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/PreferenceFragmentCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/PreferenceFragmentCompat.kt @@ -15,9 +15,7 @@ abstract class PreferenceFragmentCompat : TakisoftPreferenceFragmentCompat() { // @see https://github.com/Gericop/Android-Support-Preference-V7-Fix/issues/201 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (preferenceScreen == null) { - val preferenceManager = preferenceManager - val context = preferenceManager.context - val preferenceScreen = preferenceManager.createPreferenceScreen(context) + val preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()) setPreferenceScreen(preferenceScreen) } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputEditText.kt b/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputEditText.kt index e113dcbc4..adebfbe99 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputEditText.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputEditText.kt @@ -16,7 +16,6 @@ import android.view.View import androidx.annotation.AttrRes import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.textfield.TextInputEditText -import me.zhanghai.android.files.R import me.zhanghai.android.files.util.getColorStateListByAttr class ReadOnlyTextInputEditText : TextInputEditText { @@ -73,7 +72,8 @@ class ReadOnlyTextInputEditText : TextInputEditText { // @see com.google.android.material.textfield.DropdownMenuEndIconDelegate#addRippleEffect( // AutoCompleteTextView) private fun addRippleEffect(boxBackground: MaterialShapeDrawable): Drawable { - val rippleColor = context.getColorStateListByAttr(R.attr.colorControlHighlight) + val rippleColor = + context.getColorStateListByAttr(androidx.appcompat.R.attr.colorControlHighlight) val mask = MaterialShapeDrawable(boxBackground.shapeAppearanceModel) .apply { setTint(Color.WHITE) } return RippleDrawable(rippleColor, boxBackground, mask) diff --git a/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputLayout.kt b/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputLayout.kt index 8bb7622dc..776c6fa39 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputLayout.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/ReadOnlyTextInputLayout.kt @@ -12,7 +12,6 @@ import android.view.ViewGroup import android.widget.EditText import androidx.annotation.AttrRes import com.google.android.material.textfield.TextInputLayout -import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.getDrawableCompat class ReadOnlyTextInputLayout : TextInputLayout { @@ -39,7 +38,9 @@ class ReadOnlyTextInputLayout : TextInputLayout { fun setDropDown(dropDown: Boolean) { if (dropDown) { endIconMode = END_ICON_CUSTOM - endIconDrawable = context.getDrawableCompat(R.drawable.mtrl_ic_arrow_drop_down) + endIconDrawable = context.getDrawableCompat( + com.google.android.material.R.drawable.mtrl_ic_arrow_drop_down + ) } else { endIconMode = END_ICON_NONE } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/ScrollingViewOnApplyWindowInsetsListener.kt b/app/src/main/java/me/zhanghai/android/files/ui/ScrollingViewOnApplyWindowInsetsListener.kt index 0348cc0f0..c1d6a5e3f 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/ScrollingViewOnApplyWindowInsetsListener.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/ScrollingViewOnApplyWindowInsetsListener.kt @@ -12,13 +12,13 @@ import me.zhanghai.android.fastscroll.FastScroller class ScrollingViewOnApplyWindowInsetsListener( view: View, - private val fastScroller: FastScroller + private val fastScroller: FastScroller? = null ) : View.OnApplyWindowInsetsListener { private val initialPadding = Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom) init { - fastScroller.setPadding(0, 0, 0, 0) + fastScroller?.setPadding(0, 0, 0, 0) } override fun onApplyWindowInsets(view: View, insets: WindowInsets): WindowInsets { @@ -26,7 +26,7 @@ class ScrollingViewOnApplyWindowInsetsListener( initialPadding.left, initialPadding.top, initialPadding.right, initialPadding.bottom + insets.systemWindowInsetBottom ) - fastScroller.setPadding(0, 0, 0, insets.systemWindowInsetBottom) + fastScroller?.setPadding(0, 0, 0, insets.systemWindowInsetBottom) return insets } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/SimpleAdapter.kt b/app/src/main/java/me/zhanghai/android/files/ui/SimpleAdapter.kt index 50d7432d3..1881977e5 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/SimpleAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/SimpleAdapter.kt @@ -58,7 +58,7 @@ abstract class SimpleAdapter : RecyclerView.Ada fun findPositionById(id: Long): Int { val count = itemCount - for (index in 0 until count) { + for (index in 0.. + * All Rights Reserved. + */ + +package me.zhanghai.android.files.ui + +import androidx.activity.OnBackPressedCallback +import com.leinardi.android.speeddial.SpeedDialView + +class SpeedDialViewOnBackPressedCallback( + private val speedDialView: SpeedDialView +) : OnBackPressedCallback(speedDialView.isOpen) { + init { + speedDialView.setOnChangeListener( + object : SpeedDialView.OnChangeListener { + override fun onMainActionSelected(): Boolean = false + + override fun onToggleChanged(isOpen: Boolean) { + isEnabled = speedDialView.isOpen + } + } + ) + } + + override fun handleOnBackPressed() { + speedDialView.close() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/ui/TabFragmentPagerAdapter.kt b/app/src/main/java/me/zhanghai/android/files/ui/TabFragmentPagerAdapter.kt index 0dff1fabb..e24fef155 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/TabFragmentPagerAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/TabFragmentPagerAdapter.kt @@ -12,7 +12,7 @@ import androidx.fragment.app.FragmentPagerAdapter class TabFragmentPagerAdapter( fragmentManager: FragmentManager, private vararg val tabs: Pair Fragment> -) : FragmentPagerAdapter(fragmentManager) { +) : FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { override fun getItem(position: Int): Fragment = tabs[position].second() override fun getCount(): Int = tabs.size diff --git a/app/src/main/java/me/zhanghai/android/files/ui/ThemedSpeedDialView.kt b/app/src/main/java/me/zhanghai/android/files/ui/ThemedSpeedDialView.kt index a43d0b100..da49dacec 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/ThemedSpeedDialView.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/ThemedSpeedDialView.kt @@ -29,7 +29,6 @@ import com.leinardi.android.speeddial.FabWithLabelView import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialView import kotlinx.parcelize.Parcelize -import me.zhanghai.android.files.R import me.zhanghai.android.files.compat.createCompat import me.zhanghai.android.files.compat.setTextAppearanceCompat import me.zhanghai.android.files.util.ParcelableState @@ -43,6 +42,8 @@ import me.zhanghai.android.files.util.shortAnimTime import me.zhanghai.android.files.util.withModulatedAlpha class ThemedSpeedDialView : SpeedDialView { + private var onChangeListener: OnChangeListener? = null + private var mainFabAnimator: Animator? = null constructor(context: Context) : super(context) @@ -64,13 +65,19 @@ class ThemedSpeedDialView : SpeedDialView { } val context = context if (context.isMaterial3Theme) { - mainFabClosedBackgroundColor = context.getColorByAttr(R.attr.colorSecondaryContainer) - mainFabClosedIconColor = context.getColorByAttr(R.attr.colorOnSecondaryContainer) - mainFabOpenedBackgroundColor = context.getColorByAttr(R.attr.colorPrimary) - mainFabOpenedIconColor = context.getColorByAttr(R.attr.colorOnPrimary) + mainFabClosedBackgroundColor = + context.getColorByAttr(com.google.android.material.R.attr.colorSecondaryContainer) + mainFabClosedIconColor = + context.getColorByAttr(com.google.android.material.R.attr.colorOnSecondaryContainer) + mainFabOpenedBackgroundColor = + context.getColorByAttr(androidx.appcompat.R.attr.colorPrimary) + mainFabOpenedIconColor = + context.getColorByAttr(com.google.android.material.R.attr.colorOnPrimary) } else { - mainFabClosedBackgroundColor = context.getColorByAttr(R.attr.colorSecondary) - mainFabClosedIconColor = context.getColorByAttr(R.attr.colorOnSecondary) + mainFabClosedBackgroundColor = + context.getColorByAttr(com.google.android.material.R.attr.colorSecondary) + mainFabClosedIconColor = + context.getColorByAttr(com.google.android.material.R.attr.colorOnSecondary) mainFabOpenedBackgroundColor = mainFabClosedBackgroundColor mainFabOpenedIconColor = mainFabClosedIconColor } @@ -81,8 +88,9 @@ class ThemedSpeedDialView : SpeedDialView { } mainFabAnimationRotateAngle = 0f setMainFabClosedDrawable(mainFabDrawable) - setOnChangeListener(object : OnChangeListener { - override fun onMainActionSelected(): Boolean = false + super.setOnChangeListener(object : OnChangeListener { + override fun onMainActionSelected(): Boolean = + onChangeListener?.onMainActionSelected() ?: false override fun onToggleChanged(isOpen: Boolean) { mainFabAnimator?.cancel() @@ -94,10 +102,15 @@ class ThemedSpeedDialView : SpeedDialView { }) start() } + onChangeListener?.onToggleChanged(isOpen) } }) } + override fun setOnChangeListener(onChangeListener: OnChangeListener?) { + this.onChangeListener = onChangeListener + } + private fun createMainFabAnimator(isOpen: Boolean): Animator = AnimatorSet().apply { playTogether( @@ -122,7 +135,8 @@ class ThemedSpeedDialView : SpeedDialView { val overlayLayout = overlayLayout if (overlayLayout != null) { - val surfaceColor = context.getColorByAttr(R.attr.colorSurface) + val surfaceColor = + context.getColorByAttr(com.google.android.material.R.attr.colorSurface) val overlayColor = surfaceColor.asColor().withModulatedAlpha(0.87f).value overlayLayout.setBackgroundColor(overlayColor) } @@ -136,18 +150,19 @@ class ThemedSpeedDialView : SpeedDialView { val context = context val isMaterial3Theme = context.isMaterial3Theme val fabImageTintColor = if (isMaterial3Theme) { - context.getColorByAttr(R.attr.colorPrimary) + context.getColorByAttr(androidx.appcompat.R.attr.colorPrimary) } else { - context.getColorByAttr(R.attr.colorSecondary) + context.getColorByAttr(com.google.android.material.R.attr.colorSecondary) } - val fabBackgroundColor = context.getColorByAttr(R.attr.colorSurface) + val fabBackgroundColor = + context.getColorByAttr(com.google.android.material.R.attr.colorSurface) val labelColor = context.getColorByAttr(android.R.attr.textColorSecondary) val labelBackgroundColor = if (isMaterial3Theme) { Color.TRANSPARENT } else { // Label view doesn't have enough elevation (only 1dp) for elevation overlay to work // well. - context.getColorByAttr(R.attr.colorBackgroundFloating) + context.getColorByAttr(androidx.appcompat.R.attr.colorBackgroundFloating) } val actionItem = SpeedDialActionItem.Builder( actionItem.id, @@ -177,7 +192,9 @@ class ThemedSpeedDialView : SpeedDialView { foreground = null (getChildAt(0) as TextView).apply { setTextAppearanceCompat( - context.getResourceIdByAttr(R.attr.textAppearanceLabelLarge) + context.getResourceIdByAttr( + com.google.android.material.R.attr.textAppearanceLabelLarge + ) ) } } diff --git a/app/src/main/java/me/zhanghai/android/files/ui/ToolbarActionMode.kt b/app/src/main/java/me/zhanghai/android/files/ui/ToolbarActionMode.kt index d50d0c26f..b80709577 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/ToolbarActionMode.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/ToolbarActionMode.kt @@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable import android.view.Menu import android.view.MenuItem import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback import androidx.annotation.DrawableRes import androidx.annotation.MenuRes import androidx.annotation.StringRes @@ -23,9 +24,15 @@ abstract class ToolbarActionMode( private var callback: Callback? = null init { - toolbar.setNavigationOnClickListener { finish() } + toolbar.setNavigationOnClickListener { callback?.onToolbarNavigationIconClicked(this) } toolbar.setOnMenuItemClickListener { - callback?.onToolbarActionModeItemClicked(this, it) ?: false + callback?.onToolbarActionModeMenuItemClicked(this, it) ?: false + } + } + + val onBackPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + finish() } } @@ -35,8 +42,15 @@ abstract class ToolbarActionMode( toolbar.navigationIcon = value } - fun setNavigationIcon(@DrawableRes iconRes: Int) { + var navigationContentDescription: CharSequence? + get() = toolbar.navigationContentDescription + set(value) { + toolbar.navigationContentDescription = value + } + + fun setNavigationIcon(@DrawableRes iconRes: Int, @StringRes contentDescriptionRes: Int) { toolbar.setNavigationIcon(iconRes) + toolbar.setNavigationContentDescription(contentDescriptionRes) } var title: CharSequence? @@ -78,6 +92,7 @@ abstract class ToolbarActionMode( fun start(callback: Callback, animate: Boolean = true) { this.callback = callback + onBackPressedCallback.isEnabled = true show(bar, animate) callback.onToolbarActionModeStarted(this) } @@ -87,6 +102,7 @@ abstract class ToolbarActionMode( fun finish(animate: Boolean = true) { val callback = callback ?: return this.callback = null + onBackPressedCallback.isEnabled = false toolbar.menu.close() hide(bar, animate) callback.onToolbarActionModeFinished(this) @@ -95,9 +111,13 @@ abstract class ToolbarActionMode( protected abstract fun hide(bar: ViewGroup, animate: Boolean) interface Callback { - fun onToolbarActionModeStarted(toolbarActionMode: ToolbarActionMode) + fun onToolbarActionModeStarted(toolbarActionMode: ToolbarActionMode) {} + + fun onToolbarNavigationIconClicked(toolbarActionMode: ToolbarActionMode) { + toolbarActionMode.finish() + } - fun onToolbarActionModeItemClicked( + fun onToolbarActionModeMenuItemClicked( toolbarActionMode: ToolbarActionMode, item: MenuItem ): Boolean diff --git a/app/src/main/java/me/zhanghai/android/files/ui/WrapFirstPageContentViewPager.kt b/app/src/main/java/me/zhanghai/android/files/ui/WrapFirstPageContentViewPager.kt index 46f10d44d..c33ee6b94 100644 --- a/app/src/main/java/me/zhanghai/android/files/ui/WrapFirstPageContentViewPager.kt +++ b/app/src/main/java/me/zhanghai/android/files/ui/WrapFirstPageContentViewPager.kt @@ -15,14 +15,15 @@ class WrapFirstPageContentViewPager : ViewPager { constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - var heightMeasureSpec = heightMeasureSpec - if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY && childCount > 0) { + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val newHeightMeasureSpec = if (heightMode != MeasureSpec.EXACTLY && childCount > 0) { val child = getChildAt(0) child.measure(widthMeasureSpec, heightMeasureSpec) - heightMeasureSpec = MeasureSpec.makeMeasureSpec( - child.measuredHeight, MeasureSpec.EXACTLY - ) + val height = child.measuredHeight + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + } else { + heightMeasureSpec } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) + super.onMeasure(widthMeasureSpec, newHeightMeasureSpec) } } diff --git a/app/src/main/java/me/zhanghai/android/files/util/AbstractLocalCursor.kt b/app/src/main/java/me/zhanghai/android/files/util/AbstractLocalCursor.kt index 779859a8c..96a8cd8b5 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/AbstractLocalCursor.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/AbstractLocalCursor.kt @@ -89,13 +89,13 @@ abstract class AbstractLocalCursor : Cursor { private fun getObjectChecked(columnIndex: Int): Any? { val columnCount = columnCount - if (columnIndex !in 0 until columnCount) { + if (columnIndex !in 0.. // @see com.android.documentsui.inspector.MediaView.getAddress val Address.userFriendlyString: String? get() = - addressLines.joinToString("\n") { it ?: "" }.takeIfNotBlank() + addressLines.joinToString("\n") { it.orEmpty() }.takeIfNotBlank() ?: locality.takeIfNotBlank() ?: subAdminArea.takeIfNotBlank() ?: adminArea.takeIfNotBlank() ?: countryName.takeIfNotBlank() diff --git a/app/src/main/java/me/zhanghai/android/files/util/ByteArrayExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/ByteArrayExtensions.kt index 2ab07b787..dba2073c5 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/ByteArrayExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/ByteArrayExtensions.kt @@ -19,4 +19,4 @@ fun ByteArray.toHexString(): String { return String(chars) } -private fun Int.toHexChar(): Char = if (this >= 10) 'a' + (this - 10) else '0' + this +private fun Int.toHexChar(): Char = if (this >= 10) 'A' + (this - 10) else '0' + this diff --git a/app/src/main/java/me/zhanghai/android/files/util/ClickableArrowKeyMovementMethod.kt b/app/src/main/java/me/zhanghai/android/files/util/ClickableArrowKeyMovementMethod.kt new file mode 100644 index 000000000..6cd476eca --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/ClickableArrowKeyMovementMethod.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2020 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import android.text.NoCopySpan.Concrete +import android.text.Selection +import android.text.Spannable +import android.text.method.ArrowKeyMovementMethod +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.widget.TextView +import kotlin.math.max +import kotlin.math.min + +/** + * @see LinkMovementMethod + * @see ArrowKeyMovementMethod + * @see ClickableMovementMethod + */ +object ClickableArrowKeyMovementMethod : ArrowKeyMovementMethod() { + private const val CLICK = 1 + private const val UP = 2 + private const val DOWN = 3 + + private val FROM_BELOW = Concrete() + + override fun initialize(view: TextView, text: Spannable) { + super.initialize(view, text) + + text.removeSpan(FROM_BELOW) + } + + override fun onTakeFocus(view: TextView, text: Spannable, direction: Int) { + super.onTakeFocus(view, text, direction) + + if (direction.hasBits(View.FOCUS_BACKWARD)) { + text.setSpan(FROM_BELOW, 0, 0, Spannable.SPAN_POINT_POINT) + } else { + text.removeSpan(FROM_BELOW) + } + } + + override fun handleMovementKey( + view: TextView, + text: Spannable, + keyCode: Int, + movementMetaState: Int, + event: KeyEvent + ): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { + if (KeyEvent.metaStateHasNoModifiers(movementMetaState)) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 + && action(CLICK, view, text)) { + return true + } + } + } + } + return super.handleMovementKey(view, text, keyCode, movementMetaState, event) + } + + override fun up(view: TextView, text: Spannable): Boolean { + if (action(UP, view, text)) { + return true + } + return super.up(view, text) + } + + override fun down(view: TextView, text: Spannable): Boolean { + if (action(DOWN, view, text)) { + return true + } + return super.down(view, text) + } + + override fun left(view: TextView, text: Spannable): Boolean { + if (action(UP, view, text)) { + return true + } + return super.left(view, text) + } + + override fun right(view: TextView, text: Spannable): Boolean { + if (action(DOWN, view, text)) { + return true + } + return super.right(view, text) + } + + private fun action(what: Int, view: TextView, text: Spannable): Boolean { + val layout = view.layout + val padding = view.totalPaddingTop + view.totalPaddingBottom + val areaTop = view.scrollY + val areaBottom = areaTop + view.height - padding + val lineTop = layout.getLineForVertical(areaTop) + val lineBottom = layout.getLineForVertical(areaBottom) + val first = layout.getLineStart(lineTop) + val last = layout.getLineEnd(lineBottom) + val candidates = text.getSpans(first, last, ClickableSpan::class.java) + val a = Selection.getSelectionStart(text) + val b = Selection.getSelectionEnd(text) + var selectionStart = min(a, b) + var selectionEnd = max(a, b) + if (selectionStart < 0) { + if (text.getSpanStart(FROM_BELOW) >= 0) { + selectionEnd = text.length + selectionStart = selectionEnd + } + } + if (selectionStart > last) { + selectionEnd = Int.MAX_VALUE + selectionStart = selectionEnd + } + if (selectionEnd < first) { + selectionEnd = -1 + selectionStart = selectionEnd + } + when (what) { + CLICK -> { + if (selectionStart == selectionEnd) { + return false + } + val span = text.getSpans(selectionStart, selectionEnd, ClickableSpan::class.java) + .singleOrNull() ?: return false + span.onClick(view) + } + UP -> { + var bestStart = -1 + var bestEnd = -1 + for (candidate in candidates) { + val end = text.getSpanEnd(candidate) + if (end < selectionEnd || selectionStart == selectionEnd) { + if (end > bestEnd) { + bestStart = text.getSpanStart(candidate) + bestEnd = end + } + } + } + if (bestStart >= 0) { + Selection.setSelection(text, bestEnd, bestStart) + return true + } + } + DOWN -> { + var bestStart = Int.MAX_VALUE + var bestEnd = Int.MAX_VALUE + for (candidate in candidates) { + val start = text.getSpanStart(candidate) + if (start > selectionStart || selectionStart == selectionEnd) { + if (start < bestStart) { + bestStart = start + bestEnd = text.getSpanEnd(candidate) + } + } + } + if (bestEnd < Int.MAX_VALUE) { + Selection.setSelection(text, bestStart, bestEnd) + return true + } + } + } + return false + } + + override fun onTouchEvent(view: TextView, text: Spannable, event: MotionEvent): Boolean { + when (val action = event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> { + val x = event.x.toInt() - view.totalPaddingLeft + view.scrollX + val y = event.y.toInt() - view.totalPaddingTop + view.scrollY + val layout = view.layout + val span = if (y < 0 || y > layout.height) { + null + } else { + val line = layout.getLineForVertical(y) + if (x < layout.getLineLeft(line) || x > layout.getLineRight(line)) { + null + } else { + val off = layout.getOffsetForHorizontal(line, x.toFloat()) + text.getSpans(off, off, ClickableSpan::class.java).firstOrNull() + } + } + if (span != null) { + if (action == MotionEvent.ACTION_DOWN) { + Selection.setSelection(text, text.getSpanStart(span), text.getSpanEnd(span)) + } else { + span.onClick(view) + } + return true + } + // Removed + //else { + // Selection.removeSelection(text) + //} + } + } + return super.onTouchEvent(view, text, event) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/util/ClickableMovementMethod.kt b/app/src/main/java/me/zhanghai/android/files/util/ClickableMovementMethod.kt index 4acdf9865..cba3395c6 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/ClickableMovementMethod.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/ClickableMovementMethod.kt @@ -18,22 +18,32 @@ import android.widget.TextView * [ClickableSpan]s. */ object ClickableMovementMethod : BaseMovementMethod() { - override fun canSelectArbitrarily(): Boolean = false + override fun initialize(view: TextView, text: Spannable) { + Selection.removeSelection(text) + } - override fun onTouchEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean { + override fun onTouchEvent(view: TextView, text: Spannable, event: MotionEvent): Boolean { when (val action = event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> { - val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX - val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY - val layout = widget.layout - val line = layout.getLineForVertical(y) - val off = layout.getOffsetForHorizontal(line, x.toFloat()) - val span = text.getSpans(off, off, ClickableSpan::class.java).firstOrNull() + val x = event.x.toInt() - view.totalPaddingLeft + view.scrollX + val y = event.y.toInt() - view.totalPaddingTop + view.scrollY + val layout = view.layout + val span = if (y < 0 || y > layout.height) { + null + } else { + val line = layout.getLineForVertical(y) + if (x < layout.getLineLeft(line) || x > layout.getLineRight(line)) { + null + } else { + val off = layout.getOffsetForHorizontal(line, x.toFloat()) + text.getSpans(off, off, ClickableSpan::class.java).firstOrNull() + } + } if (span != null) { if (action == MotionEvent.ACTION_DOWN) { Selection.setSelection(text, text.getSpanStart(span), text.getSpanEnd(span)) } else { - span.onClick(widget) + span.onClick(view) } return true } else { @@ -43,8 +53,4 @@ object ClickableMovementMethod : BaseMovementMethod() { } return false } - - override fun initialize(widget: TextView, text: Spannable) { - Selection.removeSelection(text) - } } diff --git a/app/src/main/java/me/zhanghai/android/files/util/CollectionExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/CollectionExtensions.kt index fe2147d55..3752bbe57 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/CollectionExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/CollectionExtensions.kt @@ -31,7 +31,8 @@ fun > enumSetOf(first: T, vararg rest: T): EnumSet = EnumSet.of(f fun Iterable.toLinkedSet(): LinkedHashSet = toCollection(LinkedHashSet()) -fun > Collection.toEnumSet(): EnumSet = EnumSet.copyOf(this) +inline fun > Collection.toEnumSet(): EnumSet = + if (isNotEmpty()) EnumSet.copyOf(this) else EnumSet.noneOf(T::class.java) fun > T.takeIfNotEmpty(): T? = if (isNotEmpty()) this else null diff --git a/app/src/main/java/me/zhanghai/android/files/util/ContextExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/ContextExtensions.kt index 4b2cf477f..13a1b8b0b 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/ContextExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/ContextExtensions.kt @@ -13,6 +13,7 @@ import android.content.ContextWrapper import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration +import android.content.res.Resources import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Looper @@ -36,7 +37,9 @@ import androidx.annotation.PluralsRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.res.ResourcesCompat +import androidx.core.util.TypedValueCompat import me.zhanghai.android.files.R +import me.zhanghai.android.files.compat.complexUnitCompat import me.zhanghai.android.files.compat.getFloatCompat import me.zhanghai.android.files.compat.mainExecutorCompat import me.zhanghai.android.files.compat.obtainStyledAttributesCompat @@ -56,17 +59,38 @@ val Context.activity: Activity? fun Context.getAnimation(@AnimRes id: Int): Animation = AnimationUtils.loadAnimation(this, id) -fun Context.getBoolean(@BoolRes id: Int) = resources.getBoolean(id) +fun Context.getBoolean(@BoolRes id: Int): Boolean = resources.getBoolean(id) -fun Context.getDimension(@DimenRes id: Int) = resources.getDimension(id) +@Dimension +fun Context.getDimension(@DimenRes id: Int): Float = resources.getDimension(id) + +@Dimension(unit = Dimension.DP) +fun Context.getDimensionDp(@DimenRes id: Int): Float { + TypedValue::class.useTemp { value -> + resources.getValue(id, value, true) + if (value.type != TypedValue.TYPE_DIMENSION) { + throw Resources.NotFoundException( + "Resource ID #0x${Integer.toHexString(id)} type #0x${ + Integer.toHexString(value.type) + } is not valid" + ) + } + if (value.complexUnitCompat == TypedValue.COMPLEX_UNIT_DIP) { + return TypedValue.complexToFloat(value.data) + } + return dimensionToDp(TypedValue.complexToDimension(value.data, resources.displayMetrics)) + } +} -fun Context.getDimensionPixelOffset(@DimenRes id: Int) = resources.getDimensionPixelOffset(id) +@Dimension +fun Context.getDimensionPixelOffset(@DimenRes id: Int): Int = resources.getDimensionPixelOffset(id) -fun Context.getDimensionPixelSize(@DimenRes id: Int) = resources.getDimensionPixelSize(id) +@Dimension +fun Context.getDimensionPixelSize(@DimenRes id: Int): Int = resources.getDimensionPixelSize(id) -fun Context.getFloat(@DimenRes id: Int) = resources.getFloatCompat(id) +fun Context.getFloat(@DimenRes id: Int): Float = resources.getFloatCompat(id) -fun Context.getInteger(@IntegerRes id: Int) = resources.getInteger(id) +fun Context.getInteger(@IntegerRes id: Int): Int = resources.getInteger(id) fun Context.getInterpolator(@InterpolatorRes id: Int): Interpolator = AnimationUtils.loadInterpolator(this, id) @@ -162,6 +186,13 @@ fun Context.dpToDimensionPixelSize(@Dimension(unit = Dimension.DP) dp: Float): I fun Context.dpToDimensionPixelSize(@Dimension(unit = Dimension.DP) dp: Int) = dpToDimensionPixelSize(dp.toFloat()) +@Dimension(unit = Dimension.DP) +fun Context.dimensionToDp(@Dimension dimension: Float): Float = + TypedValueCompat.pxToDp(dimension, resources.displayMetrics) + +@Dimension(unit = Dimension.DP) +fun Context.dimensionToDp(@Dimension dimension: Int): Float = dimensionToDp(dimension.toFloat()) + fun Context.hasSwDp(@Dimension(unit = Dimension.DP) dp: Int): Boolean = resources.configuration.smallestScreenWidthDp >= dp @@ -178,11 +209,11 @@ val Context.hasW960Dp: Boolean get() = hasWDp(960) val Context.isLightTheme: Boolean - get() = getBooleanByAttr(R.attr.isLightTheme) + get() = getBooleanByAttr(androidx.appcompat.R.attr.isLightTheme) val Context.isMaterial3Theme: Boolean @SuppressLint("PrivateResource") - get() = getBooleanByAttr(R.attr.isMaterial3Theme) + get() = getBooleanByAttr(com.google.android.material.R.attr.isMaterial3Theme) val Context.isOrientationLandscape: Boolean get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE diff --git a/app/src/main/java/me/zhanghai/android/files/util/EnvironmentExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/EnvironmentExtensions.kt new file mode 100644 index 000000000..b740962e9 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/EnvironmentExtensions.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import me.zhanghai.android.files.app.application +import me.zhanghai.android.files.app.packageManager +import kotlin.reflect.KClass + +// TvSettings didn't have "All files access" page until Android 13. +@ChecksSdkIntAtLeast(Build.VERSION_CODES.R) +fun KClass.supportsExternalStorageManager(): Boolean = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> true + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + isManageAppAllFilesAccessPermissionIntentResolved + else -> false + } + +@RequiresApi(Build.VERSION_CODES.R) +fun KClass.createManageAppAllFilesAccessPermissionIntent(packageName: String): Intent = + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.fromParts("package", packageName, null) + ) + +@delegate:RequiresApi(Build.VERSION_CODES.R) +private val isManageAppAllFilesAccessPermissionIntentResolved: Boolean + by lazy(LazyThreadSafetyMode.NONE) { + Environment::class.createManageAppAllFilesAccessPermissionIntent(application.packageName) + .resolveActivity(packageManager) != null + } diff --git a/app/src/main/java/me/zhanghai/android/files/util/FragmentExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/FragmentExtensions.kt index 0396eec72..2b50e40fd 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/FragmentExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/FragmentExtensions.kt @@ -7,8 +7,13 @@ package me.zhanghai.android.files.util import android.content.ActivityNotFoundException import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable import android.os.Bundle +import android.view.animation.Animation +import android.view.animation.Interpolator import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.annotation.AnimRes import androidx.annotation.AnyRes import androidx.annotation.ArrayRes @@ -29,34 +34,49 @@ import me.zhanghai.android.files.compat.getColorCompat import me.zhanghai.android.files.compat.getColorStateListCompat import me.zhanghai.android.files.compat.getDrawableCompat -fun Fragment.checkSelfPermission(permission: String) = +fun Fragment.addOnBackPressedCallback(callback: OnBackPressedCallback) { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) +} + +fun Fragment.checkSelfPermission(permission: String): Int = requireContext().checkSelfPermissionCompat(permission) -fun Fragment.finish() = requireActivity().finish() +fun Fragment.finish() { + requireActivity().finish() +} -fun Fragment.getAnimation(@AnimRes id: Int) = requireContext().getAnimation(id) +fun Fragment.getAnimation(@AnimRes id: Int): Animation = requireContext().getAnimation(id) -fun Fragment.getBoolean(@BoolRes id: Int) = requireContext().getBoolean(id) +fun Fragment.getBoolean(@BoolRes id: Int): Boolean = requireContext().getBoolean(id) @ColorInt -fun Fragment.getColor(@ColorRes id: Int) = requireContext().getColorCompat(id) +fun Fragment.getColor(@ColorRes id: Int): Int = requireContext().getColorCompat(id) -fun Fragment.getColorStateList(@ColorRes id: Int) = requireContext().getColorStateListCompat(id) +fun Fragment.getColorStateList(@ColorRes id: Int): ColorStateList = + requireContext().getColorStateListCompat(id) -fun Fragment.getDimension(@DimenRes id: Int) = requireContext().getDimension(id) +@Dimension +fun Fragment.getDimension(@DimenRes id: Int): Float = requireContext().getDimension(id) + +@Dimension(unit = Dimension.DP) +fun Fragment.getDimensionDp(@DimenRes id: Int): Float = requireContext().getDimensionDp(id) -fun Fragment.getDimensionPixelOffset(@DimenRes id: Int) = +@Dimension +fun Fragment.getDimensionPixelOffset(@DimenRes id: Int): Int = requireContext().getDimensionPixelOffset(id) -fun Fragment.getDimensionPixelSize(@DimenRes id: Int) = requireContext().getDimensionPixelSize(id) +@Dimension +fun Fragment.getDimensionPixelSize(@DimenRes id: Int): Int = + requireContext().getDimensionPixelSize(id) -fun Fragment.getDrawable(@DrawableRes id: Int) = requireContext().getDrawableCompat(id) +fun Fragment.getDrawable(@DrawableRes id: Int): Drawable = requireContext().getDrawableCompat(id) -fun Fragment.getFloat(@DimenRes id: Int) = requireContext().getFloat(id) +fun Fragment.getFloat(@DimenRes id: Int): Float = requireContext().getFloat(id) -fun Fragment.getInteger(@IntegerRes id: Int) = requireContext().getInteger(id) +fun Fragment.getInteger(@IntegerRes id: Int): Int = requireContext().getInteger(id) -fun Fragment.getInterpolator(@InterpolatorRes id: Int) = requireContext().getInterpolator(id) +fun Fragment.getInterpolator(@InterpolatorRes id: Int): Interpolator = + requireContext().getInterpolator(id) fun Fragment.getQuantityString(@PluralsRes id: Int, quantity: Int): String = requireContext().getQuantityString(id, quantity) @@ -123,6 +143,14 @@ fun Fragment.dpToDimensionPixelSize(@Dimension(unit = Dimension.DP) dp: Float) = fun Fragment.dpToDimensionPixelSize(@Dimension(unit = Dimension.DP) dp: Int) = requireContext().dpToDimensionPixelSize(dp) +@Dimension(unit = Dimension.DP) +fun Fragment.dimensionToDp(@Dimension dimension: Float): Float = + requireContext().dimensionToDp(dimension) + +@Dimension(unit = Dimension.DP) +fun Fragment.dimensionToDp(@Dimension dimension: Int): Float = + requireContext().dimensionToDp(dimension) + fun Fragment.setResult(resultCode: Int, resultData: Intent? = null) = requireActivity().setResult(resultCode, resultData) @@ -135,11 +163,13 @@ val Fragment.mediumAnimTime val Fragment.longAnimTime get() = requireContext().longAnimTime -fun Fragment.showToast(textRes: Int, duration: Int = Toast.LENGTH_SHORT) = +fun Fragment.showToast(textRes: Int, duration: Int = Toast.LENGTH_SHORT) { requireContext().showToast(textRes, duration) +} -fun Fragment.showToast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) = +fun Fragment.showToast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) { requireContext().showToast(text, duration) +} fun Fragment.startActivitySafe(intent: Intent, options: Bundle? = null) { try { diff --git a/app/src/main/java/me/zhanghai/android/files/util/FragmentViewModelLazy.kt b/app/src/main/java/me/zhanghai/android/files/util/FragmentViewModelLazy.kt index bd25e0469..ce6199e9a 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/FragmentViewModelLazy.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/FragmentViewModelLazy.kt @@ -10,12 +10,15 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.CreationExtras inline fun Fragment.viewModels( noinline ownerProducer: () -> ViewModelStoreOwner = { this }, + noinline extrasProducer: (() -> CreationExtras)? = null, noinline factoryProducer: (() -> () -> VM)? = null ) = viewModels( ownerProducer, + extrasProducer, factoryProducer?.let { { val factory = it() @@ -28,5 +31,6 @@ inline fun Fragment.viewModels( ) inline fun Fragment.activityViewModels( + noinline extrasProducer: (() -> CreationExtras)? = null, noinline factoryProducer: (() -> () -> VM)? = null -) = viewModels(::requireActivity, factoryProducer) +) = viewModels(::requireActivity, extrasProducer, factoryProducer) diff --git a/app/src/main/java/me/zhanghai/android/files/util/IntentExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/IntentExtensions.kt index e48a5f8f6..e7a0551ac 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/IntentExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/IntentExtensions.kt @@ -15,6 +15,7 @@ import androidx.core.app.ShareCompat import me.zhanghai.android.files.app.appClassLoader import me.zhanghai.android.files.app.application import me.zhanghai.android.files.app.packageManager +import me.zhanghai.android.files.compat.DocumentsContractCompat import me.zhanghai.android.files.compat.removeFlagsCompat import me.zhanghai.android.files.file.MimeType import me.zhanghai.android.files.file.intentType @@ -153,6 +154,10 @@ fun Collection.createSendStreamIntent(mimeTypes: Collection): Int removeFlagsCompat(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET) } +fun Uri.createDocumentsUiViewDirectoryIntent(): Intent = + createViewIntent(MimeType.DIRECTORY) + .apply { DocumentsContractCompat.getDocumentsUiPackage()?.let { setPackage(it) } } + fun Uri.createViewIntent(): Intent = Intent(Intent.ACTION_VIEW, this) fun Uri.createViewIntent(mimeType: MimeType): Intent = diff --git a/app/src/main/java/me/zhanghai/android/files/util/IntentPathExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/IntentPathExtensions.kt index 60a567205..62074bfca 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/IntentPathExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/IntentPathExtensions.kt @@ -37,6 +37,17 @@ var Intent.extraPath: Path? putExtra(EXTRA_PATH_URI, value?.toUri()?.toString()) } +val Intent.saveAsPath: Path? + get() { + val uri = + when (action) { + Intent.ACTION_VIEW -> data + Intent.ACTION_SEND -> getParcelableExtraSafe(Intent.EXTRA_STREAM) as? Uri + else -> null + } + return uri?.toPathOrNull() + } + private fun Uri.toPathOrNull(): Path? = when (scheme) { ContentResolver.SCHEME_FILE, null -> path?.takeIfNotEmpty()?.let { Paths.get(it) } diff --git a/app/src/main/java/me/zhanghai/android/files/util/LinkArrowKeyMovementMethod.kt b/app/src/main/java/me/zhanghai/android/files/util/LinkArrowKeyMovementMethod.kt deleted file mode 100644 index 0c3159607..000000000 --- a/app/src/main/java/me/zhanghai/android/files/util/LinkArrowKeyMovementMethod.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright (c) 2020 Hai Zhang - * All Rights Reserved. - */ - -package me.zhanghai.android.files.util - -import android.text.NoCopySpan.Concrete -import android.text.Selection -import android.text.Spannable -import android.text.method.ArrowKeyMovementMethod -import android.text.style.ClickableSpan -import android.view.KeyEvent -import android.view.MotionEvent -import android.view.View -import android.widget.TextView -import kotlin.math.max -import kotlin.math.min - -/** - * @see LinkMovementMethod - * @see ArrowKeyMovementMethod - */ -object LinkArrowKeyMovementMethod : ArrowKeyMovementMethod() { - private const val CLICK = 1 - private const val UP = 2 - private const val DOWN = 3 - - private val FROM_BELOW = Concrete() - - override fun initialize(widget: TextView?, text: Spannable) { - super.initialize(widget, text) - - text.removeSpan(FROM_BELOW) - } - - override fun onTakeFocus(view: TextView, text: Spannable, dir: Int) { - super.onTakeFocus(view, text, dir) - - if (dir.hasBits(View.FOCUS_BACKWARD)) { - text.setSpan(FROM_BELOW, 0, 0, Spannable.SPAN_POINT_POINT) - } else { - text.removeSpan(FROM_BELOW) - } - } - - override fun handleMovementKey( - widget: TextView, - buffer: Spannable, - keyCode: Int, - movementMetaState: Int, - event: KeyEvent - ): Boolean { - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { - if (KeyEvent.metaStateHasNoModifiers(movementMetaState)) { - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 - && action(CLICK, widget, buffer)) { - return true - } - } - } - return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event) - } - - override fun up(widget: TextView, buffer: Spannable): Boolean { - if (action(UP, widget, buffer)) { - return true - } - return super.up(widget, buffer) - } - - override fun down(widget: TextView, buffer: Spannable): Boolean { - if (action(DOWN, widget, buffer)) { - return true - } - return super.down(widget, buffer) - } - - override fun left(widget: TextView, buffer: Spannable): Boolean { - if (action(UP, widget, buffer)) { - return true - } - return super.left(widget, buffer) - } - - override fun right(widget: TextView, buffer: Spannable): Boolean { - if (action(DOWN, widget, buffer)) { - return true - } - return super.right(widget, buffer) - } - - private fun action(what: Int, widget: TextView, buffer: Spannable): Boolean { - val layout = widget.layout - val padding = widget.totalPaddingTop + widget.totalPaddingBottom - val areaTop = widget.scrollY - val areaBot = areaTop + widget.height - padding - val lineTop = layout.getLineForVertical(areaTop) - val lineBot = layout.getLineForVertical(areaBot) - val first = layout.getLineStart(lineTop) - val last = layout.getLineEnd(lineBot) - val candidates = buffer.getSpans(first, last, ClickableSpan::class.java) - val a = Selection.getSelectionStart(buffer) - val b = Selection.getSelectionEnd(buffer) - var selStart = min(a, b) - var selEnd = max(a, b) - if (selStart < 0) { - if (buffer.getSpanStart(FROM_BELOW) >= 0) { - selEnd = buffer.length - selStart = selEnd - } - } - if (selStart > last) { - selEnd = Int.MAX_VALUE - selStart = selEnd - } - if (selEnd < first) { - selEnd = -1 - selStart = selEnd - } - when (what) { - CLICK -> { - if (selStart == selEnd) { - return false - } - val link = buffer.getSpans(selStart, selEnd, ClickableSpan::class.java) - if (link.size != 1) { - return false - } - link[0].onClick(widget) - } - UP -> { - var bestStart = -1 - var bestEnd = -1 - for (candidate in candidates) { - val end = buffer.getSpanEnd(candidate) - if (end < selEnd || selStart == selEnd) { - if (end > bestEnd) { - bestStart = buffer.getSpanStart(candidate) - bestEnd = end - } - } - } - if (bestStart >= 0) { - Selection.setSelection(buffer, bestEnd, bestStart) - return true - } - } - DOWN -> { - var bestStart = Int.MAX_VALUE - var bestEnd = Int.MAX_VALUE - for (candidate in candidates) { - val start = buffer.getSpanStart(candidate) - if (start > selStart || selStart == selEnd) { - if (start < bestStart) { - bestStart = start - bestEnd = buffer.getSpanEnd(candidate) - } - } - } - if (bestEnd < Int.MAX_VALUE) { - Selection.setSelection(buffer, bestStart, bestEnd) - return true - } - } - } - return false - } - - override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { - val action = event.action - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { - var x = event.x.toInt() - var y = event.y.toInt() - x -= widget.totalPaddingLeft - y -= widget.totalPaddingTop - x += widget.scrollX - y += widget.scrollY - val layout = widget.layout - val line = layout.getLineForVertical(y) - val off = layout.getOffsetForHorizontal(line, x.toFloat()) - val links = buffer.getSpans(off, off, ClickableSpan::class.java) - if (links.isNotEmpty()) { - if (action == MotionEvent.ACTION_UP) { - links[0].onClick(widget) - } else if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection( - buffer, buffer.getSpanStart(links[0]), buffer.getSpanEnd(links[0]) - ) - } - return true - } - // Removed - //else { - // Selection.removeSelection(buffer); - //} - } - return super.onTouchEvent(widget, buffer, event) - } -} diff --git a/app/src/main/java/me/zhanghai/android/files/util/LiveDataExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/LiveDataExtensions.kt index ae8a5ecf2..a98c6bd23 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/LiveDataExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/LiveDataExtensions.kt @@ -6,12 +6,7 @@ package me.zhanghai.android.files.util import androidx.lifecycle.LiveData -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map @Suppress("UNCHECKED_CAST") val LiveData.valueCompat: T get() = value as T - -inline fun LiveData.mapDistinct(crossinline mapFunction: (X) -> Y): LiveData = - map(mapFunction).distinctUntilChanged() diff --git a/app/src/main/java/me/zhanghai/android/files/util/LocaleListCompatExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/LocaleListCompatExtensions.kt new file mode 100644 index 000000000..fbcf5d016 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/LocaleListCompatExtensions.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import androidx.core.os.LocaleListCompat +import java.util.Locale + +fun LocaleListCompat.toList(): List = List(size()) { this[it]!! } diff --git a/app/src/main/java/me/zhanghai/android/files/util/MediaMetadataRetrieverPathExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/MediaMetadataRetrieverPathExtensions.kt index 23abe8132..ce0bb6273 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/MediaMetadataRetrieverPathExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/MediaMetadataRetrieverPathExtensions.kt @@ -5,14 +5,26 @@ package me.zhanghai.android.files.util +import android.media.MediaDataSource import android.media.MediaMetadataRetriever +import android.os.Build +import androidx.annotation.RequiresApi +import java8.nio.channels.SeekableByteChannel import java8.nio.file.Path +import me.zhanghai.android.files.provider.common.newByteChannel import me.zhanghai.android.files.provider.document.isDocumentPath import me.zhanghai.android.files.provider.document.resolver.DocumentResolver +import me.zhanghai.android.files.provider.ftp.isFtpPath import me.zhanghai.android.files.provider.linux.isLinuxPath +import java.io.IOException +import java.nio.ByteBuffer val Path.isMediaMetadataRetrieverCompatible: Boolean - get() = isLinuxPath || isDocumentPath + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + !isFtpPath + } else { + isLinuxPath || isDocumentPath + } fun MediaMetadataRetriever.setDataSource(path: Path) { when { @@ -20,6 +32,33 @@ fun MediaMetadataRetriever.setDataSource(path: Path) { path.isDocumentPath -> DocumentResolver.openParcelFileDescriptor(path as DocumentResolver.Path, "r") .use { pfd -> setDataSource(pfd.fileDescriptor) } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + val channel = try { + path.newByteChannel() + } catch (e: IOException) { + throw IllegalArgumentException(e) + } + setDataSource(PathMediaDataSource(channel)) + } else -> throw IllegalArgumentException(path.toString()) } } + +@RequiresApi(Build.VERSION_CODES.M) +private class PathMediaDataSource(private val channel: SeekableByteChannel) : MediaDataSource() { + @Throws(IOException::class) + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + channel.position(position) + return channel.read(ByteBuffer.wrap(buffer, offset, size)) + } + + @Throws(IOException::class) + override fun getSize(): Long { + return channel.size() + } + + @Throws(IOException::class) + override fun close() { + channel.close() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/util/NotificationTemplate.kt b/app/src/main/java/me/zhanghai/android/files/util/NotificationTemplate.kt index 0d1071fca..c93a4132c 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/NotificationTemplate.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/NotificationTemplate.kt @@ -24,6 +24,7 @@ class NotificationTemplate( @StringRes val contentTitleRes: Int? = null, @StringRes val contentTextRes: Int? = null, val ongoing: Boolean? = null, + val onlyAlertOnce: Boolean? = null, val autoCancel: Boolean? = null, val category: String? = null, val priority: Int? = null @@ -35,6 +36,7 @@ class NotificationTemplate( contentTitleRes?.let { setContentTitle(context.getText(contentTitleRes)) } contentTextRes?.let { setContentText(context.getText(contentTextRes)) } ongoing?.let { setOngoing(it) } + onlyAlertOnce?.let { setOnlyAlertOnce(it) } autoCancel?.let { setAutoCancel(it) } category?.let { setCategory(it) } this@NotificationTemplate.priority?.let { priority = it } diff --git a/app/src/main/java/me/zhanghai/android/files/util/ParcelExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/ParcelExtensions.kt index da0d2c5d3..83ad45c3c 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/ParcelExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/ParcelExtensions.kt @@ -7,24 +7,26 @@ package me.zhanghai.android.files.util import android.os.Parcel import android.os.Parcelable -import me.zhanghai.android.files.compat.readParcelableListCompat import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract +import me.zhanghai.android.files.app.appClassLoader +import me.zhanghai.android.files.compat.readParcelableListCompat -inline fun Parcel.readParcelable(): T? = - readParcelable(T::class.java.classLoader) +@Suppress("DEPRECATION") +fun Parcel.readParcelable(): T? = readParcelable(appClassLoader) fun Parcel.readParcelableListCompat(classLoader: ClassLoader?): List = readParcelableListCompat(mutableListOf(), classLoader) -inline fun > Parcel.readParcelableListCompat( - list: L -): L = readParcelableListCompat(list, E::class.java.classLoader) +fun > Parcel.readParcelableListCompat(list: L): L = + readParcelableListCompat(list, appClassLoader) -inline fun Parcel.readParcelableListCompat(): List = +fun Parcel.readParcelableListCompat(): List = readParcelableListCompat(mutableListOf()) +@Suppress("UNCHECKED_CAST") fun Parcel.readValue(): T? = readValue(appClassLoader) as T? + @OptIn(ExperimentalContracts::class) inline fun Parcel.use(block: (Parcel) -> R): R { contract { @@ -36,3 +38,17 @@ inline fun Parcel.use(block: (Parcel) -> R): R { recycle() } } + +@OptIn(ExperimentalContracts::class) +inline fun Parcel.withPosition(position: Int, block: Parcel.() -> R): R { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + val savedPosition = dataPosition() + setDataPosition(position) + return try { + block(this) + } finally { + setDataPosition(savedPosition) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/util/ParcelSlicedList.kt b/app/src/main/java/me/zhanghai/android/files/util/ParcelSlicedList.kt index 954cc448a..c974d8d19 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/ParcelSlicedList.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/ParcelSlicedList.kt @@ -10,7 +10,7 @@ import android.os.Parcel import android.os.Parcelable // @see android.content.pm.ParceledListSlice -class ParcelSlicedList : Parcelable { +class ParcelSlicedList : Parcelable { val list: List constructor(list: List) { @@ -38,24 +38,19 @@ class ParcelSlicedList : Parcelable { private fun readSliceFromParcel(list: MutableList, source: Parcel) { val size = source.readInt() repeat(size) { - @Suppress("UNCHECKED_CAST") - val element = source.readParcelable(ParcelSlicedList::class.java.classLoader) as T + @Suppress("UNCHECKED_CAST") val element = source.readValue() as T list += element } } - override fun describeContents(): Int = - list.fold(0) { contentFlags, parcelable -> - contentFlags or (parcelable?.describeContents() ?: 0) - } + override fun describeContents(): Int = 0 override fun writeToParcel(dest: Parcel, flags: Int) { val size = list.size dest.writeInt(size) val iterator = list.iterator() - writeSliceToParcel(iterator, dest, flags) + writeSliceToParcel(iterator, dest) if (iterator.hasNext()) { - val writeFlags = flags dest.writeStrongBinder(object : Binder() { override fun onTransact( code: Int, @@ -66,7 +61,7 @@ class ParcelSlicedList : Parcelable { when (code) { FIRST_CALL_TRANSACTION -> { if (reply != null) { - writeSliceToParcel(iterator, reply, writeFlags) + writeSliceToParcel(iterator, reply) } true } @@ -76,13 +71,13 @@ class ParcelSlicedList : Parcelable { } } - private fun writeSliceToParcel(iterator: Iterator, dest: Parcel, flags: Int) { + private fun writeSliceToParcel(iterator: Iterator, dest: Parcel) { val startPosition = dest.dataPosition() dest.writeInt(0) var size = 0 while (iterator.hasNext() && dest.dataSize() < MAX_IPC_SIZE) { val element = iterator.next() - dest.writeParcelable(element, flags) + dest.writeValue(element) ++size } val endPosition = dest.dataPosition() @@ -93,7 +88,7 @@ class ParcelSlicedList : Parcelable { companion object { // @see IBinder.MAX_IPC_SIZE - const val MAX_IPC_SIZE = 64 * 1024 + private const val MAX_IPC_SIZE = 64 * 1024 @JvmField val CREATOR = object : Parcelable.Creator> { diff --git a/app/src/main/java/me/zhanghai/android/files/util/RuntimeBroadcastReceiver.kt b/app/src/main/java/me/zhanghai/android/files/util/RuntimeBroadcastReceiver.kt new file mode 100644 index 000000000..918b933c1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/RuntimeBroadcastReceiver.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import me.zhanghai.android.files.compat.registerReceiverCompat + +class RuntimeBroadcastReceiver( + private val filter: IntentFilter, + private val receiver: BroadcastReceiver, + private val context: Context, + private val flags: Int = ContextCompat.RECEIVER_NOT_EXPORTED +) { + fun register() { + context.registerReceiverCompat(receiver, filter, flags) + } + + fun unregister() { + context.unregisterReceiver(receiver) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/util/StableUriParceler.kt b/app/src/main/java/me/zhanghai/android/files/util/StableUriParceler.kt new file mode 100644 index 000000000..f40da78b4 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/StableUriParceler.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import android.net.Uri +import android.os.Build +import android.os.Parcel +import androidx.annotation.RequiresApi +import kotlinx.parcelize.Parceler +import me.zhanghai.android.files.hiddenapi.RestrictedHiddenApi + +// The built-in parceling of Uri isn't guaranteed to be stable and has changed 2 times, so we should +// always use our own parceling for persistence. +// The reading code here is backwards compatible in that it also handles built-in parceling of Uri, +// including the changes in +// https://android.googlesource.com/platform/frameworks/base/+/97f621d81fc51de240ba73bc008d997e0eea7939 , +// and the String8 change in API 30. +object StableUriParceler : Parceler { + private const val NULL_TYPE_ID = 0 + private const val STRING_URI_TYPE_ID = 1 + private const val OPAQUE_URI_TYPE_ID = 2 + private const val HIERARCHICAL_URI_TYPE_ID = 3 + + private const val REPRESENTATION_BOTH = 0 + private const val REPRESENTATION_ENCODED = 1 + private const val REPRESENTATION_DECODED = 2 + + @get:RequiresApi(Build.VERSION_CODES.R) + @RestrictedHiddenApi + private val parcelReadString8Method by lazyReflectedMethod(Parcel::class.java, "readString8") + + override fun create(parcel: Parcel): Uri? { + val uriString = parcel.readString() ?: return null + // Parcel.readParcelableCreator() + return if (uriString.startsWith(Uri::class.java.name)) { + readUri(parcel) + } else { + Uri.parse(uriString) + } + } + + // Uri.CREATOR.createFromParcel() + private fun readUri(parcel: Parcel): Uri? { + val uriString = when (val typeId = parcel.readInt()) { + NULL_TYPE_ID -> return null + // Uri.StringUri.readFrom() + STRING_URI_TYPE_ID -> parcel.readUriString() + OPAQUE_URI_TYPE_ID -> { + // Uri.OpaqueUri.readFrom() + val scheme = parcel.readUriString()!! + // Assume that we never persist a Uri with only a scheme. + if (scheme.contains(':')) { + scheme + } else { + val encodedSsp = readEncodedPart(parcel) + val encodedFragment = readEncodedPart(parcel) + // Uri.OpaqueUri.toString() + buildString { + append(scheme) + append(':') + append(encodedSsp) + if (!encodedFragment.isNullOrEmpty()) { + append('#') + append(encodedFragment) + } + } + } + } + HIERARCHICAL_URI_TYPE_ID -> { + // Uri.HierarchicalUri.readFrom() + // Scheme can be null for HierarchicalUri. + val scheme = parcel.readUriString() + // Assume that we never persist a Uri with only a scheme. + if (scheme != null && scheme.contains(':')) { + scheme + } else { + val encodedAuthority = readEncodedPart(parcel) + val hasSchemeOrAuthority = !scheme.isNullOrEmpty() || + !encodedAuthority.isNullOrEmpty() + val encodedPath = readEncodedPathPart(hasSchemeOrAuthority, parcel) + val encodedQuery = readEncodedPart(parcel) + val encodedFragment = readEncodedPart(parcel) + // Uri.HierarchicalUri.toString() + buildString { + if (scheme != null) { + append(scheme) + append(':') + } + if (encodedAuthority != null) { + append("//") + append(encodedAuthority) + } + if (encodedPath != null) { + append(encodedPath) + } + if (!encodedQuery.isNullOrEmpty()) { + append('?') + append(encodedQuery) + } + if (!encodedFragment.isNullOrEmpty()) { + append('#') + append(encodedFragment) + } + } + } + } + else -> error("Unknown type ID $typeId") + } + return Uri.parse(uriString) + } + + // Uri.Part.readFrom() + private fun readEncodedPart(parcel: Parcel): String? = + when (val representation = parcel.readInt()) { + REPRESENTATION_BOTH -> parcel.readUriString().also { parcel.readUriString() } + REPRESENTATION_ENCODED -> parcel.readUriString() + REPRESENTATION_DECODED -> Uri.encode(parcel.readUriString()) + else -> error("Unknown representation $representation") + } + + // Uri.PathPart.readFrom() + private fun readEncodedPathPart(hasSchemeOrAuthority: Boolean, parcel: Parcel): String? { + val encodedPathPart = when (val representation = parcel.readInt()) { + REPRESENTATION_BOTH -> parcel.readUriString().also { parcel.readUriString() } + REPRESENTATION_ENCODED -> parcel.readUriString() + REPRESENTATION_DECODED -> Uri.encode(parcel.readUriString(), "/") + else -> error("Unknown representation $representation") + } + return if (hasSchemeOrAuthority) { + makeEncodedPathPartAbsolute(encodedPathPart) + } else { + encodedPathPart + } + } + + // Uri.PathPart.makeAbsolute() + private fun makeEncodedPathPartAbsolute(encodedPathPart: String?): String? = + if (encodedPathPart.isNullOrEmpty() || encodedPathPart.startsWith("/")) { + encodedPathPart + } else { + "/${encodedPathPart}" + } + + private fun Parcel.readUriString(): String? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + parcelReadString8Method.invoke(this) as String? + } else { + readString() + } + + override fun Uri?.write(parcel: Parcel, flags: Int) { + parcel.writeString(this?.toString()) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/util/StatusBarManagerExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/StatusBarManagerExtensions.kt new file mode 100644 index 000000000..6e50da493 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/StatusBarManagerExtensions.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.pm.PackageManager +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import me.zhanghai.android.files.app.application +import me.zhanghai.android.files.app.packageManager +import java.util.concurrent.Executor + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +fun StatusBarManager.requestAddTileService( + serviceClass: Class, + resultExecutor: Executor, + resultCallback: (Int) -> Unit +) { + val application = application + val componentName = ComponentName(application, serviceClass) + val packageManager = packageManager + val serviceInfo = packageManager.getServiceInfo( + componentName, + PackageManager.MATCH_DIRECT_BOOT_AWARE or PackageManager.MATCH_DIRECT_BOOT_UNAWARE + ) + val label = serviceInfo.loadLabel(packageManager) + val icon = Icon.createWithResource(application, serviceInfo.iconResource) + requestAddTileService(componentName, label, icon, resultExecutor, resultCallback) +} diff --git a/app/src/main/java/me/zhanghai/android/files/util/TextViewExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/TextViewExtensions.kt index 3ed732856..f069bba33 100644 --- a/app/src/main/java/me/zhanghai/android/files/util/TextViewExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/TextViewExtensions.kt @@ -92,5 +92,5 @@ fun TextView.setSpanClickable() { fun TextView.setSpanClickableAndTextSelectable() { setTextIsSelectable(true) - movementMethod = LinkArrowKeyMovementMethod + movementMethod = ClickableArrowKeyMovementMethod } diff --git a/app/src/main/java/me/zhanghai/android/files/util/TypedValueExtensions.kt b/app/src/main/java/me/zhanghai/android/files/util/TypedValueExtensions.kt new file mode 100644 index 000000000..e274d0e4e --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/util/TypedValueExtensions.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.util + +import android.util.TypedValue +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KClass + +inline fun KClass.useTemp(block: (TypedValue) -> T): T { + val temp = TypedValue::class.obtainTemp() + return try { + block(temp) + } finally { + temp.releaseTemp() + } +} + +private val tempTypedValue = AtomicReference(TypedValue()) + +fun KClass.obtainTemp(): TypedValue = tempTypedValue.getAndSet(null) ?: TypedValue() + +fun TypedValue.releaseTemp() { + tempTypedValue.compareAndSet(null, this) +} diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerWakeLock.kt b/app/src/main/java/me/zhanghai/android/files/util/WakeWifiLock.kt similarity index 52% rename from app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerWakeLock.kt rename to app/src/main/java/me/zhanghai/android/files/util/WakeWifiLock.kt index f4a260fda..9cfd15ca5 100644 --- a/app/src/main/java/me/zhanghai/android/files/ftpserver/FtpServerWakeLock.kt +++ b/app/src/main/java/me/zhanghai/android/files/util/WakeWifiLock.kt @@ -3,31 +3,32 @@ * All Rights Reserved. */ -package me.zhanghai.android.files.ftpserver +package me.zhanghai.android.files.util import android.net.wifi.WifiManager import android.os.PowerManager import me.zhanghai.android.files.app.powerManager import me.zhanghai.android.files.app.wifiManager -class FtpServerWakeLock { - private val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG) +class WakeWifiLock(tag: String) { + private val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag) .apply { setReferenceCounted(false) } private val wifiLock = - wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, LOCK_TAG) + wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag) .apply { setReferenceCounted(false) } - fun acquire() { - wakeLock.acquire() - wifiLock.acquire() - } - - fun release() { - wifiLock.release() - wakeLock.release() - } - - companion object { - private val LOCK_TAG = FtpServerWakeLock::class.java.simpleName - } + var isAcquired: Boolean = false + set(value) { + if (field == value) { + return + } + if (value) { + wakeLock.acquire() + wifiLock.acquire() + } else { + wifiLock.release() + wakeLock.release() + } + field = value + } } diff --git a/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerAdapter.kt b/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerAdapter.kt index dcf35681c..89b900921 100644 --- a/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerAdapter.kt +++ b/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerAdapter.kt @@ -12,9 +12,9 @@ import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import coil.clear -import coil.loadAny -import coil.size.OriginalSize +import coil.dispose +import coil.load +import coil.size.Size import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.DefaultOnImageEventListener @@ -63,7 +63,7 @@ class ImageViewerAdapter( super.onViewRecycled(holder) val binding = holder.binding - binding.image.clear() + binding.image.dispose() binding.largeImage.recycle() } @@ -103,12 +103,12 @@ class ImageViewerAdapter( if (!imageInfo.shouldUseLargeImageView) { binding.image.apply { isVisible = true - loadAny(path to imageInfo.attributes) { - size(OriginalSize) + load(path to imageInfo.attributes) { + size(Size.ORIGINAL) fadeIn(context.shortAnimTime) listener( onSuccess = { _, _ -> binding.progress.fadeOutUnsafe() }, - onError = { _, e -> showError(binding, e) } + onError = { _, result -> showError(binding, result.throwable) } ) } } diff --git a/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt b/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt index eda4b9e4e..cb082f449 100644 --- a/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt @@ -15,6 +15,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.doOnPreDraw import androidx.fragment.app.Fragment import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.viewpager2.widget.ViewPager2 @@ -100,17 +101,22 @@ class ImageViewerFragment : Fragment(), ConfirmDeleteDialogFragment.Listener { } // This will set up window flags. systemUiHelper.show() - adapter = ImageViewerAdapter(viewLifecycleOwner) { systemUiHelper.toggle() } - adapter.replace(paths) - binding.viewPager.adapter = adapter - // ViewPager saves its position and will restore it later. - binding.viewPager.setCurrentItem(args.position, false) - binding.viewPager.setPageTransformer(DepthPageTransformer) - binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - updateTitle() - } - }) + adapter = ImageViewerAdapter(viewLifecycleOwner) { systemUiHelper.toggle() }.apply { + replace(paths) + } + binding.viewPager.apply { + // 1 is the default for the old androidx.viewpager.widget.ViewPager. + offscreenPageLimit = 1 + adapter = this@ImageViewerFragment.adapter + // ViewPager saves its position and will restore it later. + setCurrentItem(args.position, false) + setPageTransformer(DepthPageTransformer) + registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + updateTitle() + } + }) + } } override fun onViewStateRestored(savedInstanceState: Bundle?) { @@ -174,6 +180,9 @@ class ImageViewerFragment : Fragment(), ConfirmDeleteDialogFragment.Listener { binding.viewPager.currentItem = paths.lastIndex } updateTitle() + // Work around blank screen due to ViewPager2.PageTransformer not being called (and thus the + // next item keeps its 0 alpha) when we have offscreenPageLimit = 1. + binding.viewPager.doOnPreDraw { binding.viewPager.requestTransform() } } private fun updateTitle() { diff --git a/app/src/main/java/me/zhanghai/android/files/viewer/saveas/SaveAsActivity.kt b/app/src/main/java/me/zhanghai/android/files/viewer/saveas/SaveAsActivity.kt new file mode 100644 index 000000000..a550f34c4 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/viewer/saveas/SaveAsActivity.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.viewer.saveas + +import android.os.Bundle +import android.os.Environment +import java8.nio.file.Path +import java8.nio.file.Paths +import me.zhanghai.android.files.R +import me.zhanghai.android.files.app.AppActivity +import me.zhanghai.android.files.file.MimeType +import me.zhanghai.android.files.file.asMimeTypeOrNull +import me.zhanghai.android.files.filejob.FileJobService +import me.zhanghai.android.files.filelist.FileListActivity +import me.zhanghai.android.files.util.saveAsPath +import me.zhanghai.android.files.util.showToast + +class SaveAsActivity : AppActivity() { + private val createFileLauncher = + registerForActivityResult(FileListActivity.CreateFileContract(), ::onCreateFileResult) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val intent = intent + val mimeType = intent.type?.asMimeTypeOrNull() ?: MimeType.ANY + val path = intent.saveAsPath + if (path == null) { + showToast(R.string.save_as_error) + finish() + return + } + val title = path.fileName.toString() + val initialPath = + Paths.get( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path + ) + createFileLauncher.launch(Triple(mimeType, title, initialPath)) + } + + private fun onCreateFileResult(result: Path?) { + if (result == null) { + finish() + return + } + FileJobService.save(intent.saveAsPath!!, result, this) + finish() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorActivity.kt b/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorActivity.kt index 1c6b04c05..dc2a6c4cb 100644 --- a/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorActivity.kt +++ b/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorActivity.kt @@ -28,15 +28,8 @@ class TextEditorActivity : AppActivity() { } } - override fun onBackPressed() { - if (fragment.onFinish()) { - return - } - super.onBackPressed() - } - override fun onSupportNavigateUp(): Boolean { - if (fragment.onFinish()) { + if (fragment.onSupportNavigateUp()) { return true } return super.onSupportNavigateUp() diff --git a/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorFragment.kt b/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorFragment.kt index 64651265d..7ba7d772e 100644 --- a/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/viewer/text/TextEditorFragment.kt @@ -14,6 +14,7 @@ import android.view.MenuItem import android.view.SubMenu import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children import androidx.core.widget.doAfterTextChanged @@ -28,6 +29,7 @@ import me.zhanghai.android.files.ui.ThemedFastScroller import me.zhanghai.android.files.util.ActionState import me.zhanghai.android.files.util.DataState import me.zhanghai.android.files.util.ParcelableArgs +import me.zhanghai.android.files.util.addOnBackPressedCallback import me.zhanghai.android.files.util.args import me.zhanghai.android.files.util.extraPath import me.zhanghai.android.files.util.fadeInUnsafe @@ -48,6 +50,8 @@ class TextEditorFragment : Fragment(), ConfirmReloadDialogFragment.Listener, private val viewModel by viewModels { { TextEditorViewModel(argsFile) } } + private lateinit var onBackPressedCallback: OnBackPressedCallback + private var isSettingText = false override fun onCreate(savedInstanceState: Bundle?) { @@ -56,6 +60,18 @@ class TextEditorFragment : Fragment(), ConfirmReloadDialogFragment.Listener, setHasOptionsMenu(true) lifecycleScope.launchWhenStarted { + onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + ConfirmCloseDialogFragment.show(this@TextEditorFragment) + } + } + launch { + viewModel.isTextChanged.collect { + onBackPressedCallback.isEnabled = viewModel.isTextChanged.value + } + } + addOnBackPressedCallback(onBackPressedCallback) + launch { viewModel.encoding.collect { onEncodingChanged(it) } } launch { viewModel.textState.collect { onTextStateChanged(it) } } launch { viewModel.isTextChanged.collect { onIsTextChangedChanged(it) } } @@ -142,15 +158,15 @@ class TextEditorFragment : Fragment(), ConfirmReloadDialogFragment.Listener, true } Menu.FIRST -> { - viewModel.encoding.value = Charset.forName(item.titleCondensed.toString()) + viewModel.encoding.value = Charset.forName(item.titleCondensed!!.toString()) true } else -> super.onOptionsItemSelected(item) } - fun onFinish(): Boolean { - if (viewModel.isTextChanged.value) { - ConfirmCloseDialogFragment.show(this) + fun onSupportNavigateUp(): Boolean { + if (onBackPressedCallback.isEnabled) { + onBackPressedCallback.handleOnBackPressed() return true } return false @@ -272,7 +288,7 @@ class TextEditorFragment : Fragment(), ConfirmReloadDialogFragment.Listener, companion object { fun inflate(menu: Menu, inflater: MenuInflater): MenuBinding { inflater.inflate(R.menu.text_editor, menu) - val encodingSubMenu = menu.findItem(R.id.action_encoding).subMenu + val encodingSubMenu = menu.findItem(R.id.action_encoding).subMenu!! for ((charsetName, charset) in Charset.availableCharsets()) { // HACK: Use titleCondensed to store charset name. encodingSubMenu.add(Menu.NONE, Menu.FIRST, Menu.NONE, charset.displayName()) diff --git a/app/src/main/jni/syscalls.c b/app/src/main/jni/syscall.c similarity index 93% rename from app/src/main/jni/syscalls.c rename to app/src/main/jni/syscall.c index fa2150ed1..3a8cbd28f 100644 --- a/app/src/main/jni/syscalls.c +++ b/app/src/main/jni/syscall.c @@ -32,7 +32,7 @@ #define ALOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) #define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -#define LOG_TAG "syscalls" +#define LOG_TAG "syscall" #undef TEMP_FAILURE_RETRY // Checks errno when return value is -1. @@ -372,7 +372,7 @@ static jobject newFileDescriptor(JNIEnv *env, int fd) { } JNIEXPORT jboolean JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_access( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_access( JNIEnv *env, jclass clazz, jobject javaPath, jint javaMode) { char *path = mallocStringFromByteString(env, javaPath); int mode = javaMode; @@ -387,7 +387,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_access( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_chmod( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_chmod( JNIEnv *env, jclass clazz, jobject javaPath, jint javaMode) { char *path = mallocStringFromByteString(env, javaPath); mode_t mode = (mode_t) javaMode; @@ -399,7 +399,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_chmod( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_chown( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_chown( JNIEnv *env, jclass clazz, jobject javaPath, jint javaUid, jint javaGid) { char *path = mallocStringFromByteString(env, javaPath); uid_t uid = (uid_t) javaUid; @@ -412,7 +412,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_chown( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_closedir( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_closedir( JNIEnv *env, jclass clazz, jlong javaDir) { DIR *dir = (DIR *) javaDir; TEMP_FAILURE_RETRY(closedir(dir)); @@ -422,7 +422,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_closedir( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_endmntent( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_endmntent( JNIEnv *env, jclass clazz, jlong javaFile) { FILE *file = (FILE *) javaFile; // The endmntent() function always returns 1. @@ -461,7 +461,7 @@ void endgrent() { #endif JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_endgrent(JNIEnv *env, jclass clazz) { +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_endgrent(JNIEnv *env, jclass clazz) { TEMP_FAILURE_RETRY_V(endgrent()); if (errno) { throwSyscallException(env, "endgrent"); @@ -495,7 +495,7 @@ void endpwent() { #endif JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_endpwent(JNIEnv *env, jclass clazz) { +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_endpwent(JNIEnv *env, jclass clazz) { TEMP_FAILURE_RETRY_V(endpwent()); if (errno) { throwSyscallException(env, "endpwent"); @@ -503,13 +503,13 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_endpwent(JNIEnv * } JNIEXPORT jint JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_errno( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_errno( JNIEnv *env, jclass clazz) { return errno; } JNIEXPORT jint JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_fcntl_1int( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_fcntl_1int( JNIEnv *env, jclass clazz, jobject javaFd, jint javaCmd, jint javaArg) { int fd = getFdFromFileDescriptor(env, javaFd); int cmd = javaCmd; @@ -523,7 +523,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_fcntl_1int( } JNIEXPORT jint JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_fcntl_1void( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_fcntl_1void( JNIEnv *env, jclass clazz, jobject javaFd, jint javaCmd) { int fd = getFdFromFileDescriptor(env, javaFd); int cmd = javaCmd; @@ -590,7 +590,7 @@ static jobject newStructGroup(JNIEnv *env, const struct group *group) { } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getgrent(JNIEnv *env, jclass clazz) { +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getgrent(JNIEnv *env, jclass clazz) { while (true) { // getgrent() in bionic is thread safe. struct group *group = TEMP_FAILURE_RETRY_N(getgrent()); @@ -618,7 +618,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getgrent(JNIEnv * } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getgrgid( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getgrgid( JNIEnv *env, jclass clazz, jint javaGid) { #if __ANDROID_API__ >= __ANDROID_API_N__ gid_t gid = (gid_t) javaGid; @@ -655,7 +655,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getgrgid( } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getgrnam( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getgrnam( JNIEnv *env, jclass clazz, jobject javaName) { #if __ANDROID_API__ >= __ANDROID_API_N__ char *name = mallocStringFromByteString(env, javaName); @@ -751,7 +751,7 @@ static jobject newStructMntent(JNIEnv *env, const struct mntent *mntent) { } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getmntent( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getmntent( JNIEnv *env, jclass clazz, jlong javaFile) { FILE *file = (FILE *) javaFile; #if __ANDROID_API__ >= __ANDROID_API_L_MR1__ @@ -830,7 +830,7 @@ static jobject newStructPasswd(JNIEnv *env, const struct passwd *passwd) { } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getpwent(JNIEnv *env, jclass clazz) { +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getpwent(JNIEnv *env, jclass clazz) { while (true) { // getpwent() in bionic is thread safe. struct passwd *passwd = TEMP_FAILURE_RETRY_N(getpwent()); @@ -853,7 +853,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getpwent(JNIEnv * } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getpwnam( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getpwnam( JNIEnv *env, jclass clazz, jobject javaName) { char *name = mallocStringFromByteString(env, javaName); size_t bufferSize = (size_t) sysconf(_SC_GETPW_R_SIZE_MAX); @@ -878,7 +878,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getpwnam( } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_getpwuid( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_getpwuid( JNIEnv *env, jclass clazz, jint javaUid) { uid_t uid = (uid_t) javaUid; size_t bufferSize = (size_t) sysconf(_SC_GETPW_R_SIZE_MAX); @@ -926,7 +926,7 @@ static char* _hasmntopt(const struct mntent* mnt, const char* opt) { #endif JNIEXPORT jboolean JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_hasmntopt( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_hasmntopt( JNIEnv *env, jclass clazz, jobject javaMntent, jobject javaOption) { struct mntent mntent = {}; mntent.mnt_opts = mallocMntOptsFromStructMntent(env, javaMntent); @@ -943,7 +943,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_hasmntopt( } JNIEXPORT jint JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1add_1watch( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_inotify_1add_1watch( JNIEnv *env, jclass clazz, jobject javaFd, jobject javaPath, jint javaMask) { int fd = getFdFromFileDescriptor(env, javaFd); char *path = mallocStringFromByteString(env, javaPath); @@ -958,7 +958,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1add_1wat } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1init1( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_inotify_1init1( JNIEnv *env, jclass clazz, jint javaFlags) { int flags = javaFlags; int fd = TEMP_FAILURE_RETRY(inotify_init1(flags)); @@ -993,7 +993,7 @@ static jobject newStructInotifyEvent(JNIEnv *env, const struct inotify_event *ev } JNIEXPORT jobjectArray JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1get_1events( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_inotify_1get_1events( JNIEnv *env, jclass clazz, jbyteArray javaBuffer, jint javaOffset, jint javaLength) { void *buffer = (*env)->GetByteArrayElements(env, javaBuffer, NULL); size_t offset = (size_t) javaOffset; @@ -1031,7 +1031,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1get_1eve } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1rm_1watch( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_inotify_1rm_1watch( JNIEnv *env, jclass clazz, jobject javaFd, jint javaWd) { int fd = getFdFromFileDescriptor(env, javaFd); uint32_t wd = (uint32_t) javaWd; @@ -1042,7 +1042,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_inotify_1rm_1watc } JNIEXPORT jint JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_ioctl_1int( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_ioctl_1int( JNIEnv* env, jclass clazz, jobject javaFd, jint javaRequest, jobject javaArgument) { int fd = getFdFromFileDescriptor(env, javaFd); int request = javaRequest; @@ -1064,7 +1064,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_ioctl_1int( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lchown( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_lchown( JNIEnv *env, jclass clazz, jobject javaPath, jint javaUid, jint javaGid) { char *path = mallocStringFromByteString(env, javaPath); uid_t uid = (uid_t) javaUid; @@ -1077,7 +1077,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lchown( } JNIEXPORT jbyteArray JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lgetxattr( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_lgetxattr( JNIEnv *env, jclass clazz, jobject javaPath, jobject javaName) { char *path = mallocStringFromByteString(env, javaPath); char *name = mallocStringFromByteString(env, javaName); @@ -1116,7 +1116,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lgetxattr( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_link( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_link( JNIEnv *env, jclass clazz, jobject javaOldPath, jobject javaNewPath) { char *oldPath = mallocStringFromByteString(env, javaOldPath); char *newPath = mallocStringFromByteString(env, javaNewPath); @@ -1129,7 +1129,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_link( } JNIEXPORT jobjectArray JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_llistxattr( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_llistxattr( JNIEnv *env, jclass clazz, jobject javaPath) { char *path = mallocStringFromByteString(env, javaPath); jobjectArray javaNames = NULL; @@ -1188,7 +1188,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_llistxattr( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lsetxattr( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_lsetxattr( JNIEnv *env, jclass clazz, jobject javaPath, jobject javaName, jbyteArray javaValue, jint javaFlags) { char *path = mallocStringFromByteString(env, javaPath); @@ -1263,7 +1263,7 @@ static jobject doStat(JNIEnv *env, jobject javaPath, bool isLstat) { } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lstat( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_lstat( JNIEnv *env, jclass clazz, jobject javaPath) { return doStat(env, javaPath, true); } @@ -1293,13 +1293,13 @@ doUtimens(JNIEnv *env, jobject javaPath, jobjectArray javaTimes, bool isLutimens } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_lutimens( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_lutimens( JNIEnv *env, jclass clazz, jobject javaPath, jobjectArray javaTimes) { doUtimens(env, javaPath, javaTimes, true); } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_mkdir( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_mkdir( JNIEnv *env, jclass clazz, jobject javaPath, jint javaMode) { char *path = mallocStringFromByteString(env, javaPath); mode_t mode = (mode_t) javaMode; @@ -1311,7 +1311,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_mkdir( } JNIEXPORT jint JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_mount( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_mount( JNIEnv *env, jclass clazz, jobject javaSource, jobject javaTarget, jobject javaFileSystemType, jlong javaMountFlags, jbyteArray javaData) { if (geteuid() != 0) { @@ -1345,7 +1345,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_mount( } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_open( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_open( JNIEnv *env, jclass clazz, jobject javaPath, jint javaFlags, jint javaMode) { char *path = mallocStringFromByteString(env, javaPath); int flags = javaFlags; @@ -1360,7 +1360,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_open( } JNIEXPORT jlong JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_opendir( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_opendir( JNIEnv *env, jclass clazz, jobject javaPath) { char *path = mallocStringFromByteString(env, javaPath); DIR *dir = TEMP_FAILURE_RETRY_N(opendir(path)); @@ -1391,7 +1391,7 @@ static jobject newStructDirent(JNIEnv *env, const struct dirent64 *dirent) { } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_readdir( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_readdir( JNIEnv *env, jclass clazz, jlong javaDir) { DIR *dir = (DIR *) javaDir; struct dirent64 *dirent = TEMP_FAILURE_RETRY_N(readdir64(dir)); @@ -1406,7 +1406,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_readdir( } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_readlink( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_readlink( JNIEnv *env, jclass clazz, jobject javaPath) { char *path = mallocStringFromByteString(env, javaPath); size_t maxSize = PATH_MAX; @@ -1434,7 +1434,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_readlink( } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_realpath( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_realpath( JNIEnv *env, jclass clazz, jobject javaPath) { char *path = mallocStringFromByteString(env, javaPath); char resolvedPath[PATH_MAX] = {}; @@ -1448,7 +1448,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_realpath( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_remove( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_remove( JNIEnv *env, jclass clazz, jobject javaPath) { char *path = mallocStringFromByteString(env, javaPath); int result = TEMP_FAILURE_RETRY(remove(path)); @@ -1461,7 +1461,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_remove( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_rename( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_rename( JNIEnv *env, jclass clazz, jobject javaOldPath, jobject javaNewPath) { char *oldPath = mallocStringFromByteString(env, javaOldPath); char *newPath = mallocStringFromByteString(env, javaNewPath); @@ -1474,7 +1474,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_rename( } JNIEXPORT jlong JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_sendfile( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_sendfile( JNIEnv* env, jclass clazz, jobject javaOutFd, jobject javaInFd, jobject javaOffset, jlong javaCount) { int outFd = getFdFromFileDescriptor(env, javaOutFd); @@ -1498,7 +1498,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_sendfile( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_setgrent(JNIEnv *env, jclass clazz) { +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_setgrent(JNIEnv *env, jclass clazz) { TEMP_FAILURE_RETRY_V(setgrent()); if (errno) { throwSyscallException(env, "setgrent"); @@ -1506,7 +1506,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_setgrent(JNIEnv * } JNIEXPORT jlong JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_setmntent( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_setmntent( JNIEnv *env, jclass clazz, jobject javaPath, jobject javaMode) { char *path = mallocStringFromByteString(env, javaPath); char *mode = mallocStringFromByteString(env, javaMode); @@ -1521,7 +1521,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_setmntent( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_setpwent(JNIEnv *env, jclass clazz) { +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_setpwent(JNIEnv *env, jclass clazz) { TEMP_FAILURE_RETRY_V(setpwent()); if (errno) { throwSyscallException(env, "setpwent"); @@ -1529,7 +1529,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_setpwent(JNIEnv * } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_stat( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_stat( JNIEnv *env, jclass clazz, jobject javaPath) { return doStat(env, javaPath, false); } @@ -1556,7 +1556,7 @@ static jobject newStructStatVfs(JNIEnv *env, const struct statvfs64 *statvfs) { } JNIEXPORT jobject JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_statvfs( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_statvfs( JNIEnv *env, jclass clazz, jobject javaPath) { char *path = mallocStringFromByteString(env, javaPath); struct statvfs64 statvfs = {}; @@ -1570,7 +1570,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_statvfs( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_symlink( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_symlink( JNIEnv *env, jclass clazz, jobject javaTarget, jobject javaLinkPath) { char *target = mallocStringFromByteString(env, javaTarget); char *linkPath = mallocStringFromByteString(env, javaLinkPath); @@ -1583,7 +1583,7 @@ Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_symlink( } JNIEXPORT void JNICALL -Java_me_zhanghai_android_files_provider_linux_syscall_Syscalls_utimens( +Java_me_zhanghai_android_files_provider_linux_syscall_Syscall_utimens( JNIEnv *env, jclass clazz, jobject javaPath, jobjectArray javaTimes) { doUtimens(env, javaPath, javaTimes, false); } diff --git a/app/src/main/res/drawable-xhdpi/banner.png b/app/src/main/res/drawable-xhdpi/banner.png new file mode 100644 index 000000000..69ef794d4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/banner.png differ diff --git a/app/src/main/res/drawable/color_swatch_view_background.xml b/app/src/main/res/drawable/color_swatch_view_background.xml index ed14e8417..a73ae1668 100644 --- a/app/src/main/res/drawable/color_swatch_view_background.xml +++ b/app/src/main/res/drawable/color_swatch_view_background.xml @@ -23,7 +23,14 @@ --> + + diff --git a/app/src/main/res/drawable/encrypted_badge_icon_18dp.xml b/app/src/main/res/drawable/encrypted_badge_icon_18dp.xml new file mode 100644 index 000000000..c75dce6b3 --- /dev/null +++ b/app/src/main/res/drawable/encrypted_badge_icon_18dp.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/file_directory_thumbnail.xml b/app/src/main/res/drawable/file_directory_thumbnail.xml new file mode 100644 index 000000000..19e1da440 --- /dev/null +++ b/app/src/main/res/drawable/file_directory_thumbnail.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/file_item_grid_foreground_material3.xml b/app/src/main/res/drawable/file_item_grid_foreground_material3.xml new file mode 100644 index 000000000..5e787202d --- /dev/null +++ b/app/src/main/res/drawable/file_item_grid_foreground_material3.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/file_item_grid_thumbnail_outline.xml b/app/src/main/res/drawable/file_item_grid_thumbnail_outline.xml new file mode 100644 index 000000000..fbf471e61 --- /dev/null +++ b/app/src/main/res/drawable/file_item_grid_thumbnail_outline.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/file_item_grid_thumbnail_outline_material3.xml b/app/src/main/res/drawable/file_item_grid_thumbnail_outline_material3.xml new file mode 100644 index 000000000..295850aed --- /dev/null +++ b/app/src/main/res/drawable/file_item_grid_thumbnail_outline_material3.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/google_plus_icon_white_24dp.xml b/app/src/main/res/drawable/google_plus_icon_white_24dp.xml deleted file mode 100644 index 2efc41126..000000000 --- a/app/src/main/res/drawable/google_plus_icon_white_24dp.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/switch_thumb_material3.xml b/app/src/main/res/drawable/switch_thumb_material3.xml deleted file mode 100644 index ad80afde7..000000000 --- a/app/src/main/res/drawable/switch_thumb_material3.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/switch_track_material3.xml b/app/src/main/res/drawable/switch_track_material3.xml deleted file mode 100644 index b1e88e139..000000000 --- a/app/src/main/res/drawable/switch_track_material3.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout-sw600dp-land/file_list_fragment_include.xml b/app/src/main/res/layout-sw600dp-land/file_list_fragment_include.xml index d63ce4d11..c24dabea8 100644 --- a/app/src/main/res/layout-sw600dp-land/file_list_fragment_include.xml +++ b/app/src/main/res/layout-sw600dp-land/file_list_fragment_include.xml @@ -38,7 +38,7 @@ - + diff --git a/app/src/main/res/layout/about_fragment.xml b/app/src/main/res/layout/about_fragment.xml index 78a25dbc4..90c9dfbc9 100644 --- a/app/src/main/res/layout/about_fragment.xml +++ b/app/src/main/res/layout/about_fragment.xml @@ -300,33 +300,6 @@ android:textAppearance="?textAppearanceListItem" /> - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/layout/bookmark_directory_list_fragment.xml b/app/src/main/res/layout/bookmark_directory_list_fragment.xml index ab82dd7da..a8c6bd503 100644 --- a/app/src/main/res/layout/bookmark_directory_list_fragment.xml +++ b/app/src/main/res/layout/bookmark_directory_list_fragment.xml @@ -44,7 +44,10 @@ android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" + android:paddingBottom="@dimen/list_bottom_padding_with_fab" android:clipToPadding="false" + android:fitsSystemWindows="true" + android:scrollbarStyle="outsideOverlay" android:scrollbars="vertical" /> diff --git a/app/src/main/res/layout/create_archive_dialog.xml b/app/src/main/res/layout/create_archive_dialog.xml index a6cfeffb6..724e0a007 100644 --- a/app/src/main/res/layout/create_archive_dialog.xml +++ b/app/src/main/res/layout/create_archive_dialog.xml @@ -7,6 +7,7 @@ @@ -61,6 +62,23 @@ android:textAppearance="?textAppearanceListItem" android:visibility="@integer/create_archive_type_seven_z_visibility" /> + + + + + diff --git a/app/src/main/res/layout/edit_external_storage_shortcut_dialog.xml b/app/src/main/res/layout/edit_external_storage_shortcut_dialog.xml new file mode 100644 index 000000000..427669abf --- /dev/null +++ b/app/src/main/res/layout/edit_external_storage_shortcut_dialog.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/edit_webdav_server_fragment.xml b/app/src/main/res/layout/edit_webdav_server_fragment.xml new file mode 100644 index 000000000..55c57eadf --- /dev/null +++ b/app/src/main/res/layout/edit_webdav_server_fragment.xml @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +