diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3d026ba --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build viewer +on: + pull_request: + push: + branches: + - main + - fix/tbs-sdk-1.0.8.6000124 + +jobs: + build-viewer: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: '17' + - name: Build viewer + shell: bash + run: ./gradlew :viewer:assemble --stacktrace --console=plain diff --git a/build.gradle.kts b/build.gradle.kts index 16f845e..a31e196 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ plugins { } val publishGroup = "io.github.vickyleu.documentviewer" -val publishVersion = "2.0.1" +val publishVersion = "2.0.2" val publishRepo = "DocumentViewer" val publishUrl = "https://github.com/vickyleu/$publishRepo" val publishCoordinates = mapOf( diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 77acd81..fac4940 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Aug 23 04:29:59 CST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index ddc32b9..b229b8e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,10 +36,12 @@ dependencyResolutionManagement { } } - -includeBuild("../ComposeFilePicker") { - dependencySubstitution { - substitute(module("com.vickyleu.kmp.filepicker:filepicker")).using(project(":filePicker")) +val composeFilePickerDir = file("../ComposeFilePicker") +if (composeFilePickerDir.isDirectory) { + includeBuild(composeFilePickerDir) { + dependencySubstitution { + substitute(module("io.github.vickyleu.filepicker:filepicker")).using(project(":filePicker")) + } } } diff --git a/viewer/libs/TBS_FILE_SDK.md b/viewer/libs/TBS_FILE_SDK.md new file mode 100644 index 0000000..b5d1d7b --- /dev/null +++ b/viewer/libs/TBS_FILE_SDK.md @@ -0,0 +1,14 @@ +# Tencent TbsFile SDK binary + +This module expects the official Tencent Browsing Service document SDK binary: + +- Version: `V1.0.8.6000124` +- Release date: `2026-08-06` +- Variant: comprehensive document formats, `64-bit + 32-bit` +- Expected file path: `viewer/src/androidMain/libs/TbsFileSdk.aar` +- Official SDK page: `https://cloud.tencent.com/document/product/1645/83899` +- Official binary URL at the time of this update: `https://tbs.imtt.qq.com/sdk/release/TbsFileSdk_base_universal_release_1.0.8.6000124.20260806101826.aar` + +The repository currently contains an AAR at that path, but its binary version has not been verified/replaced through the source connector. Replace it on the local build machine with the official `V1.0.8.6000124` binary before validation and publication. + +Before publishing `viewer`, verify the downloaded AAR came from the official Tencent host, record its SHA-256, run a clean Android build, and confirm a smoke-test document can be opened. diff --git a/viewer/src/androidMain/kotlin/org/uooc/document/DocumentPreviewer.android.kt b/viewer/src/androidMain/kotlin/org/uooc/document/DocumentPreviewer.android.kt index 9e65aef..254f251 100644 --- a/viewer/src/androidMain/kotlin/org/uooc/document/DocumentPreviewer.android.kt +++ b/viewer/src/androidMain/kotlin/org/uooc/document/DocumentPreviewer.android.kt @@ -1,9 +1,7 @@ package org.uooc.document import android.content.Context -import android.content.Intent -import android.os.Build -import android.provider.Settings +import android.util.Log import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -21,57 +19,62 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.viewinterop.AndroidView -import coil3.Uri import com.github.jing332.filepicker.base.FileImpl import com.tencent.tbs.reader.TbsFileInterfaceImpl import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +private const val TAG = "DocumentPreviewer" internal actual fun DocumentPreviewer.setupLicense( license: String, - applicationContext: coil3.PlatformContext + applicationContext: coil3.PlatformContext, ) { val ctx = applicationContext.applicationContext as Context - TbsFileInterfaceImpl.setLicenseKey(license) - TbsFileInterfaceImpl.fileEnginePreCheck(ctx) - //初始化Engine - val isInit = if(TbsFileInterfaceImpl.isEngineLoaded().not()){ - TbsFileInterfaceImpl.initEngine(ctx) - }else { - DocumentPreviewer.TMResult.SUCCESS.code - } -// if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){ -// if(!Settings.System.canWrite(ctx)){ -// val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS) -// intent.setData(android.net.Uri.parse("package:" + ctx.packageName)) -// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) -// ctx.startActivity(intent) -// } -// } - this.currentState = DocumentPreviewer.TMResult.fromCode(isInit) - println("TbsFileInterfaceImpl.initEngine: ${this.currentState.message}") -} + val initCode = + try { + if (license.isBlank()) { + DocumentPreviewer.TMResult.UNSET.code + } else { + TbsFileInterfaceImpl.setLicenseKey(license) + if (TbsFileInterfaceImpl.isEngineLoaded()) { + DocumentPreviewer.TMResult.SUCCESS.code + } else { + TbsFileInterfaceImpl.initEngine(ctx) + } + } + } catch (t: Throwable) { + Log.e(TAG, "TbsFile initEngine threw an exception for package=${ctx.packageName}", t) + DocumentPreviewer.TMResult.UNKNOWN.code + } + this.currentInitCode = initCode + this.currentState = DocumentPreviewer.TMResult.fromCode(initCode) + Log.i( + TAG, + "TbsFile initEngine package=${ctx.packageName}, code=$initCode, " + + "state=${this.currentState.name}, engineLoaded=${runCatching { TbsFileInterfaceImpl.isEngineLoaded() }.getOrDefault(false)}", + ) +} @Composable internal actual fun DocumentPreviewer.documentView( document: FileImpl, - callback: (Boolean, String) -> Unit + callback: (Boolean, String) -> Unit, ) { - val file = remember { - mutableStateOf(document) - } + val file = remember { mutableStateOf(document) } val density = LocalDensity.current val scope = rememberCoroutineScope() - BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { val loadState = remember { mutableStateOf(false to "Loading document...") } + val documentView = remember { mutableStateOf(null) } - val documentView = remember { - mutableStateOf(null) - } - with(LocalDensity.current){ + with(LocalDensity.current) { Column(modifier = Modifier.fillMaxSize()) { AndroidView( factory = { context -> @@ -81,29 +84,24 @@ internal actual fun DocumentPreviewer.documentView( documentView.value = this } }, - update = { - - }, - modifier = Modifier.fillMaxWidth() - .wrapContentHeight() - + update = {}, + modifier = Modifier.fillMaxWidth().wrapContentHeight(), ) } } LaunchedEffect(documentView.value) { - if(documentView.value==null){ - return@LaunchedEffect - } + val view = documentView.value ?: return@LaunchedEffect scope.launch { - documentView.value?.setDocument(scope, file.value, density,this@documentView.currentState) { success, message -> + view.setDocument(scope, file.value, density, this@documentView.currentState) { success, message -> loadState.value = success to message } } } + DisposableEffect(documentView.value) { - if(documentView.value==null){ - return@DisposableEffect onDispose { } + if (documentView.value == null) { + return@DisposableEffect onDispose {} } onDispose { documentView.value?.dispose() @@ -111,26 +109,20 @@ internal actual fun DocumentPreviewer.documentView( } } - if (loadState.value.first.not()) { + if (!loadState.value.first) { Text( - text = loadState.value.second.let { - if(it.contains("未设置 licenseKey")){ - "tbs未充值,请联系管理员" - }else{ - it - } - }, - modifier = Modifier.align(Alignment.Center) + text = loadState.value.second, + modifier = Modifier.align(Alignment.Center), ) } + LaunchedEffect(Unit) { snapshotFlow { loadState.value } .drop(1) .distinctUntilChanged() - .collect{ - callback(it.first,it.second) + .collect { + callback(it.first, it.second) } } - } -} \ No newline at end of file +} diff --git a/viewer/src/androidMain/kotlin/org/uooc/document/DocumentView.kt b/viewer/src/androidMain/kotlin/org/uooc/document/DocumentView.kt index d72d387..5bfac56 100644 --- a/viewer/src/androidMain/kotlin/org/uooc/document/DocumentView.kt +++ b/viewer/src/androidMain/kotlin/org/uooc/document/DocumentView.kt @@ -8,7 +8,6 @@ import android.util.Log import android.view.ViewTreeObserver import android.widget.FrameLayout import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp import com.github.jing332.filepicker.base.FileImpl import com.tencent.tbs.reader.ITbsReader import com.tencent.tbs.reader.TbsFileInterfaceImpl @@ -18,139 +17,177 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import kotlin.math.roundToInt -private val TAG = "DocumentPreviewer" +private const val TAG = "DocumentPreviewer" class DocumentView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null, ) : FrameLayout(context, attrs) { private lateinit var currentState: DocumentPreviewer.TMResult + + @Suppress("UNUSED_PARAMETER") suspend fun setDocument( scope: CoroutineScope, file: FileImpl, density: Density, currentState: DocumentPreviewer.TMResult, - callback: (Boolean, String) -> Unit + callback: (Boolean, String) -> Unit, ) { this.currentState = currentState val completer = CompletableDeferred>() + scope.launch { - withContext(Dispatchers.IO) { - //增加下面一句解决没有TbsReaderTemp文件夹存在导致加载文件失败 - val bsReaderTemp = - FileUtils.getDir(context).toString() + File.separator + "TbsReaderTemp" - val bsReaderTempFile = File(bsReaderTemp) - if (!bsReaderTempFile.exists()) { - val mkdir: Boolean = bsReaderTempFile.mkdir() - if (!mkdir) { - Log.e(TAG, "创建$bsReaderTemp 失败") - completer.complete(false to "TbsReaderTemp缓存文件创建失败") + try { + withContext(Dispatchers.IO) { + val tbsReaderTemp = File(FileUtils.getDir(context), "TbsReaderTemp") + if (!tbsReaderTemp.exists() && !tbsReaderTemp.mkdirs()) { + Log.e(TAG, "Failed to create TBS temp directory: $tbsReaderTemp") + completeOnce(completer, false, "TbsReaderTemp缓存文件创建失败") return@withContext } - } - if (this@DocumentView.currentState.code != 0) { - completer.complete(false to "TbsFile Engine初始化失败") - return@withContext - } - //文件格式 - val fileExt = FileUtils.getFileType(file.toString()) - println("文件格式:$fileExt") - - - withContext(Dispatchers.Main) { - val bool = TbsFileInterfaceImpl.canOpenFileExt(fileExt) - Log.d(TAG, "文件是否支持$bool 文件路径:$file $bsReaderTemp $fileExt") - if (bool) { - //加载文件 - val localBundle = Bundle() - localBundle.putString("filePath", file.absolutePath.toString()) - localBundle.putString("tempPath", bsReaderTemp) - localBundle.putString("fileExt", fileExt) - - localBundle.putInt( - "set_content_view_width", - with(density) { measuredWidth.toFloat().dp.value.roundToInt() }) -// localBundle.putBoolean("file_reader_stream_mode", false)//设置为文件流打开模式 - localBundle.putInt( - "set_content_view_height", - with(density) { - measuredHeight.toFloat().dp.value.roundToInt().coerceAtLeast(200) - }) + + if (this@DocumentView.currentState != DocumentPreviewer.TMResult.SUCCESS) { + val initCode = DocumentPreviewer.currentInitCode + completeOnce( + completer, + false, + DocumentPreviewer.describeInitResult(initCode), + ) + return@withContext + } + + val fileExt = FileUtils.getFileType(file.toString()) + Log.d(TAG, "Opening document: ext=$fileExt") + + withContext(Dispatchers.Main) { + if (!TbsFileInterfaceImpl.canOpenFileExt(fileExt)) { + Log.e(TAG, "TBS cannot open extension: $fileExt") + completeOnce(completer, false, "文件格式不支持或者打开失败: $fileExt") + return@withContext + } + + val localBundle = Bundle().apply { + putString("filePath", file.absolutePath.toString()) + putString("tempPath", tbsReaderTemp.absolutePath) + putString("fileExt", fileExt) + // These values are pixels already. Converting px -> dp -> numeric px was incorrect. + putInt("set_content_view_width", measuredWidth.coerceAtLeast(1)) + putInt("set_content_view_height", measuredHeight.coerceAtLeast(200)) + } + this@DocumentView.post { - val ret = TbsFileInterfaceImpl.getInstance().openFileReader( - context, localBundle, - { code, args, msg -> - Log.e(TAG, "文件打开回调 $code $args $msg") - when (code) { - ITbsReader.OPEN_FILEREADER_STATUS_UI_CALLBACK -> { - if (args is Bundle) { - val id = args.getInt("typeId", 0) - val typeDes = - args.getString("typeDes", "fileReaderOpened") - if (ITbsReader.TBS_READER_TYPE_STATUS_UI_OPENED == id) { - //加密文档弹框取消需关闭activity -// Navigation.findNavController(getView()).popBackStack() - - } else if (ITbsReader.TBS_READER_TYPE_STATUS_UI_SHUTDOWN == id) { - //加密文档弹框取消需关闭activity -// Navigation.findNavController(getView()).popBackStack() - if (completer.isCompleted.not()) { - completer.complete(false to "文件打开2失败:${msg}") + try { + val ret = TbsFileInterfaceImpl.getInstance().openFileReader( + context, + localBundle, + { code, args, msg -> + Log.d(TAG, "TBS open callback code=$code, message=$msg") + when (code) { + ITbsReader.OPEN_FILEREADER_STATUS_UI_CALLBACK -> { + if (args is Bundle) { + val id = args.getInt("typeId", 0) + if (ITbsReader.TBS_READER_TYPE_STATUS_UI_SHUTDOWN == id) { + completeOnce( + completer, + false, + "文件阅读器已关闭${msg?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ""}", + ) } - } - } else { - if (completer.isCompleted.not()) { - completer.complete(false to "文件打开3失败:${msg}") + } else { + completeOnce( + completer, + false, + "文件阅读器状态异常${msg?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ""}", + ) } } - } - ITbsReader.NOTIFY_CANDISPLAY -> { - //文件即将显示 - Log.wtf("NOTIFY_CANDISPLAY", "文件即将显示") - completer.complete(true to "") + ITbsReader.NOTIFY_CANDISPLAY -> { + completeOnce(completer, true, "") + } } + }, + this@DocumentView, + ) - else -> Unit - } - }, this@DocumentView - ) - if (ret == 0) { - } else { - completer.complete(false to "error:$ret") + if (ret != 0) { + completeOnce( + completer, + false, + describeOpenReaderFailure(ret), + ) + } + } catch (t: Throwable) { + Log.e(TAG, "TBS openFileReader threw", t) + completeOnce( + completer, + false, + "TbsFile 打开文件异常: ${t.message ?: t::class.simpleName}", + ) } } - } else { - Log.e(TAG, "文件打开失败!文件格式暂不支持") - completer.complete(false to "文件格式不支持或者打开失败") } } + } catch (t: Throwable) { + Log.e(TAG, "Document preparation failed", t) + completeOnce( + completer, + false, + "文档预览初始化异常: ${t.message ?: t::class.simpleName}", + ) } } - val (rlt, msg) = completer.await() - callback.invoke(rlt, msg) + + val (result, message) = completer.await() + callback(result, message) } override fun onConfigurationChanged(newConfig: Configuration?) { super.onConfigurationChanged(newConfig) - this.getViewTreeObserver().addOnGlobalLayoutListener(object : - ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - this@DocumentView.getViewTreeObserver().removeOnGlobalLayoutListener(this) - val w: Int = this@DocumentView.width - val h: Int = this@DocumentView.height - TbsFileInterfaceImpl.getInstance().onSizeChanged(w, h) - } - }) + viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + viewTreeObserver.removeOnGlobalLayoutListener(this) + TbsFileInterfaceImpl.getInstance().onSizeChanged(width, height) + } + }, + ) } fun dispose() { try { - this.removeAllViews() - val instance = TbsFileInterfaceImpl.getInstance() - instance.closeFileReader() - } catch (ignore: Exception) { + TbsFileInterfaceImpl.getInstance().closeFileReader() + } catch (t: Throwable) { + Log.w(TAG, "TBS closeFileReader failed", t) + } finally { + removeAllViews() } } -} \ No newline at end of file + + private fun completeOnce( + completer: CompletableDeferred>, + success: Boolean, + message: String, + ) { + if (!completer.isCompleted) { + completer.complete(success to message) + } + } + + private fun describeOpenReaderFailure(code: Int): String { + val reason = + when (code) { + -1 -> "参数错误" + -2 -> "Reader 尚未加载" + -3 -> "鉴权失败" + -4 -> "Engine 正在加载" + -5 -> "阅读器 View 初始化失败" + -6 -> "文件格式不支持" + -7 -> "Reader 入口正在异步加载" + -8 -> "TBS Core 正在下载或尚未就绪" + else -> "未知错误" + } + return "TbsFile 打开文件失败 (code=$code, reason=$reason)" + } +} diff --git a/viewer/src/commonMain/kotlin/org/uooc/document/DocumentPreviewer.kt b/viewer/src/commonMain/kotlin/org/uooc/document/DocumentPreviewer.kt index 64f2689..9d2bd53 100644 --- a/viewer/src/commonMain/kotlin/org/uooc/document/DocumentPreviewer.kt +++ b/viewer/src/commonMain/kotlin/org/uooc/document/DocumentPreviewer.kt @@ -6,75 +6,60 @@ import com.github.jing332.filepicker.base.FileImpl object DocumentPreviewer { var currentState = TMResult.UNKNOWN internal set + + /** Raw TBS initEngine return code. Kept separately so unknown SDK codes are not lost. */ + var currentInitCode: Int = TMResult.UNKNOWN.code + internal set + @Composable - fun previewDocument(document: FileImpl,callback: (Boolean, String) -> Unit) { + fun previewDocument(document: FileImpl, callback: (Boolean, String) -> Unit) { println("Previewing document at ${document.getAbsolutePath()}") - documentView(document,callback) + documentView(document, callback) } fun setup(license: String, applicationContext: coil3.PlatformContext) { - println("Setting up document previewer with license $license") + // Never print the license key. setupLicense() logs only non-sensitive diagnostics. setupLicense(license, applicationContext) } - - /** - * 初始化接口错误码 - * intEngine 接口错误码为方法返回值。 - * initEngineAsync 接口错误码为回调 actionType == ITbsReader.OPEN_FILEREADER_ASYNC_LOAD_READER_ENTRY_CALLBACK 时 args 的值。 - * 错误码 - * 说明 - * 102 - * 未设置 licenseKey。 - * 202 - * 请检查调用接口是否正确,应调用 setLicenseKey 接口而不是 setLicense 接口。 - * 103 、305 - * 1. 请检查设备网络是否连通。 - * 2. 尝试切换网络。 - * 212、322 - * 调用量包次数用完。 - * 4001 - * licenseKey 不存在,请检查设置的 licenseKey 是否正确。 - * 4002 - * 客户端包名和 licenseKey 不匹配。 - * - * - * - */ + /** TBS File Engine initialization result codes. */ enum class TMResult(val code: Int = 0, val message: String = "") { SUCCESS(0, "Success"), - MISMATCH(4002, "License key mismatch"), - UNSET(102, "Unset license key"), - CHECK(202, "Check if the interface is called correctly, should call setLicenseKey instead of setLicense"), - NETWORK_MAYBE1(103, "Check if the device network is connected, try switching networks"), - NETWORK_MAYBE2(305, "Check if the device network is connected, try switching networks"), - QUOTA1(212, "The number of calls is used up"), - QUOTA2(322, "The number of calls is used up"), - NOT_EXIST(4001, "License key does not exist, please check if the set license key is correct"), - PACKAGE(4002, "The client package name does not match the license key"), - UNKNOWN(-1, "Unknown error"); + UNSET(102, "License key is not set"), + NETWORK_MAYBE1(103, "Network unavailable; check connectivity or try another network"), + MISMATCH(209, "License key mismatch"), + CHECK(202, "Invalid license API usage; setLicenseKey must be used"), + QUOTA1(212, "TBS File quota is exhausted or unavailable to this app"), + NETWORK_MAYBE2(305, "Network unavailable; check connectivity or try another network"), + QUOTA2(322, "TBS File quota is exhausted or unavailable to this app"), + NOT_EXIST(4001, "License key does not exist"), + PACKAGE(4002, "Application package name does not match the license key"), + UNKNOWN(-1, "Unknown TBS File initialization error"); companion object { - fun fromCode(code: Int): TMResult { - return values().find { it.code == code }?.apply { - println("Found code $code ${this.message}") - } ?: run{ - println("Unknown code $code") - UNKNOWN - } - } + fun fromCode(code: Int): TMResult = values().firstOrNull { it.code == code } ?: UNKNOWN } } -} + internal fun describeInitResult(code: Int): String { + val result = TMResult.fromCode(code) + return if (result == TMResult.SUCCESS) { + "TbsFile Engine initialized" + } else if (result == TMResult.UNKNOWN) { + "TbsFile Engine initialization failed (code=$code, reason=${result.message})" + } else { + "TbsFile Engine initialization failed (code=$code, reason=${result.message})" + } + } +} @Composable internal expect fun DocumentPreviewer.documentView( document: FileImpl, - callback: (Boolean, String) -> Unit + callback: (Boolean, String) -> Unit, ) internal expect fun DocumentPreviewer.setupLicense( license: String, - applicationContext: coil3.PlatformContext -) \ No newline at end of file + applicationContext: coil3.PlatformContext, +)