Skip to content

Commit 421928e

Browse files
author
Nguyễn Tuấn Việt
committed
add: Add more built-in worker
1 parent db8aeaa commit 421928e

29 files changed

Lines changed: 6881 additions & 468 deletions

‎android/src/main/kotlin/dev/brewkits/native_workmanager/SimpleAndroidWorkerFactory.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
55
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorkerFactory
66
import dev.brewkits.native_workmanager.workers.DartCallbackWorkerWrapper
7+
import dev.brewkits.native_workmanager.workers.FileCompressionWorker
78
import dev.brewkits.native_workmanager.workers.HttpDownloadWorker
89
import dev.brewkits.native_workmanager.workers.HttpRequestWorker
910
import dev.brewkits.native_workmanager.workers.HttpSyncWorker
@@ -18,6 +19,7 @@ import dev.brewkits.native_workmanager.workers.HttpUploadWorker
1819
* - HttpUploadWorker: Multipart file upload with security validation
1920
* - HttpDownloadWorker: Streaming file download with security validation
2021
* - HttpSyncWorker: JSON sync operations with security validation
22+
* - FileCompressionWorker: ZIP compression for files/directories (v0.9.0+)
2123
*
2224
* Supports custom worker registration via [setUserFactory].
2325
* User factory is checked first, then falls back to built-in workers.
@@ -66,6 +68,7 @@ class SimpleAndroidWorkerFactory(
6668
"HttpUploadWorker" -> HttpUploadWorker()
6769
"HttpDownloadWorker" -> HttpDownloadWorker()
6870
"HttpSyncWorker" -> HttpSyncWorker()
71+
"FileCompressionWorker" -> FileCompressionWorker()
6972
else -> null
7073
}
7174
}
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
package dev.brewkits.native_workmanager.workers
2+
3+
import android.util.Log
4+
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
5+
import org.json.JSONArray
6+
import org.json.JSONObject
7+
import java.io.BufferedInputStream
8+
import java.io.File
9+
import java.io.FileInputStream
10+
import java.io.FileOutputStream
11+
import java.util.zip.ZipEntry
12+
import java.util.zip.ZipOutputStream
13+
14+
/**
15+
* Built-in worker: File compression (ZIP format)
16+
*
17+
* Compresses files or directories into ZIP archives in the background.
18+
* Perfect for log file archiving, backup preparation, or reducing upload sizes.
19+
*
20+
* Usage from Dart:
21+
* ```dart
22+
* await NativeWorkManager.enqueue(
23+
* taskId: 'compress-logs',
24+
* trigger: TaskTrigger.oneTime(),
25+
* worker: NativeWorker.fileCompress(
26+
* inputPath: '/app/logs/',
27+
* outputPath: '/app/archive/logs_2026-02-05.zip',
28+
* level: CompressionLevel.high,
29+
* deleteOriginal: true,
30+
* ),
31+
* );
32+
* ```
33+
*
34+
* Input JSON schema:
35+
* {
36+
* "inputPath": "/path/to/file/or/directory", // Required
37+
* "outputPath": "/path/to/output.zip", // Required
38+
* "compressionLevel": "medium", // Optional: low, medium, high
39+
* "excludePatterns": ["*.tmp", ".DS_Store"], // Optional: exclude patterns
40+
* "deleteOriginal": false, // Optional: delete source after compression
41+
* }
42+
*/
43+
class FileCompressionWorker : AndroidWorker {
44+
companion object {
45+
private const val TAG = "FileCompressionWorker"
46+
private const val BUFFER_SIZE = 8192
47+
48+
// Compression levels mapped to Java constants
49+
private const val COMPRESSION_LOW = 3
50+
private const val COMPRESSION_MEDIUM = 6
51+
private const val COMPRESSION_HIGH = 9
52+
}
53+
54+
override suspend fun doWork(input: String?): Boolean {
55+
return try {
56+
Log.d(TAG, "Starting file compression...")
57+
58+
// ════════════════════════════════════════════════════════════
59+
// STEP 1: Parse and validate input
60+
// ════════════════════════════════════════════════════════════
61+
if (input.isNullOrEmpty()) {
62+
Log.e(TAG, "Input is null or empty")
63+
return false
64+
}
65+
66+
val config = JSONObject(input)
67+
val inputPath = config.optString("inputPath", "")
68+
val outputPath = config.optString("outputPath", "")
69+
val compressionLevel = config.optString("compressionLevel", "medium")
70+
val deleteOriginal = config.optBoolean("deleteOriginal", false)
71+
72+
// Parse exclude patterns
73+
val excludePatterns = mutableListOf<String>()
74+
if (config.has("excludePatterns")) {
75+
val patternsArray = config.getJSONArray("excludePatterns")
76+
for (i in 0 until patternsArray.length()) {
77+
excludePatterns.add(patternsArray.getString(i))
78+
}
79+
}
80+
81+
// ════════════════════════════════════════════════════════════
82+
// STEP 2: Validate required parameters
83+
// ════════════════════════════════════════════════════════════
84+
if (inputPath.isEmpty()) {
85+
Log.e(TAG, "Missing required parameter: inputPath")
86+
return false
87+
}
88+
89+
if (outputPath.isEmpty()) {
90+
Log.e(TAG, "Missing required parameter: outputPath")
91+
return false
92+
}
93+
94+
// Check input exists
95+
val inputFile = File(inputPath)
96+
if (!inputFile.exists()) {
97+
Log.e(TAG, "Input file/directory does not exist: $inputPath")
98+
return false
99+
}
100+
101+
// Check output path is valid
102+
if (!outputPath.endsWith(".zip", ignoreCase = true)) {
103+
Log.e(TAG, "Output path must end with .zip: $outputPath")
104+
return false
105+
}
106+
107+
// ════════════════════════════════════════════════════════════
108+
// STEP 3: Create output directory if needed
109+
// ════════════════════════════════════════════════════════════
110+
val outputFile = File(outputPath)
111+
outputFile.parentFile?.mkdirs()
112+
113+
// Delete existing output file if exists
114+
if (outputFile.exists()) {
115+
Log.d(TAG, "Removing existing output file: $outputPath")
116+
outputFile.delete()
117+
}
118+
119+
// ════════════════════════════════════════════════════════════
120+
// STEP 4: Map compression level
121+
// ════════════════════════════════════════════════════════════
122+
val zipCompressionLevel = when (compressionLevel.lowercase()) {
123+
"low" -> COMPRESSION_LOW
124+
"high" -> COMPRESSION_HIGH
125+
else -> COMPRESSION_MEDIUM // Default
126+
}
127+
128+
Log.d(TAG, "Compression level: $compressionLevel ($zipCompressionLevel)")
129+
130+
// ════════════════════════════════════════════════════════════
131+
// STEP 5: Compress files
132+
// ════════════════════════════════════════════════════════════
133+
var filesCompressed = 0
134+
var totalBytes = 0L
135+
136+
ZipOutputStream(FileOutputStream(outputFile)).use { zipOut ->
137+
zipOut.setLevel(zipCompressionLevel)
138+
139+
if (inputFile.isDirectory) {
140+
// Compress directory recursively
141+
filesCompressed = compressDirectory(
142+
zipOut,
143+
inputFile,
144+
inputFile.name,
145+
excludePatterns
146+
)
147+
} else {
148+
// Compress single file
149+
if (!shouldExclude(inputFile.name, excludePatterns)) {
150+
compressFile(zipOut, inputFile, inputFile.name)
151+
filesCompressed = 1
152+
}
153+
}
154+
155+
zipOut.finish()
156+
}
157+
158+
// ════════════════════════════════════════════════════════════
159+
// STEP 6: Verify output
160+
// ════════════════════════════════════════════════════════════
161+
if (!outputFile.exists() || outputFile.length() == 0L) {
162+
Log.e(TAG, "Compression failed: output file is empty or missing")
163+
return false
164+
}
165+
166+
val originalSize = calculateSize(inputFile)
167+
val compressedSize = outputFile.length()
168+
val compressionRatio = (compressedSize.toFloat() / originalSize.toFloat() * 100).toInt()
169+
170+
Log.d(TAG, "Compression successful:")
171+
Log.d(TAG, " Files: $filesCompressed")
172+
Log.d(TAG, " Original: ${formatBytes(originalSize)}")
173+
Log.d(TAG, " Compressed: ${formatBytes(compressedSize)}")
174+
Log.d(TAG, " Ratio: $compressionRatio%")
175+
176+
// ════════════════════════════════════════════════════════════
177+
// STEP 7: Delete original if requested
178+
// ════════════════════════════════════════════════════════════
179+
if (deleteOriginal) {
180+
Log.d(TAG, "Deleting original: $inputPath")
181+
if (inputFile.isDirectory) {
182+
inputFile.deleteRecursively()
183+
} else {
184+
inputFile.delete()
185+
}
186+
}
187+
188+
return true
189+
190+
} catch (e: Exception) {
191+
Log.e(TAG, "File compression failed", e)
192+
return false
193+
}
194+
}
195+
196+
/**
197+
* Compress a directory recursively
198+
*/
199+
private fun compressDirectory(
200+
zipOut: ZipOutputStream,
201+
directory: File,
202+
basePath: String,
203+
excludePatterns: List<String>
204+
): Int {
205+
var count = 0
206+
207+
directory.listFiles()?.forEach { file ->
208+
if (shouldExclude(file.name, excludePatterns)) {
209+
Log.d(TAG, "Excluding: ${file.name}")
210+
return@forEach
211+
}
212+
213+
val entryName = if (basePath.isEmpty()) {
214+
file.name
215+
} else {
216+
"$basePath/${file.name}"
217+
}
218+
219+
if (file.isDirectory) {
220+
// Add directory entry
221+
zipOut.putNextEntry(ZipEntry("$entryName/"))
222+
zipOut.closeEntry()
223+
224+
// Recurse into subdirectory
225+
count += compressDirectory(zipOut, file, entryName, excludePatterns)
226+
} else {
227+
// Add file
228+
compressFile(zipOut, file, entryName)
229+
count++
230+
}
231+
}
232+
233+
return count
234+
}
235+
236+
/**
237+
* Compress a single file into the ZIP stream
238+
*/
239+
private fun compressFile(zipOut: ZipOutputStream, file: File, entryName: String) {
240+
BufferedInputStream(FileInputStream(file), BUFFER_SIZE).use { bis ->
241+
val entry = ZipEntry(entryName)
242+
entry.time = file.lastModified()
243+
zipOut.putNextEntry(entry)
244+
245+
val buffer = ByteArray(BUFFER_SIZE)
246+
var length: Int
247+
while (bis.read(buffer).also { length = it } != -1) {
248+
zipOut.write(buffer, 0, length)
249+
}
250+
251+
zipOut.closeEntry()
252+
}
253+
}
254+
255+
/**
256+
* Check if file should be excluded based on patterns
257+
*/
258+
private fun shouldExclude(fileName: String, patterns: List<String>): Boolean {
259+
return patterns.any { pattern ->
260+
when {
261+
pattern.startsWith("*.") -> {
262+
// Extension pattern: *.tmp
263+
val extension = pattern.substring(1)
264+
fileName.endsWith(extension, ignoreCase = true)
265+
}
266+
pattern.startsWith("*") -> {
267+
// Suffix pattern: *backup
268+
val suffix = pattern.substring(1)
269+
fileName.endsWith(suffix, ignoreCase = true)
270+
}
271+
pattern.endsWith("*") -> {
272+
// Prefix pattern: temp*
273+
val prefix = pattern.substring(0, pattern.length - 1)
274+
fileName.startsWith(prefix, ignoreCase = true)
275+
}
276+
else -> {
277+
// Exact match
278+
fileName.equals(pattern, ignoreCase = true)
279+
}
280+
}
281+
}
282+
}
283+
284+
/**
285+
* Calculate total size of file or directory
286+
*/
287+
private fun calculateSize(file: File): Long {
288+
return if (file.isDirectory) {
289+
file.walkTopDown().filter { it.isFile }.map { it.length() }.sum()
290+
} else {
291+
file.length()
292+
}
293+
}
294+
295+
/**
296+
* Format bytes to human-readable string
297+
*/
298+
private fun formatBytes(bytes: Long): String {
299+
return when {
300+
bytes < 1024 -> "$bytes B"
301+
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
302+
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
303+
else -> "${bytes / (1024 * 1024 * 1024)} GB"
304+
}
305+
}
306+
}

0 commit comments

Comments
 (0)