diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 253aca9b..68bf3e93 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -14,6 +14,7 @@ import com.utun.chat.data.TelecomCallManager import com.utun.chat.data.ChatEventCallback import com.utun.chat.data.CallController import com.utun.chat.data.AvatarRepository +import com.utun.chat.data.MemberFilesRepository import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.CoroutineScope @@ -30,6 +31,7 @@ object AppEventHandler : ChatEventCallback { val events: SharedFlow> = _events override fun onEvent(type: Int, data: ByteArray?) { + if (type == 13 || type in 49..50) mainHandler.post { ChatApplication.instance.memberFiles.onEvent(type, data) } if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31) || type in 49..53) mainHandler.post { ChatApplication.instance.avatars.onEvent(type, data) } if (type == 1 || type == 32) playMessageSound(type, data) @@ -61,6 +63,7 @@ class ChatApplication : Application() { lateinit var logManager: LogManager lateinit var configProvider: ConfigProvider lateinit var avatars: AvatarRepository + lateinit var memberFiles: MemberFilesRepository override fun onCreate() { super.onCreate() @@ -68,6 +71,7 @@ class ChatApplication : Application() { logManager = LogManager configProvider = ConfigProvider(this) avatars = AvatarRepository(this) + memberFiles = MemberFilesRepository(this) /* Notification sound state: global toggle + per-group mute */ NotificationSound.enabled = configProvider.getSoundEnabled() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index ad00ba8a..f4517d8f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -42,9 +42,12 @@ object NativeLib { } fun stop() { - runBlocking(Dispatchers.Main.immediate) { CallController.reset(); ChatApplication.instance.avatars.stop() } + runBlocking(Dispatchers.Main.immediate) { + CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel() + } if (!isReady()) return nativeStop() + runBlocking(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() } } fun setEventCallback(cb: ChatEventCallback) { @@ -54,12 +57,17 @@ object NativeLib { } fun destroy() { - runBlocking(Dispatchers.Main.immediate) { CallController.reset(); ChatApplication.instance.avatars.stop() } + runBlocking(Dispatchers.Main.immediate) { + CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel() + } nativeDestroy() + runBlocking(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() } } fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { - runBlocking(Dispatchers.Main.immediate) { CallController.reset(); ChatApplication.instance.avatars.stop() } + runBlocking(Dispatchers.Main.immediate) { + CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel() + } return nativeRestart(configText) } @@ -137,6 +145,15 @@ object NativeLib { fun setGroupAvatar(id: Long, group: Long, data: ByteArray): Boolean = nativeAvatarOperation(6, id, group, 0, 0, null, null, data) fun cancelAvatar(id: Long): Boolean = nativeAvatarOperation(7, id, 0, 0, 0, null, null, null) + fun fileRequest(group: Long, server: Long, wire: ByteArray): Boolean = nativeFileRequest(group, server, wire) + fun fileTransfer(id: Long, group: Long, server: Long, owner: Long, remote: String, local: String, + upload: Boolean, version: ByteArray): Boolean = + nativeFileTransfer(id, group, server, owner, remote.toByteArray(), local.toByteArray(), upload, version) + fun fileCancel(id: Long): Boolean = nativeFileCancel(id) + private external fun nativeFileRequest(group: Long, server: Long, wire: ByteArray): Boolean + private external fun nativeFileTransfer(id: Long, group: Long, server: Long, owner: Long, remote: ByteArray, + local: ByteArray, upload: Boolean, version: ByteArray): Boolean + private external fun nativeFileCancel(id: Long): Boolean fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } fun toggleMemberRadioMute(channelId: String, nodeId: Long): Boolean = nativeToggleMemberRadioMute(channelId, nodeId) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 35eee614..f213ccf2 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -2961,4 +2962,89 @@ invalid: u_free(req); return JNI_FALSE; } +/* Общие файловые команды. Wire уже UTF-8, поэтому JNI modified UTF-8 не портит имена с emoji. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFileRequest( + JNIEnv* env, jobject thiz, jlong group, jlong server, jbyteArray wire) { + (void)thiz; + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + struct UASYNC* ua = instance_lite_get_uasync(); + jsize size = wire ? (*env)->GetArrayLength(env, wire) : 0; + struct mf_gui_request* q = NULL; + if (!inst || !ua || !uasync_is_running(ua) || !group || !server || size < MF_WIRE_HEADER || size > MF_WIRE_MAX) goto invalid; + uint8_t bytes[MF_WIRE_MAX]; + (*env)->GetByteArrayRegion(env, wire, 0, size, (jbyte*)bytes); + if ((*env)->ExceptionCheck(env)) goto invalid; + q = u_calloc(1, sizeof(*q)); + if (!q || mf_decode(bytes, size, &q->message)) goto invalid; + struct mf_message* m = &q->message; + int read = m->command == MF_LIST || m->command == MF_STAT || m->command == MF_QUOTA; + int write = m->command == MF_MKDIR || m->command == MF_RENAME || m->command == MF_REMOVE_PATH; + if (m->kind != MF_REQUEST || (!read && !write) || m->data_len || m->offset || m->size || m->more || + !mf_path_valid(m->path, read) || (!m->owner && (m->command != MF_LIST || m->path[0])) || + (m->command == MF_RENAME ? !mf_path_valid(m->target, 0) : m->target[0] != 0)) goto invalid; + if (write && (m->owner != inst->node_id || + (!strncasecmp(m->path, "avatars", 7) && (!m->path[7] || m->path[7] == '/')))) goto invalid; + if (m->command == MF_RENAME && !strncasecmp(m->target, "avatars", 7) && (!m->target[7] || m->target[7] == '/')) goto invalid; + q->inst = inst; q->group = (uint64_t)group; q->server = (uint64_t)server; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "Android files: request id=%llu cmd=%s group=%llu server=%llu owner=%llu path=%s", + (unsigned long long)m->request_id, mf_command_text(m->command), (unsigned long long)group, + (unsigned long long)server, (unsigned long long)m->owner, m->path); + uasync_post(ua, member_files_gui_request, q); return JNI_TRUE; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "Android files: request rejected group=%llu server=%llu bytes=%d ready=%d", + (unsigned long long)group, (unsigned long long)server, (int)size, inst && ua && uasync_is_running(ua)); + u_free(q); return JNI_FALSE; +} + +/* Копирование настоящего UTF-8 пути с проверкой длины и отсутствия NUL. */ +static int android_file_path(JNIEnv* env, jbyteArray bytes, char* out) { + jsize size = bytes ? (*env)->GetArrayLength(env, bytes) : 0; + if (size < 1 || size > MF_PATH_MAX) return -1; + (*env)->GetByteArrayRegion(env, bytes, 0, size, (jbyte*)out); + if ((*env)->ExceptionCheck(env) || memchr(out, 0, size)) return -1; + out[size] = 0; return 0; +} + +/* Handle/локальный файл принадлежат общему transfer, Kotlin ждёт его терминальное событие. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFileTransfer( + JNIEnv* env, jobject thiz, jlong id, jlong group, jlong server, jlong owner, + jbyteArray remote, jbyteArray local, jboolean upload, jbyteArray version) { + (void)thiz; + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + struct UASYNC* ua = instance_lite_get_uasync(); + struct mf_transfer_request* q = NULL; + if (!inst || !ua || !uasync_is_running(ua) || !id || !group || !server || !owner || + !version || (*env)->GetArrayLength(env, version) != 16) goto invalid; + q = u_calloc(1, sizeof(*q)); + if (!q || android_file_path(env, remote, q->remote) || android_file_path(env, local, q->local) || + !mf_path_valid(q->remote, 0) || q->local[0] != '/') goto invalid; + (*env)->GetByteArrayRegion(env, version, 0, 16, (jbyte*)q->version); + if ((*env)->ExceptionCheck(env) || (!upload && mf_zero(q->version, 16))) goto invalid; + if (upload && (owner != (jlong)inst->node_id || + (!strncasecmp(q->remote, "avatars", 7) && (!q->remote[7] || q->remote[7] == '/')))) goto invalid; + q->inst = inst; q->id = id; q->group = group; q->server = server; q->owner = owner; q->upload = upload != JNI_FALSE; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "Android files: transfer queued id=%llu group=%llu server=%llu owner=%llu upload=%d path=%s", + (unsigned long long)id, (unsigned long long)group, (unsigned long long)server, (unsigned long long)owner, q->upload, q->remote); + uasync_post(ua, member_files_gui_transfer, q); return JNI_TRUE; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "Android files: transfer rejected id=%llu group=%llu server=%llu owner=%llu upload=%d", + (unsigned long long)id, (unsigned long long)group, (unsigned long long)server, (unsigned long long)owner, upload); + u_free(q); return JNI_FALSE; +} + +/* Отмена по уникальному ID не затрагивает передачи других владельцев. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFileCancel(JNIEnv* env, jobject thiz, jlong id) { + (void)env; (void)thiz; + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + struct UASYNC* ua = instance_lite_get_uasync(); + if (!id || !inst || !ua || !uasync_is_running(ua)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "Android files: cancel unavailable id=%llu", (unsigned long long)id); return JNI_FALSE; + } + struct mf_cancel_request* q = u_calloc(1, sizeof(*q)); + if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "Android files: cancel allocation failed"); return JNI_FALSE; } + q->inst = inst; q->id = id; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "Android files: cancel queued id=%llu", (unsigned long long)id); + uasync_post(ua, member_files_gui_cancel, q); return JNI_TRUE; +} + #endif /* __ANDROID__ */