Files
wgtunnel-android/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt
T
2025-04-21 11:51:18 -04:00

262 lines
11 KiB
Kotlin

package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns
import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri
import com.zaneschepke.wireguardautotunnel.util.extensions.installApk
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import java.io.*
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.amnezia.awg.config.Config
import timber.log.Timber
class FileUtils(private val context: Context, private val ioDispatcher: CoroutineDispatcher) {
suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> =
withContext(ioDispatcher) {
tunnels.mapNotNull { config ->
if (config.wgQuick.isBlank()) {
Timber.w("Skipping tunnel ${config.tunName}: empty wgQuick config")
return@mapNotNull null
}
val file = File(context.cacheDir, "${config.tunName}-wg.conf")
file.outputStream().use { it.write(config.wgQuick.toByteArray()) }
Timber.d("Created WG file: ${file.path}, size: ${file.length()} bytes")
if (file.length() == 0L) {
Timber.w("WG file ${file.path} is empty")
null
} else {
file
}
}
}
suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> =
withContext(ioDispatcher) {
tunnels
.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT && it.amQuick.isNotBlank() }
.mapNotNull { config ->
val file = File(context.cacheDir, "${config.tunName}-am.conf")
file.outputStream().use { it.write(config.amQuick.toByteArray()) }
Timber.d("Created AM file: ${file.path}, size: ${file.length()} bytes")
if (file.length() == 0L) {
Timber.w("AM file ${file.path} is empty")
null
} else {
file
}
}
}
suspend fun zipAll(zipFile: File, files: List<File>) =
withContext(ioDispatcher) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
files.forEach { file ->
if (!file.exists() || file.length() == 0L) {
Timber.w("Skipping file ${file.path}: does not exist or empty")
return@forEach
}
val entryName = file.name // Use file name only, avoid complex path logic
val entry = ZipEntry(entryName)
zos.putNextEntry(entry)
FileInputStream(file).use { fis ->
fis.copyTo(zos)
Timber.d(
"Added ${file.path} to zip as $entryName, size: ${file.length()} bytes"
)
}
zos.closeEntry()
}
zos.flush()
Timber.d("Finished zipping: ${zipFile.path}, size: ${zipFile.length()} bytes")
}
}
suspend fun createNewShareFile(name: String): File =
withContext(ioDispatcher) {
val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.deleteRecursively()
sharePath.mkdirs()
val file = File(sharePath, name)
if (file.exists()) file.delete()
file.createNewFile()
Timber.d("Created share file: ${file.path}")
file
}
suspend fun copyFileToUri(sourceFile: File, destinationUri: Uri): Result<Unit> =
withContext(ioDispatcher) {
try {
if (!sourceFile.exists()) {
Timber.e("Source file does not exist: ${sourceFile.path}")
return@withContext Result.failure(IOException("Source file does not exist"))
}
if (!sourceFile.canRead()) {
Timber.e("Source file is not readable: ${sourceFile.path}")
return@withContext Result.failure(IOException("Source file is not readable"))
}
if (sourceFile.length() == 0L) {
Timber.e("Source file is empty: ${sourceFile.path}")
return@withContext Result.failure(IOException("Source file is empty"))
}
Timber.d("Copying file: ${sourceFile.path}, size: ${sourceFile.length()} bytes")
var bytesCopied = 0L
FileInputStream(sourceFile).use { inputStream ->
context.contentResolver.openOutputStream(destinationUri)?.use { outputStream ->
val buffer = ByteArray(1024 * 1024) // 1MB buffer
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead
Timber.d("Copied $bytesCopied bytes so far")
}
outputStream.flush()
Timber.d("Total bytes copied: $bytesCopied")
Result.success(Unit)
}
?: run {
Timber.e("Failed to open OutputStream for Uri: $destinationUri")
Result.failure(IOException("Failed to open OutputStream for Uri"))
}
}
} catch (e: IOException) {
Timber.e(e, "Error copying file to Uri: ${e.message}")
Result.failure(e)
}
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) return null
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
val move = cursor.moveToFirst()
if (!move) return null
val index = getDisplayNameColumnIndex(cursor)
if (index == null) return index
return cursor.getString(index)
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(uri: Uri): String {
return getFileNameByCursor(uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String? {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
Timber.e(e)
null
}
}
private fun getFileNameByCursor(uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use {
getDisplayNameByCursor(it)
}
}
suspend fun buildTunnelsFromUri(uri: Uri): List<TunnelConf> =
withContext(ioDispatcher) {
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
val fileName = getFileName(uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> {
context.getInputStreamFromUri(uri)?.use { inputStream ->
val name = getNameFromFileName(fileName)
val amConf = Config.parse(inputStream)
listOf(
TunnelConf(
tunName = name,
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
)
)
} ?: throw FileReadException
}
Constants.ZIP_FILE_EXTENSION -> {
ZipInputStream(context.getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) !=
Constants.CONF_FILE_EXTENSION
}
.map { entry ->
val name = getNameFromFileName(entry.name)
val amConf = Config.parse(zip.bufferedReader())
TunnelConf(
tunName = name,
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
)
}
.toList()
}
}
else -> throw InvalidFileExtensionException
}
}
fun shareFile(shareFile: File) {
val uri =
FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
context.launchShareFile(uri)
}
@RequiresApi(Build.VERSION_CODES.Q)
suspend fun saveToDownloadsWithMediaStore(file: File, mimeType: String): Uri? =
withContext(ioDispatcher) {
val contentValues =
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, file.name)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = context.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val itemUri = resolver.insert(collection, contentValues) ?: return@withContext null
resolver.openOutputStream(itemUri)?.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
}
// Mark as finished
contentValues.clear()
contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(itemUri, contentValues, null, null)
return@withContext itemUri
}
fun installApk(file: File) {
context.installApk(file)
}
}