@@ -1398,6 +1398,16 @@ class MainActivity : ComponentActivity() {
13981398 autoShareTag = enabled
13991399 uiPrefs.edit().putBoolean(KEY_AUTO_SHARE_TAG , enabled).apply ()
14001400 },
1401+ onCheckDownloadPermission = {
1402+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers .IO ) {
1403+ checkTagDownloadPermission()
1404+ }
1405+ },
1406+ onDownloadTagPackage = { brand, onProgress, onImportStatus ->
1407+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers .IO ) {
1408+ downloadAndImportTagPackage(brand, onProgress, onImportStatus)
1409+ }
1410+ },
14011411 hideCopiedTags = hideCopiedTags,
14021412 onHideCopiedTagsChange = { enabled ->
14031413 hideCopiedTags = enabled
@@ -1483,9 +1493,9 @@ class MainActivity : ComponentActivity() {
14831493 cModifyInProgress = pendingCModifyItem != null ,
14841494 formatInProgress = pendingClearFuid,
14851495 anomalyUids = anomalyUids,
1486- onReportAnomaly = { trayUid, cardUid ->
1496+ onReportAnomaly = { cardUid ->
14871497 lifecycleScope.launch(Dispatchers .IO ) {
1488- com.m0h31h31.bamburfidreader.utils.AnalyticsReporter .reportAnomaly(applicationContext, trayUid, cardUid)
1498+ com.m0h31h31.bamburfidreader.utils.AnalyticsReporter .reportAnomaly(applicationContext, cardUid)
14891499 }
14901500 },
14911501 onTagScreenEnter = {
@@ -2026,7 +2036,7 @@ class MainActivity : ComponentActivity() {
20262036 .toMutableSet()
20272037
20282038 val zipEntries = extractZipEntries(uri)
2029- if (zipEntries.isEmpty() && ! cacheDir.listFiles()?.any { it.name.startsWith(" import_" ) } ? : false ) {
2039+ if (zipEntries.isEmpty() && ! ( cacheDir.listFiles()?.any { it.name.startsWith(" import_" ) } ? : false ) ) {
20302040 return " 读取标签包失败(文件损坏或密码错误)"
20312041 }
20322042 for ((entryName, bytes) in zipEntries) {
@@ -2088,6 +2098,106 @@ class MainActivity : ComponentActivity() {
20882098 }
20892099 }
20902100
2101+ // ── 在线下载共享标签包 ──────────────────────────────────────────────────────
2102+
2103+ // ── 在线下载权限检查 ──────────────────────────────────────────────────────
2104+
2105+ /* * 检查当前设备是否有资格下载标签包。IO 线程调用。返回 null 表示允许,非 null 为拒绝原因。 */
2106+ private fun checkTagDownloadPermission (): String? {
2107+ val installId = com.m0h31h31.bamburfidreader.utils.AnalyticsReporter .getInstallId(this )
2108+ val endpoint = com.m0h31h31.bamburfidreader.utils.ConfigManager .getTagCanDownloadEndpoint(this )
2109+ return try {
2110+ val url = java.net.URL (" $endpoint ?install_id=${java.net.URLEncoder .encode(installId, " UTF-8" )} " )
2111+ val conn = url.openConnection() as java.net.HttpURLConnection
2112+ conn.connectTimeout = 10000
2113+ conn.readTimeout = 10000
2114+ val code = conn.responseCode
2115+ val body = if (code in 200 .. 299 )
2116+ conn.inputStream.use { it.readBytes() }.toString(Charsets .UTF_8 )
2117+ else
2118+ conn.errorStream?.use { it.readBytes() }?.toString(Charsets .UTF_8 ) ? : " "
2119+ conn.disconnect()
2120+ val json = org.json.JSONObject (body)
2121+ if (json.optBoolean(" allowed" , false )) null
2122+ else json.optString(" reason" , getString(R .string.download_tag_package_failed))
2123+ } catch (e: Exception ) {
2124+ logDebug(" checkTagDownloadPermission error: ${e.message} " )
2125+ getString(R .string.download_tag_package_failed)
2126+ }
2127+ }
2128+
2129+ // ── 在线下载并导入标签包 ──────────────────────────────────────────────────
2130+
2131+ /* *
2132+ * 下载指定品牌的标签包并导入(suspend)。
2133+ * onProgress(0..100) 在 IO 线程回调,调用方用 withContext(Main) 更新 UI。
2134+ */
2135+ private suspend fun downloadAndImportTagPackage (
2136+ brand : String ,
2137+ onProgress : (Int ) -> Unit ,
2138+ onImportStatus : (String ) -> Unit
2139+ ): String {
2140+ val installId = com.m0h31h31.bamburfidreader.utils.AnalyticsReporter .getInstallId(this )
2141+ val endpoint = com.m0h31h31.bamburfidreader.utils.ConfigManager .getTagDownloadEndpoint(this )
2142+ val payload = org.json.JSONObject ().apply {
2143+ put(" install_id" , installId)
2144+ put(" brand" , brand)
2145+ }
2146+ val tmp = java.io.File (cacheDir, " dl_${brand} _${System .currentTimeMillis()} .zip" )
2147+ val mainHandler = android.os.Handler (android.os.Looper .getMainLooper())
2148+ return try {
2149+ val result = com.m0h31h31.bamburfidreader.utils.NetworkUtils .postJsonDownloadToFile(
2150+ endpoint, payload, tmp, onProgress = onProgress
2151+ ) ? : return getString(R .string.download_tag_package_failed)
2152+
2153+ val (code, errBody) = result
2154+ if (code !in 200 .. 299 ) {
2155+ val detail = try {
2156+ org.json.JSONObject (errBody!! .toString(Charsets .UTF_8 )).optString(" detail" , " " )
2157+ } catch (_: Exception ) { " " }
2158+ return if (detail.isNotBlank()) detail
2159+ else getString(R .string.download_tag_package_failed)
2160+ }
2161+ importTagPackageFromZipFile(tmp, snapmaker = brand == " snapmaker" ) { cur, total ->
2162+ mainHandler.post { onImportStatus(" 正在导入 ($cur /$total )…" ) }
2163+ }
2164+ } finally {
2165+ tmp.delete()
2166+ }
2167+ }
2168+
2169+ /* *
2170+ * 直接从 zip 文件导入标签包(不经过 ContentResolver/FileProvider),
2171+ * 复用现有解析逻辑,适用于下载后的临时文件。
2172+ */
2173+ private fun importTagPackageFromZipFile (
2174+ zipFile : java.io.File ,
2175+ snapmaker : Boolean ,
2176+ onProgress : ((cur: Int , total: Int ) -> Unit )? = null
2177+ ): String {
2178+ val dbHelper = filamentDbHelper ? : return " 数据库不可用"
2179+ val db = dbHelper.writableDatabase
2180+ return try {
2181+ // 用 zip4j 直接打开文件(与 extractZipEntries 相同逻辑,跳过 URI 拷贝)
2182+ val zip4j = net.lingala.zip4j.ZipFile (zipFile)
2183+ if (zip4j.isEncrypted) {
2184+ zip4j.setPassword(computeTagZipPassword().toCharArray())
2185+ }
2186+ val entries = mutableListOf<Pair <String , ByteArray >>()
2187+ for (header in zip4j.fileHeaders) {
2188+ if (! header.isDirectory) {
2189+ val bytes = zip4j.getInputStream(header).use { it.readBytes() }
2190+ entries.add(Pair (header.fileName, bytes))
2191+ }
2192+ }
2193+ if (snapmaker) processSnapmakerZipEntries(entries, db, dbHelper, onProgress)
2194+ else processBambuZipEntries(entries, db, dbHelper, onProgress)
2195+ } catch (e: Exception ) {
2196+ logDebug(" importTagPackageFromZipFile error: ${e.message} " )
2197+ " 导入失败: ${e.message.orEmpty()} "
2198+ }
2199+ }
2200+
20912201 // ── 快造标签包导入 ──────────────────────────────────────────────────────────
20922202
20932203 private fun openSnapmakerTagPackagePicker () {
@@ -2158,6 +2268,117 @@ class MainActivity : ComponentActivity() {
21582268 }
21592269 }
21602270
2271+ // ── zip entries 通用处理(供 URI 导入和文件直接导入共用)───────────────────
2272+
2273+ private fun processBambuZipEntries (
2274+ entries : List <Pair <String , ByteArray >>,
2275+ db : android.database.sqlite.SQLiteDatabase ,
2276+ dbHelper : FilamentDbHelper ,
2277+ onProgress : ((cur: Int , total: Int ) -> Unit )? = null
2278+ ): String {
2279+ val helper = dbHelper
2280+ var extractedCount = 0 ; var skippedCount = 0
2281+ var invalidCount = 0 ; var overwrittenCount = 0
2282+ val txtEntries = entries.filter { it.first.lowercase(Locale .US ).endsWith(" .txt" ) }
2283+ val total = txtEntries.size
2284+ var processed = 0
2285+ val existingRows = helper.getAllShareTagRows(db)
2286+ val existingFileUidSet = existingRows.map { it.fileUid.uppercase(Locale .US ) }.toMutableSet()
2287+ val existingTrayUidSet = existingRows
2288+ .mapNotNull { it.trayUid?.uppercase(Locale .US )?.ifBlank { null } }.toMutableSet()
2289+ for ((entryName, bytes) in txtEntries) {
2290+ val incomingUid = File (entryName).nameWithoutExtension.uppercase(Locale .US )
2291+ val alreadyExists = incomingUid.isNotBlank() && existingFileUidSet.contains(incomingUid)
2292+ if (alreadyExists && ! forceOverwriteImport) { skippedCount++ ; processed++ ; onProgress?.invoke(processed, total); continue }
2293+ val content = String (bytes, Charsets .UTF_8 )
2294+ val rawBlocks = parseHexTagFileStrict(content)
2295+ if (rawBlocks == null ) { invalidCount++ ; processed++ ; onProgress?.invoke(processed, total); continue }
2296+ if (! isValidBambuTag(rawBlocks)) { invalidCount++ ; processed++ ; onProgress?.invoke(processed, total); continue }
2297+ val preview = NfcTagProcessor .parseForPreview(rawBlocks, filamentDbHelper) { }
2298+ val trayUid = preview.trayUidHex.trim()
2299+ if (trayUid.isNotBlank() && existingTrayUidSet.contains(trayUid.uppercase())) {
2300+ skippedCount++ ; processed++ ; onProgress?.invoke(processed, total); continue
2301+ }
2302+ if (alreadyExists && forceOverwriteImport && incomingUid.isNotBlank()) {
2303+ helper.deleteShareTagByFileUid(db, incomingUid)
2304+ }
2305+ val normalized = content.trim().lines()
2306+ .map { it.trim() }.filter { it.isNotBlank() }.take(64 ).joinToString(" \n " )
2307+ val productionDate = extractProductionDate(rawBlocks)
2308+ helper.insertShareTag(
2309+ db, fileUid = incomingUid, trayUid = trayUid.ifBlank { null },
2310+ materialType = preview.displayData.type.ifBlank { null },
2311+ colorUid = preview.displayData.colorCode.ifBlank { null },
2312+ colorName = preview.displayData.colorName.ifBlank { null },
2313+ colorType = preview.displayData.colorType.ifBlank { null },
2314+ colorValues = preview.displayData.colorValues.joinToString(" ," ).ifBlank { null },
2315+ rawData = normalized, productionDate = productionDate
2316+ )
2317+ extractedCount++
2318+ if (alreadyExists && forceOverwriteImport) overwrittenCount++
2319+ if (incomingUid.isNotBlank()) existingFileUidSet.add(incomingUid)
2320+ if (trayUid.isNotBlank()) existingTrayUidSet.add(trayUid.uppercase())
2321+ processed++
2322+ onProgress?.invoke(processed, total)
2323+ }
2324+ return when {
2325+ extractedCount == 0 && skippedCount == 0 && invalidCount == 0 ->
2326+ " 导入完成,但压缩包内未发现标签数据"
2327+ extractedCount == 0 ->
2328+ " 导入完成:格式无效 $invalidCount 个,重复跳过 $skippedCount 个"
2329+ forceOverwriteImport ->
2330+ " Bambu 标签包导入完成:新增 $extractedCount 个(覆盖 $overwrittenCount 个),无效 $invalidCount 个,跳过 $skippedCount 个"
2331+ else ->
2332+ " Bambu 标签包导入完成:新增 $extractedCount 个,无效 $invalidCount 个,跳过 $skippedCount 个"
2333+ }
2334+ }
2335+
2336+ private fun processSnapmakerZipEntries (
2337+ entries : List <Pair <String , ByteArray >>,
2338+ db : android.database.sqlite.SQLiteDatabase ,
2339+ dbHelper : FilamentDbHelper ,
2340+ onProgress : ((cur: Int , total: Int ) -> Unit )? = null
2341+ ): String {
2342+ val helper = dbHelper
2343+ var extractedCount = 0 ; var skippedCount = 0 ; var invalidCount = 0
2344+ val txtEntries = entries.filter { it.first.lowercase(Locale .US ).endsWith(" .txt" ) }
2345+ val total = txtEntries.size
2346+ var processed = 0
2347+ val existingUidSet = helper.getAllSnapmakerShareTagUids(db)
2348+ .map { it.uppercase(Locale .US ) }.toMutableSet()
2349+ for ((entryName, bytes) in txtEntries) {
2350+ val incomingUid = File (entryName).nameWithoutExtension.uppercase(Locale .US )
2351+ if (incomingUid.isNotBlank() && existingUidSet.contains(incomingUid)) {
2352+ skippedCount++ ; processed++ ; onProgress?.invoke(processed, total); continue
2353+ }
2354+ val content = String (bytes, Charsets .UTF_8 )
2355+ val rawBlocks = parseHexTagFileStrict(content)
2356+ if (rawBlocks == null ) { invalidCount++ ; processed++ ; onProgress?.invoke(processed, total); continue }
2357+ if (! isValidSnapmakerTag(rawBlocks)) { invalidCount++ ; processed++ ; onProgress?.invoke(processed, total); continue }
2358+ val fields = parseSnapmakerShareFields(rawBlocks)
2359+ val normalized = content.trim().lines()
2360+ .map { it.trim() }.filter { it.isNotBlank() }.take(64 ).joinToString(" \n " )
2361+ helper.insertSnapmakerShareTag(
2362+ db, uid = incomingUid, vendor = fields.vendor,
2363+ manufacturer = fields.manufacturer, mainType = fields.mainType,
2364+ diameter = fields.diameter, weight = fields.weight,
2365+ rgb1 = fields.rgb1, mfDate = fields.mfDate, rawData = normalized
2366+ )
2367+ extractedCount++
2368+ existingUidSet.add(incomingUid)
2369+ processed++
2370+ onProgress?.invoke(processed, total)
2371+ }
2372+ return when {
2373+ extractedCount == 0 && skippedCount == 0 && invalidCount == 0 ->
2374+ " 导入完成,但压缩包内未发现标签数据"
2375+ extractedCount == 0 ->
2376+ " 导入完成:格式无效 $invalidCount 个,重复跳过 $skippedCount 个"
2377+ else ->
2378+ " Snapmaker 标签包导入完成:新增 $extractedCount 个,无效 $invalidCount 个,跳过 $skippedCount 个"
2379+ }
2380+ }
2381+
21612382 private data class SnapmakerShareFields (
21622383 val vendor : String ,
21632384 val manufacturer : String ,
@@ -5878,7 +6099,7 @@ class FilamentDbHelper(context: Context) :
58786099 val now = System .currentTimeMillis()
58796100 for ((uid, count) in uids) {
58806101 val cv = ContentValues ()
5881- cv.put(" uid" , uid.lowercase ().trim())
6102+ cv.put(" uid" , uid.uppercase ().trim())
58826103 cv.put(" report_count" , count)
58836104 cv.put(" synced_at" , now)
58846105 db.insertWithOnConflict(ANOMALY_UIDS_TABLE , null , cv, SQLiteDatabase .CONFLICT_REPLACE )
0 commit comments