Skip to content

Commit c4760b3

Browse files
authored
Merge pull request #71 from sameerasw/develop
Develop
2 parents 203854e + 7d52a95 commit c4760b3

10 files changed

Lines changed: 558 additions & 76 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ android {
1616
applicationId = "com.sameerasw.airsync"
1717
minSdk = 30
1818
targetSdk = 36
19-
versionCode = 19
20-
versionName = "2.3.1"
19+
versionCode = 20
20+
versionName = "2.4.0"
2121

2222
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2323
}

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
1010
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
1111
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
12+
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
1213
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1314
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
1415
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
2222
companion object {
2323
// New: Continue Browsing dismiss action
2424
const val ACTION_CONTINUE_BROWSING_DISMISS = "com.sameerasw.airsync.CONTINUE_BROWSING_DISMISS"
25+
const val ACTION_CANCEL_TRANSFER = "com.sameerasw.airsync.CANCEL_TRANSFER"
2526
private const val TAG = "NotificationActionReceiver"
2627
}
2728

@@ -45,6 +46,15 @@ class NotificationActionReceiver : BroadcastReceiver() {
4546
Log.d(TAG, "Disconnecting from notification")
4647
WebSocketUtil.disconnect(context)
4748
}
49+
ACTION_CANCEL_TRANSFER -> {
50+
val transferId = intent.getStringExtra("transfer_id")
51+
if (!transferId.isNullOrEmpty()) {
52+
Log.d(TAG, "Cancelling transfer $transferId from notification")
53+
// Try cancelling both (one will be active)
54+
com.sameerasw.airsync.utils.FileReceiver.cancelTransfer(context, transferId)
55+
com.sameerasw.airsync.utils.FileSender.cancelTransfer(transferId)
56+
}
57+
}
4858
}
4959
}
5060
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import android.util.Log
2+
import android.os.Environment
3+
import java.io.File
4+
import org.json.JSONArray
5+
import org.json.JSONObject
6+
7+
object FileBrowserUtil {
8+
private const val TAG = "FileBrowserUtil"
9+
private val ROOT_PATH = Environment.getExternalStorageDirectory().absolutePath
10+
11+
fun listDirectory(path: String?, showHidden: Boolean = false): String {
12+
var targetPath = if (path.isNullOrBlank()) ROOT_PATH else path
13+
14+
if (!targetPath.startsWith("/")) {
15+
targetPath = ROOT_PATH + (if (targetPath.startsWith("/")) "" else "/") + targetPath
16+
}
17+
18+
val directory = File(targetPath)
19+
20+
if (!directory.exists()) {
21+
Log.e(TAG, "Directory does not exist: $targetPath")
22+
return createErrorResponse(targetPath, "Directory does not exist")
23+
}
24+
25+
if (!directory.isDirectory) {
26+
Log.e(TAG, "Not a directory: $targetPath")
27+
return createErrorResponse(targetPath, "Not a directory")
28+
}
29+
30+
val items = directory.listFiles()
31+
if (items == null) {
32+
Log.e(TAG, "Access denied or I/O error for: $targetPath")
33+
return createErrorResponse(targetPath, "Access denied. Please ensure 'All Files Access' permission is granted.")
34+
}
35+
36+
val jsonItems = JSONArray()
37+
for (file in items.sortedBy { it.name.lowercase() }.sortedByDescending { it.isDirectory }) {
38+
if (!showHidden && file.name.startsWith(".") && file.name != ".") {
39+
continue
40+
}
41+
val item = JSONObject()
42+
item.put("name", file.name)
43+
item.put("isDir", file.isDirectory)
44+
item.put("size", if (file.isDirectory) 0 else file.length())
45+
item.put("time", file.lastModified())
46+
jsonItems.put(item)
47+
}
48+
49+
val data = JSONObject()
50+
data.put("path", targetPath)
51+
data.put("items", jsonItems)
52+
53+
val response = JSONObject()
54+
response.put("type", "browseData")
55+
response.put("data", data)
56+
57+
return response.toString()
58+
}
59+
60+
private fun createErrorResponse(path: String, message: String): String {
61+
val data = JSONObject()
62+
data.put("path", path)
63+
data.put("error", message)
64+
data.put("items", JSONArray())
65+
66+
val response = JSONObject()
67+
response.put("type", "browseData")
68+
response.put("data", data)
69+
70+
return response.toString()
71+
}
72+
}

