Skip to content

Commit e44db56

Browse files
heDukecursoragent
andcommitted
feat: QFace CDN faces and gated market GIF playback
Drop bundled bigface assets for on-demand QFace png/apng, prefer dynamic market stickers in chat with a 2-slot play gate, and add app-new Android CI. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c15dfcc commit e44db56

13 files changed

Lines changed: 819 additions & 193 deletions

File tree

‎.github/workflows/android.yml‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Android CI
2+
3+
on:
4+
push:
5+
branches: [main, master, 'upgrade/**']
6+
paths-ignore:
7+
- '**.md'
8+
- 'LICENSE'
9+
pull_request:
10+
branches: [main, master]
11+
paths-ignore:
12+
- '**.md'
13+
- 'LICENSE'
14+
workflow_dispatch:
15+
inputs:
16+
buildRelease:
17+
description: 'Also build the release APK'
18+
type: boolean
19+
default: false
20+
21+
concurrency:
22+
group: ${{ github.workflow }}-${{ github.ref }}
23+
cancel-in-progress: true
24+
25+
jobs:
26+
build:
27+
name: Assemble APK
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 45
30+
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v4
34+
35+
# README baseline: JDK 17+, AGP 9.3, Gradle 9.5.
36+
- name: Set up JDK 17
37+
uses: actions/setup-java@v4
38+
with:
39+
distribution: temurin
40+
java-version: '17'
41+
42+
# The ubuntu runner ships an Android SDK with ANDROID_HOME preset;
43+
# install the exact components this project pins (compileSdk 37,
44+
# build-tools 37.0.0) with the bundled sdkmanager directly instead of
45+
# a third-party action.
46+
- name: Install Android SDK components
47+
run: |
48+
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
49+
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
50+
# Since SDK 36 the platform packages carry a minor version:
51+
# "platforms;android-37.0" (a bare "android-37" does not exist).
52+
"$SDKMANAGER" --install "platforms;android-37.0" "build-tools;37.0.0" "platform-tools"
53+
54+
# Gradle build/configuration caches. The wrapper downloads its
55+
# distribution from the Tencent mirror pinned in
56+
# gradle-wrapper.properties, which is reachable from GitHub runners.
57+
- name: Set up Gradle
58+
uses: gradle/actions/setup-gradle@v4
59+
60+
- name: Grant execute permission to gradlew
61+
run: chmod +x gradlew
62+
63+
- name: Build debug APK
64+
run: ./gradlew :app-new:assembleDebug --stacktrace
65+
66+
- name: Build release APK
67+
if: ${{ github.event_name == 'workflow_dispatch' && inputs.buildRelease }}
68+
run: ./gradlew :app-new:assembleRelease --stacktrace
69+
70+
# NOTE: the APK embeds Tencent's proprietary watch-QQ runtime (see
71+
# README disclaimer) — keep artifact retention short.
72+
- name: Upload debug APK
73+
uses: actions/upload-artifact@v4
74+
with:
75+
name: qmce-debug
76+
path: app-new/build/outputs/apk/debug/*.apk
77+
retention-days: 7
78+
79+
- name: Upload release APK
80+
if: ${{ github.event_name == 'workflow_dispatch' && inputs.buildRelease }}
81+
uses: actions/upload-artifact@v4
82+
with:
83+
name: qmce-release
84+
path: app-new/build/outputs/apk/release/*.apk
85+
retention-days: 7
86+
87+
- name: Upload build reports on failure
88+
if: failure()
89+
uses: actions/upload-artifact@v4
90+
with:
91+
name: build-reports
92+
path: |
93+
app-new/build/reports/
94+
build/reports/
95+
retention-days: 7
96+
if-no-files-found: ignore
-5.68 MB
Binary file not shown.

‎app-new/src/main/assets/bigface_index.json‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎app-new/src/main/java/rj/qmce/lite/QmceApplication.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.multidex.MultiDex
1717
import coil3.ImageLoader
1818
import coil3.SingletonImageLoader
1919
import coil3.gif.GifDecoder
20+
import coil3.memory.MemoryCache
2021
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
2122
import com.microsoft.appcenter.AppCenter
2223
import com.microsoft.appcenter.analytics.Analytics
@@ -34,6 +35,7 @@ import mqq.app.IAccountCallback
3435
import mqq.app.MobileQQ
3536
import rj.qmce.lite.data.LoginPrefs
3637
import rj.qmce.lite.data.emotion.EmotionAssetBridge
38+
import rj.qmce.lite.data.emotion.EmotionRepository
3739
import rj.qmce.lite.data.reporting.OfficialReportBridge
3840
import rj.qmce.lite.data.update.OtaUpdateSession
3941
import rj.qmce.lite.kernel.KernelBridge
@@ -75,7 +77,8 @@ class QmceApplication : WatchApplicationDelegate(), SingletonImageLoader.Factory
7577
Constants.LogoutReason.expired,
7678
Constants.LogoutReason.suspend,
7779
-> {
78-
if (isLoginTransitionSuppressing()) {
80+
// Only suppress during active login transition — not post-login grace.
81+
if (loginTransitionActive.get()) {
7982
QmceLog.w(
8083
"QMCE",
8184
"account: suppress transitional logout reason=$reason " +
@@ -93,6 +96,11 @@ class QmceApplication : WatchApplicationDelegate(), SingletonImageLoader.Factory
9396

9497
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
9598
return ImageLoader.Builder(context)
99+
.memoryCache {
100+
MemoryCache.Builder()
101+
.maxSizePercent(context, 0.12)
102+
.build()
103+
}
96104
.components {
97105
add(GifDecoder.Factory())
98106
add(OkHttpNetworkFetcherFactory())
@@ -307,6 +315,8 @@ class QmceApplication : WatchApplicationDelegate(), SingletonImageLoader.Factory
307315
}.onFailure {
308316
QmceLog.w("QMCE", "URLDrawable runtime unavailable; emotion fallback remains enabled", it)
309317
}
318+
runCatching { EmotionRepository.warmupEmotionAssets() }
319+
.onFailure { QmceLog.w("QMCE", "emotion assets warmup schedule failed", it) }
310320
}
311321

312322
private fun isMainProcess(): Boolean {

‎app-new/src/main/java/rj/qmce/lite/data/emotion/EmotionAssetBridge.kt‎

Lines changed: 40 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,15 @@ import java.util.zip.ZipInputStream
1111
/**
1212
* Installs the small system-emotion resource set that NT expects under filesDir.
1313
*
14-
* The official resource manager does not read these files from APK assets. It only looks at
15-
* filesDir/qq_emoticon_res, so the bridge materializes the bundled config and lottie/static files
16-
* before the manager is initialized. Existing downloaded resources are preserved.
14+
* Bundles [face_config.json] and [emoji_res.zip] only. Animated system faces are served
15+
* on-demand from [QFaceRemoteStore] instead of a packaged bigface/qlottie zip.
1716
*/
1817
object EmotionAssetBridge {
1918
private const val TAG = "QMCE-EmotionAssets"
20-
private const val VERSION = 7
19+
private const val VERSION = 9
2120
private const val RESOURCE_DIR = "qq_emoticon_res"
2221
private const val CONFIG_ASSET = "face_config.json"
23-
private const val ZIP_ASSET = "bigface.zip"
2422
private const val EMOJI_ZIP_ASSET = "emoji_res.zip"
25-
private const val INDEX_ASSET = "bigface_index.json"
2623
private const val MARKER = ".qmce_emotion_assets_v$VERSION"
2724

2825
@Volatile
@@ -35,7 +32,7 @@ object EmotionAssetBridge {
3532
synchronized(this) {
3633
applicationContext = context.applicationContext
3734
runCatching {
38-
ensureLocked(context)
35+
ensureLocked(context.applicationContext)
3936
}.onFailure {
4037
Log.e(TAG, "emotion resource initialization failed", it)
4138
}
@@ -59,31 +56,26 @@ object EmotionAssetBridge {
5956

6057
private fun ensureFiles(context: Context, resourceDir: File) {
6158
val bundledConfig = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
62-
val bundledIndex = context.assets.open(INDEX_ASSET).bufferedReader().use { it.readText() }
63-
val indexFile = File(resourceDir, INDEX_ASSET)
64-
if (!indexFile.isFile || indexFile.readText() != bundledIndex) {
65-
writeAtomically(indexFile, bundledIndex)
66-
}
59+
val animatedIds = QFaceRemoteStore.animatedIds().map(Int::toString)
6760
val mergedConfig = mergeConfig(
6861
existing = File(resourceDir, CONFIG_ASSET).takeIf { it.isFile }?.readText(),
6962
bundled = bundledConfig,
70-
animatedIds = readAnimatedIds(bundledIndex),
63+
animatedIds = animatedIds,
7164
)
7265
val configFile = File(resourceDir, CONFIG_ASSET)
7366
if (!configFile.isFile || configFile.readText() != mergedConfig) {
7467
writeAtomically(configFile, mergedConfig)
7568
}
7669

7770
val marker = File(resourceDir, MARKER)
78-
if (!marker.isFile || !hasBundledResource(resourceDir, bundledIndex, bundledConfig)) {
79-
extractZipAsset(context, resourceDir, ZIP_ASSET)
80-
extractZipAsset(context, resourceDir, EMOJI_ZIP_ASSET)
71+
if (!marker.isFile || !hasStaticBundledResource(resourceDir, bundledConfig)) {
72+
extractZipAssetDirect(context, resourceDir, EMOJI_ZIP_ASSET)
8173
writeAtomically(marker, "version=$VERSION\n")
8274
}
8375
Log.i(
8476
TAG,
8577
"emotion resources ready config=${configFile.length()} bytes " +
86-
"animated=${readAnimatedIds(bundledIndex).size} dir=${resourceDir.absolutePath}",
78+
"animated=${animatedIds.size} dir=${resourceDir.absolutePath}",
8779
)
8880
}
8981

@@ -98,10 +90,7 @@ object EmotionAssetBridge {
9890
}.getOrNull()
9991
}
10092

101-
fun bundledAnimatedIds(): List<Int> = runCatching {
102-
val index = resourceFile(INDEX_ASSET)?.takeIf(File::isFile)?.readText() ?: return@runCatching emptyList()
103-
readAnimatedIds(index).mapNotNull(String::toIntOrNull).distinct()
104-
}.getOrDefault(emptyList())
93+
fun bundledAnimatedIds(): List<Int> = QFaceRemoteStore.animatedIds()
10594

10695
private fun mergeConfig(
10796
existing: String?,
@@ -141,6 +130,7 @@ object EmotionAssetBridge {
141130
}
142131

143132
animatedIds.forEach { id ->
133+
val entry = QFaceRemoteStore.lookup(id)
144134
if (!itemIndexById.containsKey(id)) {
145135
itemIndexById[id] = baseSysface.length()
146136
baseSysface.put(
@@ -149,10 +139,19 @@ object EmotionAssetBridge {
149139
put("IQLid", id)
150140
put("AQLid", id)
151141
put("EMCode", "10$id")
152-
put("QDes", "/大表情$id")
142+
put(
143+
"QDes",
144+
entry?.describe?.takeIf { it.isNotBlank() } ?: "/大表情$id",
145+
)
153146
put("AniStickerType", 1)
154-
put("AniStickerPackId", "1")
155-
put("AniStickerId", id)
147+
put(
148+
"AniStickerPackId",
149+
entry?.aniStickerPackId?.takeIf { it > 0 }?.toString() ?: "1",
150+
)
151+
put(
152+
"AniStickerId",
153+
entry?.aniStickerId?.takeIf { it > 0 }?.toString() ?: id,
154+
)
156155
},
157156
)
158157
}
@@ -177,86 +176,40 @@ object EmotionAssetBridge {
177176
}
178177
}
179178

180-
private fun readAnimatedIds(indexJson: String): List<String> = runCatching {
181-
val ids = JSONObject(indexJson).optJSONArray("ids") ?: return@runCatching emptyList()
182-
buildList(ids.length()) {
183-
for (index in 0 until ids.length()) {
184-
ids.optString(index).takeIf(String::isNotBlank)?.let(::add)
185-
}
186-
}.distinct()
187-
}.getOrDefault(emptyList())
188-
189-
private fun hasBundledResource(
179+
private fun hasStaticBundledResource(
190180
resourceDir: File,
191-
indexJson: String,
192181
bundledConfigJson: String,
193182
): Boolean {
194-
val indexIds = readAnimatedIds(indexJson)
195-
val configIds = runCatching {
196-
val sysface = JSONObject(bundledConfigJson).optJSONArray("sysface") ?: return@runCatching emptyList()
197-
buildList {
198-
for (index in 0 until sysface.length()) {
199-
val item = sysface.optJSONObject(index) ?: continue
200-
if (item.optInt("AniStickerType", 0) <= 0) continue
201-
item.optString("AniStickerId")
202-
.takeIf(String::isNotBlank)
203-
?.let(::add)
204-
}
205-
}
206-
}.getOrDefault(emptyList())
207-
val animatedIds = (indexIds + configIds).distinct()
208-
val animatedReady = animatedIds.isEmpty() || animatedIds.all { id ->
209-
File(resourceDir, "qlottie/1/$id/$id.json").isFile
210-
}
211183
val configReady = runCatching {
212184
JSONObject(bundledConfigJson).optJSONArray("sysface")?.length()?.let { it > 0 } == true &&
213185
File(resourceDir, CONFIG_ASSET).isFile
214186
}.getOrDefault(false)
215187
val emojiReady = (0..164).all { index ->
216188
File(resourceDir, "emoji_res/emoji_${index.toString().padStart(3, '0')}.png").isFile
217189
}
218-
return animatedReady && configReady && emojiReady
190+
return configReady && emojiReady
219191
}
220192

221-
private fun extractZipAsset(context: Context, resourceDir: File, assetName: String) {
222-
val stage = File(
223-
resourceDir.parentFile,
224-
".${resourceDir.name}.qmce-stage-${System.nanoTime()}",
225-
)
226-
deleteRecursively(stage)
227-
if (!stage.mkdirs()) error("cannot create ${stage.absolutePath}")
228-
193+
private fun extractZipAssetDirect(context: Context, resourceDir: File, assetName: String): Int {
229194
var extracted = 0
230-
try {
231-
context.assets.open(assetName).buffered().use { input ->
232-
ZipInputStream(input).use { zip ->
233-
while (true) {
234-
val entry = zip.nextEntry ?: break
235-
val output = safeEntry(stage, entry.name)
236-
if (entry.isDirectory) {
237-
output.mkdirs()
238-
} else {
239-
output.parentFile?.mkdirs()
240-
output.outputStream().buffered().use { zip.copyTo(it) }
241-
extracted++
242-
}
243-
zip.closeEntry()
195+
context.assets.open(assetName).buffered().use { input ->
196+
ZipInputStream(input).use { zip ->
197+
while (true) {
198+
val entry = zip.nextEntry ?: break
199+
if (entry.isDirectory) {
200+
safeEntry(resourceDir, entry.name).mkdirs()
201+
} else {
202+
val output = safeEntry(resourceDir, entry.name)
203+
output.parentFile?.mkdirs()
204+
output.outputStream().buffered().use { zip.copyTo(it) }
205+
extracted++
244206
}
207+
zip.closeEntry()
245208
}
246209
}
247-
248-
stage.walkTopDown()
249-
.filter(File::isFile)
250-
.forEach { source ->
251-
val relative = source.relativeTo(stage)
252-
val target = File(resourceDir, relative.path)
253-
target.parentFile?.mkdirs()
254-
source.copyTo(target, overwrite = true)
255-
}
256-
Log.i(TAG, "extracted $extracted bundled resources from $assetName")
257-
} finally {
258-
deleteRecursively(stage)
259210
}
211+
Log.i(TAG, "extracted $extracted entries from $assetName -> ${resourceDir.absolutePath}")
212+
return extracted
260213
}
261214

262215
private fun safeEntry(root: File, entryName: String): File {
@@ -275,9 +228,4 @@ object EmotionAssetBridge {
275228
temporary.delete()
276229
}
277230
}
278-
279-
private fun deleteRecursively(file: File) {
280-
if (!file.exists()) return
281-
file.walkBottomUp().forEach { it.delete() }
282-
}
283231
}

0 commit comments

Comments
 (0)