app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.content.Context
77
import android.net.Uri
88
import android.os.Build
99
import android.provider.MediaStore
10+
import android.util.Log
1011
import androidx.core.app.NotificationCompat
1112
import androidx.core.app.NotificationManagerCompat
1213
import kotlinx.coroutines.CoroutineScope
@@ -23,21 +24,59 @@ object FileReceiver {
2324
val name: String,
2425
val size: Int,
2526
val mime: String,
27+
val chunkSize: Int,
2628
var checksum: String? = null,
2729
var receivedBytes: Int = 0,
2830
var index: Int = 0,
29-
var output: OutputStream? = null,
30-
var uri: Uri? = null
31+
var pfd: android.os.ParcelFileDescriptor? = null,
32+
var uri: Uri? = null,
33+
// Speed / ETA tracking
34+
var lastUpdateTime: Long = System.currentTimeMillis(),
35+
var bytesAtLastUpdate: Int = 0,
36+
var smoothedSpeed: Double? = null
3137
)
3238

3339
private val incoming = ConcurrentHashMap<String, IncomingFileState>()
3440

41+
fun clearAll() {
42+
incoming.keys.forEach { id ->
43+
incoming.remove(id)?.let { state ->
44+
try {
45+
state.pfd?.close()
46+
} catch (e: Exception) { e.printStackTrace() }
47+
}
48+
}
49+
}
50+
3551
fun ensureChannel(context: Context) {
3652
// Delegate to shared NotificationUtil
3753
NotificationUtil.createFileChannel(context)
3854
}
3955

40-
fun handleInit(context: Context, id: String, name: String, size: Int, mime: String, checksum: String? = null) {
56+
fun cancelTransfer(context: Context, id: String) {
57+
val state = incoming.remove(id) ?: return
58+
Log.d("FileReceiver", "Cancelling incoming transfer $id")
59+
60+
CoroutineScope(Dispatchers.IO).launch {
61+
try {
62+
// Close and delete
63+
state.pfd?.close()
64+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
65+
state.uri?.let { context.contentResolver.delete(it, null, null) }
66+
}
67+
68+
// Cancel notification
69+
NotificationManagerCompat.from(context).cancel(id.hashCode())
70+
71+
// Send network cancel
72+
WebSocketUtil.sendMessage(FileTransferProtocol.buildCancel(id))
73+
} catch (e: Exception) {
74+
e.printStackTrace()
75+
}
76+
}
77+
}
78+
79+
fun handleInit(context: Context, id: String, name: String, size: Int, mime: String, chunkSize: Int, checksum: String? = null) {
4180
ensureChannel(context)
4281
CoroutineScope(Dispatchers.IO).launch {
4382
try {
@@ -55,11 +94,11 @@ object FileReceiver {
5594
}
5695

5796
val uri = resolver.insert(collection, values)
58-
val out = uri?.let { resolver.openOutputStream(it) }
97+
val pfd = uri?.let { resolver.openFileDescriptor(it, "rw") }
5998

60-
if (uri != null && out != null) {
61-
incoming[id] = IncomingFileState(name = name, size = size, mime = mime, checksum = checksum, output = out, uri = uri)
62-
NotificationUtil.showFileProgress(context, id.hashCode(), name, 0)
99+
if (uri != null && pfd != null) {
100+
incoming[id] = IncomingFileState(name = name, size = size, mime = mime, chunkSize = chunkSize, checksum = checksum, pfd = pfd, uri = uri)
101+
NotificationUtil.showFileProgress(context, id.hashCode(), name, 0, id)
63102
}
64103
} catch (e: Exception) {
65104
e.printStackTrace()
@@ -72,9 +111,18 @@ object FileReceiver {
72111
try {
73112
val state = incoming[id] ?: return@launch
74113
val bytes = android.util.Base64.decode(base64Chunk, android.util.Base64.NO_WRAP)
75-
state.output?.write(bytes)
76-
state.receivedBytes += bytes.size
77-
state.index = index
114+
115+
synchronized(state) {
116+
state.pfd?.fileDescriptor?.let { fd ->
117+
val channel = java.io.FileOutputStream(fd).channel
118+
val offset = index.toLong() * state.chunkSize
119+
channel.position(offset)
120+
channel.write(java.nio.ByteBuffer.wrap(bytes))
121+
state.receivedBytes += bytes.size
122+
state.index = index
123+
}
124+
}
125+
78126
updateProgressNotification(context, id, state)
79127
// send ack for this chunk
80128
try {
@@ -101,8 +149,7 @@ object FileReceiver {
101149
}
102150

103151
// Now flush and close
104-
state.output?.flush()
105-
state.output?.close()
152+
state.pfd?.close()
106153

107154
// Mark file as not pending (Android Q+)
108155
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -136,7 +183,7 @@ object FileReceiver {
136183

137184
// Notify user with an action to open the file
138185
val notifId = id.hashCode()
139-
NotificationUtil.showFileComplete(context, notifId, state.name, verified, state.uri)
186+
NotificationUtil.showFileComplete(context, notifId, state.name, verified, isSending = false, contentUri = state.uri)
140187

141188
// Send transferVerified back to sender
142189
try {
@@ -153,12 +200,48 @@ object FileReceiver {
153200
}
154201
}
155202

156-
private fun showProgress(context: Context, id: String) {
157-
NotificationUtil.showFileProgress(context, id.hashCode(), "Receiving...", 0)
158-
}
203+
159204

160205
private fun updateProgressNotification(context: Context, id: String, state: IncomingFileState) {
161-
val percent = if (state.size > 0) (state.receivedBytes * 100 / state.size) else 0
162-
NotificationUtil.showFileProgress(context, id.hashCode(), state.name, percent)
206+
val now = System.currentTimeMillis()
207+
val timeDiff = (now - state.lastUpdateTime) / 1000.0
208+
209+
if (timeDiff >= 1.0) {
210+
val bytesDiff = state.receivedBytes - state.bytesAtLastUpdate
211+
val intervalSpeed = if (timeDiff > 0) bytesDiff / timeDiff else 0.0
212+
213+
val alpha = 0.4
214+
val lastSpeed = state.smoothedSpeed
215+
val newSpeed = if (lastSpeed != null) {
216+
alpha * intervalSpeed + (1.0 - alpha) * lastSpeed
217+
} else {
218+
intervalSpeed
219+
}
220+
state.smoothedSpeed = newSpeed
221+
222+
var etaString: String? = null
223+
if (newSpeed > 0) {
224+
val remainingBytes = (state.size - state.receivedBytes).coerceAtLeast(0)
225+
val secondsRemaining = (remainingBytes / newSpeed).toLong()
226+
227+
etaString = if (secondsRemaining < 60) {
228+
"$secondsRemaining sec remaining"
229+
} else {
230+
val mins = secondsRemaining / 60
231+
"$mins min remaining"
232+
}
233+
}
234+
235+
state.lastUpdateTime = now
236+
state.bytesAtLastUpdate = state.receivedBytes
237+
238+
val percent = if (state.size > 0) (state.receivedBytes * 100 / state.size) else 0
239+
NotificationUtil.showFileProgress(context, id.hashCode(), state.name, percent, id, isSending = false, etaString = etaString)
240+
} else if (state.receivedBytes == 0) {
241+
// Initial
242+
NotificationUtil.showFileProgress(context, id.hashCode(), state.name, 0, id, isSending = false, etaString = "Calculating...")
243+
state.lastUpdateTime = now
244+
state.bytesAtLastUpdate = 0
245+
}
163246
}
164247
}

0 commit comments

Comments
 (0)