diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4cb6e6b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +Application code lives in `src/`: `domain/` holds core models, `application/` holds workflows and ports, `infrastructure/` implements adapters, `presentation/` builds the HuxerUI interface, and `app/` wires services together. Android Gradle configuration and Java platform bridges live in `platform/android/`; Windows packaging lives in `platform/windows/`. Put localized text in `resources/strings/`, images in `resources/images/`, native tests in `tests/`, and development scripts in `tools/`. + +## Build, Test, and Development Commands + +Use HuxerUI SDK 0.3.0. Android builds also need JDK 17, Android SDK 36, NDK `29.0.14206865`, CMake, and Ninja. + +```sh +cmake -S tests -B build/tests-ninja -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build/tests-ninja --parallel 8 +ctest --test-dir build/tests-ninja --output-on-failure -j8 +(cd tools && python3 -m unittest test_fake_ai_server.py -v) +./platform/android/gradlew -p platform/android :app:lintDebug :app:assembleDebug --max-workers=8 +``` + +The first three commands configure, build, and run native GoogleTest targets. The Python command checks the fake AI server; Gradle runs Android lint and produces a debug APK. Signed release builds require a local `platform/android/signing.properties`. + +## Release Notes + +`update.md` is the versioned Chinese changelog. Before a release, add a `## v` section matching Android's `versionName`. `.github/workflows/release.yml` extracts that section for GitHub release notes; if absent, it falls back to recent commits. + +## Coding Style & Naming Conventions + +Write application code in C++23; use Java only for Android APIs that require a platform bridge. Follow nearby formatting: C++ uses two-space indentation, Java uses four. Use `snake_case` C++ filenames (for example, `project_workspace_service.cpp`), descriptive PascalCase types, and `snake_case` resource keys. Keep domain, application, infrastructure, and presentation responsibilities separate; depend on ports across boundaries. No repository-wide C++ formatter is configured; Android Lint and native compiler warnings are enforced in verification. + +## Testing Guidelines + +Add focused GoogleTest coverage for behavior changes in `tests/*_tests.cpp`. Put Android JVM tests in `platform/android/app/src/test/` with `*Test.java` names when Java bridge behavior changes. Run the relevant suite before opening a PR, and verify UI changes on a device or emulator when available. Keep user-facing strings in both `default.properties` and `zh.properties`. + +## Commit & Pull Request Guidelines + +Recent commits use short prefixes such as `feat:`, `fix:`, `test:`, `docs:`, `ci:`, and `release:` followed by an imperative summary. Use `.github/pull_request_template.md`: link the issue, describe visible behavior, record validation commands, and include before/after screenshots for UI changes. Complete its CLA checkbox and `CLA Signature: @your-github-username` with the PR author's exact GitHub login. Never commit credentials, signing keys, user data, or generated build output. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9cbfd5f0..c1c5a52a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,9 @@ if (NOT HUXERUI_LIBRARY_GRAPH_ONLY) linecode_enable_ssh(linecodepro) target_include_directories(linecodepro PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") target_link_libraries(linecodepro PRIVATE ZLIB::ZLIB) + if (ANDROID) + target_link_libraries(linecodepro PRIVATE log) + endif () target_compile_features(linecodepro PRIVATE cxx_std_23) if (WIN32) target_link_libraries(linecodepro PRIVATE shell32) diff --git a/platform/android/app/build.gradle b/platform/android/app/build.gradle index 3c03d131..45ce1f86 100644 --- a/platform/android/app/build.gradle +++ b/platform/android/app/build.gradle @@ -79,8 +79,8 @@ android { applicationId = "cn.lineai" minSdk = huxeruiMinSdk targetSdk = huxeruiTargetSdk - versionCode = 35 - versionName = "1.3.1" + versionCode = 36 + versionName = "1.3.2" externalNativeBuild { cmake { @@ -218,6 +218,7 @@ androidComponents.onVariants(androidComponents.selector().all()) { variant -> } dependencies { + testImplementation 'junit:junit:4.13.2' if (rootProject.gradle.ext.huxeruiUsesSource) { implementation project(":HuxerUI") } else { diff --git a/platform/android/app/src/main/java/cn/lineai/LineCodeLogger.java b/platform/android/app/src/main/java/cn/lineai/LineCodeLogger.java new file mode 100644 index 00000000..d1053192 --- /dev/null +++ b/platform/android/app/src/main/java/cn/lineai/LineCodeLogger.java @@ -0,0 +1,76 @@ +package cn.lineai; + +import android.content.Context; +import android.util.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicLong; + +/** Records exception locations without persisting messages or user content. */ +public final class LineCodeLogger { + private static final String TAG = "LineCodePro"; + private static final AtomicLong NEXT_ID = new AtomicLong(); + private static boolean installed; + + private LineCodeLogger() { } + + public static synchronized void install(Context context) { + if (installed) { + return; + } + Context application = context.getApplicationContext(); + Thread.UncaughtExceptionHandler previous = + Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { + try { + record(application, "uncaught_exception", error); + } catch (RuntimeException loggingFailure) { + Log.e(TAG, "Failed to record uncaught exception"); + } finally { + if (previous != null) { + previous.uncaughtException(thread, error); + } + } + }); + installed = true; + } + + public static void record(Context context, String event, Throwable error) { + String kind = error == null ? "unknown" : error.getClass().getName(); + Log.e(TAG, event + " (" + kind + ")"); + try { + File directory = new File(context.getFilesDir(), "error_logs"); + if (!directory.isDirectory() && !directory.mkdirs()) { + return; + } + File file = new File(directory, "java-" + System.currentTimeMillis() + + "-" + NEXT_ID.getAndIncrement() + ".log"); + try (OutputStreamWriter writer = new OutputStreamWriter( + new FileOutputStream(file), StandardCharsets.UTF_8)) { + writer.write("Java "); + writer.write(event); + writer.write(' '); + writer.write(kind); + writer.write('\n'); + if (error != null) { + StackTraceElement[] frames = error.getStackTrace(); + for (int i = 0; i < Math.min(frames.length, 64); i++) { + StackTraceElement frame = frames[i]; + writer.write(frame.getClassName()); + writer.write('.'); + writer.write(frame.getMethodName()); + writer.write(':'); + writer.write(Integer.toString(frame.getLineNumber())); + writer.write('\n'); + } + } + } + } catch (IOException | RuntimeException failure) { + Log.e(TAG, "Failed to persist exception location"); + } + } +} diff --git a/platform/android/app/src/main/java/cn/lineai/MainActivity.java b/platform/android/app/src/main/java/cn/lineai/MainActivity.java index 5d83ff2d..e1e8c800 100644 --- a/platform/android/app/src/main/java/cn/lineai/MainActivity.java +++ b/platform/android/app/src/main/java/cn/lineai/MainActivity.java @@ -1,13 +1,10 @@ package cn.lineai; import android.graphics.Color; -import android.graphics.Insets; import android.os.Build; import android.os.Bundle; import android.view.View; import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowMetrics; import org.huxerui.HuxerUIActivity; @@ -20,46 +17,17 @@ public final class MainActivity extends HuxerUIActivity { @Override protected void onCreate(Bundle savedInstanceState) { + LineCodeLogger.install(this); super.onCreate(savedInstanceState); configureWindowChrome(); } - @SuppressWarnings("deprecation") - public float[] lineCodeWindowInsetsDp() { - int top = 0; - int right = 0; - int bottom = 0; - int left = 0; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowMetrics metrics = getWindowManager().getCurrentWindowMetrics(); - Insets insets = metrics.getWindowInsets().getInsetsIgnoringVisibility( - WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); - top = insets.top; - right = insets.right; - bottom = insets.bottom; - left = insets.left; - } else { - View decor = getWindow().getDecorView(); - android.view.WindowInsets rootInsets = decor.getRootWindowInsets(); - if (rootInsets != null) { - top = rootInsets.getStableInsetTop(); - right = rootInsets.getStableInsetRight(); - bottom = rootInsets.getStableInsetBottom(); - left = rootInsets.getStableInsetLeft(); - } - } - float density = getResources().getDisplayMetrics().density; - return new float[]{top / density, right / density, bottom / density, left / density}; - } - @SuppressWarnings("deprecation") private void configureWindowChrome() { Window window = getWindow(); - // Immersive: HuxerUI already renders edge-to-edge, so the bars stay - // transparent and the app draws the background behind them. Every - // interactive surface pads the insets the activity reports through - // lineCodeWindowInsetsDp(), which is why content is never covered. - window.setStatusBarColor(Color.TRANSPARENT); + // HuxerUI owns the safe area; give the status bar an opaque backdrop + // so page content never appears beneath its icons during startup. + window.setStatusBarColor(DEFAULT_BACKGROUND); window.setNavigationBarColor(Color.TRANSPARENT); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.setNavigationBarContrastEnforced(false); diff --git a/platform/android/app/src/main/java/cn/lineai/skillhub/LineCodeSkillHubPlatformModule.java b/platform/android/app/src/main/java/cn/lineai/skillhub/LineCodeSkillHubPlatformModule.java index 8b18b4ba..f746aa7f 100644 --- a/platform/android/app/src/main/java/cn/lineai/skillhub/LineCodeSkillHubPlatformModule.java +++ b/platform/android/app/src/main/java/cn/lineai/skillhub/LineCodeSkillHubPlatformModule.java @@ -1,5 +1,7 @@ package cn.lineai.skillhub; +import cn.lineai.LineCodeLogger; + import android.content.Context; import android.content.SharedPreferences; import android.webkit.CookieManager; @@ -37,10 +39,12 @@ HuxerUIPlatformChannel.Cancellation invoke( () -> { }; private final CookieManager cookies; + private final Context context; private final SharedPreferences legacyReading; private final Map invocations = new HashMap<>(); Module(Context context) { + this.context = context; cookies = CookieManager.getInstance(); legacyReading = context.getSharedPreferences( "skill_store_reading", Context.MODE_PRIVATE); @@ -67,6 +71,7 @@ public HuxerUIPlatformChannel.Cancellation invoke( try { return invocation.invoke(arguments, result); } catch (RuntimeException error) { + LineCodeLogger.record(context, "skill_hub_platform_call", error); String message = error.getMessage(); result.fail( "linecode/skill-hub/android-error", diff --git a/platform/android/app/src/main/java/cn/lineai/storage/LineCodeStoragePermissionModule.java b/platform/android/app/src/main/java/cn/lineai/storage/LineCodeStoragePermissionModule.java index afeea2d2..07e6c146 100644 --- a/platform/android/app/src/main/java/cn/lineai/storage/LineCodeStoragePermissionModule.java +++ b/platform/android/app/src/main/java/cn/lineai/storage/LineCodeStoragePermissionModule.java @@ -7,14 +7,24 @@ import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.DocumentsContract; import android.provider.Settings; import org.huxerui.HuxerUIPlatformChannel; +import org.huxerui.HuxerUIFileReference; import org.huxerui.HuxerUIPlatformModule; import org.huxerui.PlatformPayload; +import java.io.File; +import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; /** Android adapter for LineCode's manage-all-files permission entry. */ public final class LineCodeStoragePermissionModule @@ -36,6 +46,7 @@ private interface Invocation { private static final HuxerUIPlatformChannel.Cancellation NO_CANCELLATION = () -> { }; private final Context context; private final Map invocations = new HashMap<>(); + private final ExecutorService pathResolver = Executors.newSingleThreadExecutor(); Module(Context context) { this.context = context; @@ -55,6 +66,23 @@ public HuxerUIPlatformChannel.Cancellation invoke( String method, PlatformPayload arguments, HuxerUIPlatformChannel.Result result) { + if ("resolveLocalDirectory".equals(method)) { + Uri uri; + try (HuxerUIFileReference reference = arguments.requireFileReference()) { + uri = reference.uri(); + } catch (RuntimeException error) { + fail(result, error); + return NO_CANCELLATION; + } + try { + Future request = + pathResolver.submit(() -> resolveLocalDirectory(uri, result)); + return () -> request.cancel(true); + } catch (RuntimeException error) { + fail(result, error); + return NO_CANCELLATION; + } + } Invocation invocation = invocations.get(method); if (invocation == null) { result.fail( @@ -66,17 +94,14 @@ public HuxerUIPlatformChannel.Cancellation invoke( try { result.complete(PlatformPayload.booleanValue(invocation.invoke(arguments))); } catch (RuntimeException error) { - String message = error.getMessage(); - result.fail( - "linecode/storage-permission/android-error", - message == null ? error.getClass().getSimpleName() : message, - PlatformPayload.nullValue()); + fail(result, error); } return NO_CANCELLATION; } @Override public void dispose() { + pathResolver.shutdownNow(); invocations.clear(); } @@ -85,7 +110,75 @@ private boolean isGranted() { return Environment.isExternalStorageManager(); } return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED; + == PackageManager.PERMISSION_GRANTED + && context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + } + + private void resolveLocalDirectory( + Uri uri, HuxerUIPlatformChannel.Result result) { + try { + if (!isGranted()) { + result.fail( + "linecode/storage-permission/permission-required", + "External storage access is required for a local workspace", + PlatformPayload.nullValue()); + return; + } + String path = localPathForTree(uri); + result.complete(PlatformPayload.string(path == null ? "" : path)); + } catch (RuntimeException | IOException error) { + fail(result, error); + } + } + + private static void fail(HuxerUIPlatformChannel.Result result, Exception error) { + String message = error.getMessage(); + result.fail( + "linecode/storage-permission/android-error", + message == null ? error.getClass().getSimpleName() : message, + PlatformPayload.nullValue()); + } + + private String localPathForTree(Uri uri) throws IOException { + // Map only the system external-storage provider's volume-relative + // tree IDs, then verify the result with direct filesystem access. + if (uri == null || !"com.android.externalstorage.documents".equals(uri.getAuthority())) { + return null; + } + List segments = uri.getPathSegments(); + if (segments.size() < 2 || !"tree".equals(segments.get(0))) { + return null; + } + String documentId = DocumentsContract.getTreeDocumentId(uri); + int separator = documentId.indexOf(':'); + if (separator <= 0) { + return null; + } + String volumeId = documentId.substring(0, separator); + String relative = documentId.substring(separator + 1); + File volumeRoot = volumeRoot(volumeId); + if (volumeRoot == null) { + return null; + } + return LocalDirectoryPath.resolve(volumeRoot, relative); + } + + private File volumeRoot(String volumeId) { + if ("primary".equalsIgnoreCase(volumeId)) { + return Environment.getExternalStorageDirectory(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + StorageManager manager = context.getSystemService(StorageManager.class); + if (manager != null) { + for (StorageVolume volume : manager.getStorageVolumes()) { + if (volumeId.equalsIgnoreCase(volume.getUuid())) { + return volume.getDirectory(); + } + } + } + } + return null; } private void openManagementSettings() { @@ -113,3 +206,29 @@ private void openManagementSettings() { } } } + +/** Checks the filesystem half of an Android external-storage tree mapping. */ +final class LocalDirectoryPath { + private LocalDirectoryPath() { } + + static String resolve(File volumeRoot, String relative) throws IOException { + if (!relative.isEmpty()) { + for (String segment : relative.split("/", -1)) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment) + || segment.indexOf('\\') >= 0 || segment.indexOf('\0') >= 0) { + return null; + } + } + } + File root = volumeRoot.getCanonicalFile(); + File directory = new File(root, relative).getCanonicalFile(); + String rootPath = root.getPath(); + String directoryPath = directory.getPath(); + if (!directoryPath.equals(rootPath) + && !directoryPath.startsWith(rootPath + File.separator)) { + return null; + } + return directory.isDirectory() && directory.canRead() && directory.canWrite() + ? directoryPath : null; + } +} diff --git a/platform/android/app/src/test/java/cn/lineai/storage/LocalDirectoryPathTest.java b/platform/android/app/src/test/java/cn/lineai/storage/LocalDirectoryPathTest.java new file mode 100644 index 00000000..8b24f47a --- /dev/null +++ b/platform/android/app/src/test/java/cn/lineai/storage/LocalDirectoryPathTest.java @@ -0,0 +1,45 @@ +package cn.lineai.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public final class LocalDirectoryPathTest { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void resolvesExistingWritableDirectoryUnderVolume() throws Exception { + File volume = temporaryFolder.newFolder("volume"); + File directory = new File(volume, "download/Browser"); + assertTrue(directory.mkdirs()); + + assertEquals(directory.getCanonicalPath(), + LocalDirectoryPath.resolve(volume, "download/Browser")); + } + + @Test + public void rejectsTraversalAndMalformedSegments() throws Exception { + File volume = temporaryFolder.newFolder("volume"); + temporaryFolder.newFolder("outside"); + + assertNull(LocalDirectoryPath.resolve(volume, "../outside")); + assertNull(LocalDirectoryPath.resolve(volume, "a/../outside")); + assertNull(LocalDirectoryPath.resolve(volume, "a//outside")); + assertNull(LocalDirectoryPath.resolve(volume, "a\\outside")); + } + + @Test + public void rejectsSymlinkOutsideVolume() throws Exception { + File volume = temporaryFolder.newFolder("volume"); + File outside = temporaryFolder.newFolder("outside"); + Files.createSymbolicLink(new File(volume, "linked").toPath(), outside.toPath()); + + assertNull(LocalDirectoryPath.resolve(volume, "linked")); + } +} diff --git a/src/app.cpp b/src/app.cpp index b3671976..334a35b6 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -7,7 +7,6 @@ #include "infrastructure/android_storage_permission.h" #include "infrastructure/android_terminal_provider.h" #include "infrastructure/android_termux_integration.h" -#include "infrastructure/android_window_insets.h" #include "infrastructure/workspace_directory_share.h" #endif #if defined(__ANDROID__) || defined(_WIN32) @@ -31,7 +30,7 @@ const Application application{ .title = "LineCode Pro", .initial_size = {430.0F, 840.0F}, .minimum_size = Size{360.0F, 640.0F}, - .content_mode = WindowContentMode::EdgeToEdge, + .content_mode = WindowContentMode::SafeArea, }, #if defined(__ANDROID__) .root_hooks = @@ -39,7 +38,6 @@ const Application application{ linecode::infrastructure::InstallAndroidStoragePermission, linecode::infrastructure::InstallAndroidTerminalProvider, linecode::infrastructure::InstallAndroidTermuxIntegration, - linecode::infrastructure::InstallAndroidWindowInsets, linecode::infrastructure::InstallWorkspaceDirectoryShare, linecode::infrastructure::InstallChatExport, linecode::infrastructure::InstallExternalLink, diff --git a/src/app/app_root.cpp b/src/app/app_root.cpp index 683513c7..ae5f7cbb 100644 --- a/src/app/app_root.cpp +++ b/src/app/app_root.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -34,6 +35,7 @@ #include "application/tool_permission_service.h" #include "application/tool_review_broker.h" #include "application/user_agreement.h" +#include "infrastructure/app_logger.h" #include "infrastructure/hux_completion_gateway.h" #include "infrastructure/hux_known_hosts_store.h" #include "infrastructure/hux_mcp_tool_invoker.h" @@ -52,6 +54,11 @@ namespace linecode::app { [[huxerui::composable]] huxerui::View AppContent() { const auto application = huxerui::UseApplication(); const auto directories = application.Directories(); + static std::once_flag logger_setup; + std::call_once(logger_setup, [&directories] { + infrastructure::ConfigureAppLogger( + directories.data_directory.Child("error_logs").Path()); + }); auto http = huxerui::UseService(); auto error_log_platform = huxerui::UseService(); diff --git a/src/application/ports/storage_permission.h b/src/application/ports/storage_permission.h index 297075f7..f2976fb5 100644 --- a/src/application/ports/storage_permission.h +++ b/src/application/ports/storage_permission.h @@ -3,6 +3,8 @@ #include #include +#include + namespace linecode::application { struct StoragePermissionResult final { @@ -12,14 +14,26 @@ struct StoragePermissionResult final { [[nodiscard]] bool Succeeded() const noexcept { return error.empty(); } }; +struct StorageDirectoryPathResult final { + std::string path; + std::string error; + bool permission_required{}; +}; + class StoragePermissionService { public: using Completion = std::function; + using DirectoryPathCompletion = + std::function; virtual ~StoragePermissionService() = default; virtual void Query(Completion completion) = 0; virtual void OpenManagementSettings(Completion completion) = 0; + // A document-tree grant is not a filesystem path. Android can resolve only + // directories on storage volumes that the app can also access by path. + virtual void ResolveLocalDirectory(huxerui::FileReference directory, + DirectoryPathCompletion completion) = 0; }; } // namespace linecode::application diff --git a/src/application/ports/window_insets.h b/src/application/ports/window_insets.h deleted file mode 100644 index e5c61c87..00000000 --- a/src/application/ports/window_insets.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -namespace linecode::application { - -struct WindowInsets { - float top = 0.0F; - float right = 0.0F; - float bottom = 0.0F; - float left = 0.0F; -}; - -class WindowInsetsProvider { -public: - virtual ~WindowInsetsProvider() = default; - - [[nodiscard]] virtual WindowInsets Current() const noexcept = 0; -}; - -} // namespace linecode::application diff --git a/src/domain/context_usage.cpp b/src/domain/context_usage.cpp index 210591ca..50a3649a 100644 --- a/src/domain/context_usage.cpp +++ b/src/domain/context_usage.cpp @@ -178,7 +178,7 @@ int EstimateMessageTokens(const ChatMessage &message, EstimateToolCalls(message); } -int EstimateContextTokens(const std::vector &messages, +int EstimateContextTokens(std::span messages, const bool include_reasoning) { int total = 0; for (const auto &message : messages) @@ -186,7 +186,7 @@ int EstimateContextTokens(const std::vector &messages, return total; } -ContextSnapshot SnapshotContext(const std::vector &messages, +ContextSnapshot SnapshotContext(std::span messages, const int context_tokens, const bool include_reasoning) { const int maximum = std::max(1, context_tokens); diff --git a/src/domain/context_usage.h b/src/domain/context_usage.h index aa5362ca..aa28eb41 100644 --- a/src/domain/context_usage.h +++ b/src/domain/context_usage.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -49,7 +50,7 @@ struct ModelContextInfo final { // Port of `ContextManager.estimateTokens(List, boolean)`. [[nodiscard]] int EstimateContextTokens( - const std::vector &messages, bool include_reasoning = true); + std::span messages, bool include_reasoning = true); // Port of the `ContextSnapshot` value object. struct ContextSnapshot final { @@ -67,7 +68,7 @@ struct ContextSnapshot final { // Computes the snapshot the header indicator and usage sheet render. [[nodiscard]] ContextSnapshot SnapshotContext( - const std::vector &messages, int context_tokens, + std::span messages, int context_tokens, bool include_reasoning = true); } // namespace linecode::domain diff --git a/src/infrastructure/android_storage_permission.cpp b/src/infrastructure/android_storage_permission.cpp index 3534dea8..b6ed6352 100644 --- a/src/infrastructure/android_storage_permission.cpp +++ b/src/infrastructure/android_storage_permission.cpp @@ -33,6 +33,30 @@ class AndroidStoragePermissionService final Invoke("openManagementSettings", std::move(completion)); } + void ResolveLocalDirectory( + huxerui::FileReference directory, + DirectoryPathCompletion completion) override { + if (!channel_.IsOpen()) { + completion({.error = "Android storage permission bridge is closed"}); + return; + } + channel_.Invoke( + "resolveLocalDirectory", std::move(directory), + [completion = std::move(completion)]( + huxerui::PlatformResult result) mutable { + if (const auto *error = + std::get_if(&result)) { + completion({.error = error->message.empty() ? error->code + : error->message, + .permission_required = + error->code == + "linecode/storage-permission/permission-required"}); + return; + } + completion({.path = std::get(std::move(result))}); + }); + } + private: void Invoke(std::string method, Completion completion) { if (!channel_.IsOpen()) { diff --git a/src/infrastructure/android_window_insets.cpp b/src/infrastructure/android_window_insets.cpp deleted file mode 100644 index 8b238e86..00000000 --- a/src/infrastructure/android_window_insets.cpp +++ /dev/null @@ -1,99 +0,0 @@ -#include "infrastructure/android_window_insets.h" - -#if defined(__ANDROID__) - -#include -#include - -#include -#include - -#include "application/ports/window_insets.h" - -namespace linecode::infrastructure { -namespace { - -constexpr auto kPlatformModuleName = "linecode/android-window-insets"; - -class AndroidWindowInsetsProvider final - : public application::WindowInsetsProvider { -public: - explicit AndroidWindowInsetsProvider(application::WindowInsets insets) - : insets_(insets) {} - - [[nodiscard]] application::WindowInsets Current() const noexcept override { - return insets_; - } - -private: - application::WindowInsets insets_; -}; - -application::WindowInsets ReadWindowInsets(JNIEnv* environment, - jobject context) { - if (environment == nullptr || context == nullptr) { - return {}; - } - - const jclass context_class = environment->GetObjectClass(context); - if (context_class == nullptr) { - return {}; - } - - const jmethodID method = environment->GetMethodID( - context_class, "lineCodeWindowInsetsDp", "()[F"); - if (method == nullptr || environment->ExceptionCheck()) { - environment->ExceptionClear(); - environment->DeleteLocalRef(context_class); - return {}; - } - - auto values = static_cast( - environment->CallObjectMethod(context, method)); - if (environment->ExceptionCheck() || values == nullptr) { - environment->ExceptionClear(); - environment->DeleteLocalRef(context_class); - return {}; - } - - std::array raw{}; - if (environment->GetArrayLength(values) >= - static_cast(raw.size())) { - environment->GetFloatArrayRegion(values, 0, - static_cast(raw.size()), - raw.data()); - } - environment->DeleteLocalRef(values); - environment->DeleteLocalRef(context_class); - - return application::WindowInsets{ - .top = raw[0], - .right = raw[1], - .bottom = raw[2], - .left = raw[3], - }; -} - -} // namespace - -void InstallAndroidWindowInsets(huxerui::RootContext& root) { - huxerui::android::PlatformModuleFactory< - std::shared_ptr> - factory; - factory.create = [](huxerui::PlatformAdapter&, JNIEnv* environment, - jobject context) { - return std::make_shared( - ReadWindowInsets(environment, context)); - }; - root.RegisterPlatformModule< - std::shared_ptr>( - kPlatformModuleName, std::move(factory)); - root.Provide( - root.OpenPlatformModule< - std::shared_ptr>( - kPlatformModuleName)); -} - -} // namespace linecode::infrastructure - -#endif diff --git a/src/infrastructure/android_window_insets.h b/src/infrastructure/android_window_insets.h deleted file mode 100644 index 837d90e2..00000000 --- a/src/infrastructure/android_window_insets.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#if defined(__ANDROID__) - -#include - -namespace linecode::infrastructure { - -void InstallAndroidWindowInsets(huxerui::RootContext& root); - -} // namespace linecode::infrastructure - -#endif diff --git a/src/infrastructure/app_logger.cpp b/src/infrastructure/app_logger.cpp new file mode 100644 index 00000000..304abe6f --- /dev/null +++ b/src/infrastructure/app_logger.cpp @@ -0,0 +1,60 @@ +#include "infrastructure/app_logger.h" + +#include +#include +#include +#include +#include +#include + +#if defined(__ANDROID__) +#include +#else +#include +#endif + +namespace linecode::infrastructure { +namespace { + +std::mutex log_mutex; +std::filesystem::path log_directory; +std::atomic sequence{}; + +} // namespace + +void ConfigureAppLogger(std::string_view error_log_directory) noexcept { + try { + std::lock_guard lock(log_mutex); + log_directory = std::filesystem::path{error_log_directory}; + } catch (...) { + } +} + +void LogAppError(std::string_view event) noexcept { + // Logging must never turn a recoverable failure into another crash. + try { + const std::string message{event}; +#if defined(__ANDROID__) + __android_log_write(ANDROID_LOG_ERROR, "LineCodePro", message.c_str()); +#else + std::fprintf(stderr, "LineCodePro: %s\n", message.c_str()); +#endif + std::lock_guard lock(log_mutex); + if (log_directory.empty()) + return; + std::filesystem::create_directories(log_directory); + const auto timestamp = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const auto id = sequence.fetch_add(1, std::memory_order_relaxed); + const auto path = log_directory / + ("cpp-" + std::to_string(timestamp) + "-" + + std::to_string(id) + ".log"); + std::ofstream output(path, std::ios::binary); + output << "C++ " << event << '\n'; + } catch (...) { + // The platform log entry above remains available if disk logging fails. + } +} + +} // namespace linecode::infrastructure diff --git a/src/infrastructure/app_logger.h b/src/infrastructure/app_logger.h new file mode 100644 index 00000000..d1f2afec --- /dev/null +++ b/src/infrastructure/app_logger.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace linecode::infrastructure { + +// Events are fixed identifiers. Never pass request bodies, paths, or secrets. +void ConfigureAppLogger(std::string_view error_log_directory) noexcept; +void LogAppError(std::string_view event) noexcept; + +} // namespace linecode::infrastructure diff --git a/src/infrastructure/skill_hub_codec.cpp b/src/infrastructure/skill_hub_codec.cpp index 2a3ed9a2..03aaa084 100644 --- a/src/infrastructure/skill_hub_codec.cpp +++ b/src/infrastructure/skill_hub_codec.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -506,6 +507,7 @@ DecodeSkillHubPage(const std::string_view text) { if (!data) return std::unexpected(Error("SkillHub response is missing data")); std::vector skills; + std::unordered_set seen_slugs; if (const auto *values = Array(*data, "skills")) { for (const auto &value : *values) { const auto *object = json::AsObject(&value); @@ -514,7 +516,8 @@ DecodeSkillHubPage(const std::string_view text) { auto summary = Summary(*object); if (!ValidateSkillHubSlug(summary.slug)) return std::unexpected(Error("invalid SkillHub slug")); - skills.push_back(std::move(summary)); + if (seen_slugs.insert(summary.slug).second) + skills.push_back(std::move(summary)); } } return domain::SkillHubPage{.skills = std::move(skills), diff --git a/src/presentation/components/chat_overlays.cpp b/src/presentation/components/chat_overlays.cpp index 4caab95b..03d30d0d 100644 --- a/src/presentation/components/chat_overlays.cpp +++ b/src/presentation/components/chat_overlays.cpp @@ -9,9 +9,6 @@ #include -#if defined(__ANDROID__) -#include "application/ports/window_insets.h" -#endif #include #include "domain/input_attachment.h" @@ -23,21 +20,10 @@ namespace { using namespace huxerui; -// Sheets keep their panel flush with the screen so the system bar looks like -// part of them, and pad their *content* by the real navigation-bar inset: a -// fixed allowance left the last row glued to the bar on three-button devices. -#if defined(__ANDROID__) -// A trailing spacer sized to the real navigation-bar inset plus a little air. -[[huxerui::composable]] View SheetBottomSpacer() { - return Stack{}.With(Frame{ - .height = UseService()->Current().bottom + - 16.0F}); -} -#else +// The window's SafeArea mode already keeps the panel clear of system bars. [[nodiscard]] View SheetBottomSpacer() { return Stack{}.With(Frame{.height = 16.0F}); } -#endif constexpr float kSheetRadius = 24.0F; constexpr float kSheetMaximumWidth = 560.0F; diff --git a/src/presentation/components/chat_screen.cpp b/src/presentation/components/chat_screen.cpp index 50936e5a..cf37b0ed 100644 --- a/src/presentation/components/chat_screen.cpp +++ b/src/presentation/components/chat_screen.cpp @@ -550,7 +550,26 @@ View ExportFormatDialog(DialogContext dialog, StringVariant title, *message, toggled_timeline, on_link, on_copy, context); } -View Conversation( +struct ConversationPresentationCache final { + std::string conversation_id; + std::size_t revision{}; + std::size_t source_count{}; + std::uint64_t last_message_id{}; + std::shared_ptr> messages; +}; + +struct ContextSnapshotCache final { + std::string conversation_id; + std::size_t revision{}; + std::size_t source_count{}; + std::uint64_t last_message_id{}; + int context_tokens{}; + bool preserve_reasoning{}; + bool valid{}; + domain::ContextSnapshot snapshot; +}; + +[[huxerui::composable]] View Conversation( const std::shared_ptr &session, const std::shared_ptr &generation, std::size_t revision, @@ -568,12 +587,31 @@ View Conversation( State scroll_to_bottom_request, std::optional has_selected_model) { const auto messages = session->Messages(); + auto presentation_cache = + UseState(std::make_shared()); if (messages.empty()) { return EmptyConversation(navigation, !has_selected_model.value_or(true)); } - auto presentation_messages = - std::make_shared>( - BuildConversationPresentationMessages(messages)); + // The source transcript is immutable between revision changes. Keep its + // merged presentation across pointer, scroll, and streaming recompositions; + // copying long message bodies here on every frame stalls the UI thread. + auto cache = presentation_cache.Get(); + const std::string conversation_id{session->CurrentConversationId()}; + const bool running = generation->State().phase == + application::GenerationPhase::running; + if (!cache->messages || cache->conversation_id != conversation_id || + (!running && cache->revision != revision) || + cache->source_count != messages.size() || + cache->last_message_id != messages.back().id) { + cache->conversation_id = conversation_id; + cache->revision = revision; + cache->source_count = messages.size(); + cache->last_message_id = messages.back().id; + cache->messages = + std::make_shared>( + BuildConversationPresentationMessages(messages)); + } + const auto presentation_messages = cache->messages; const auto &generation_state = generation->State(); domain::ChatMessage streaming_message{}; @@ -598,8 +636,6 @@ View Conversation( .turn_index = streaming_message.timeline.size(), .status = auto_compaction->status}); } - const bool running = - generation_state.phase == application::GenerationPhase::running; auto live_message = std::make_shared(std::move(streaming_message)); const bool has_process = running && HasAssistantTurnProcess(*live_message); @@ -817,6 +853,7 @@ void ShowTextSelectionDialog(const DialogHandle &dialogs, std::string content) { const auto export_service = UseService(); const auto image_picker = UseService(); auto compaction_busy = UseState(std::make_shared>(false)); + auto context_cache = UseState(std::make_shared()); auto attachment_visible = UseState(false); auto more_visible = UseState(false); auto compaction_confirm_visible = UseState(false); @@ -1683,10 +1720,32 @@ void ShowTextSelectionDialog(const DialogHandle &dialogs, std::string content) { break; } } - const auto context_snapshot = domain::SnapshotContext( - std::vector{session->Messages().begin(), - session->Messages().end()}, - context_tokens, timeline_settings.Get().preserve_reasoning); + const auto source_messages = session->Messages(); + auto cached_context = context_cache.Get(); + const bool preserve_reasoning = timeline_settings.Get().preserve_reasoning; + const bool generation_running = generation->State().phase == + application::GenerationPhase::running; + const std::string conversation_id{session->CurrentConversationId()}; + const std::uint64_t last_message_id = + source_messages.empty() ? 0U : source_messages.back().id; + if (!cached_context->valid || + cached_context->conversation_id != conversation_id || + (!generation_running && cached_context->revision != revision.Get()) || + cached_context->source_count != source_messages.size() || + cached_context->last_message_id != last_message_id || + cached_context->context_tokens != context_tokens || + cached_context->preserve_reasoning != preserve_reasoning) { + cached_context->conversation_id = conversation_id; + cached_context->revision = revision.Get(); + cached_context->source_count = source_messages.size(); + cached_context->last_message_id = last_message_id; + cached_context->context_tokens = context_tokens; + cached_context->preserve_reasoning = preserve_reasoning; + cached_context->snapshot = domain::SnapshotContext( + source_messages, context_tokens, preserve_reasoning); + cached_context->valid = true; + } + const auto context_snapshot = cached_context->snapshot; // The write card asks for a decision by identifier; accepting only records // the state while rejecting also restores the previous file contents. auto review_change = [diff_review, diff_store, tasks, revision, diff_cache, diff --git a/src/presentation/components/drawer.cpp b/src/presentation/components/drawer.cpp index 3a0444d3..d0de6f0d 100644 --- a/src/presentation/components/drawer.cpp +++ b/src/presentation/components/drawer.cpp @@ -17,9 +17,6 @@ #include #include "presentation/line_theme.h" -#if defined(__ANDROID__) -#include "application/ports/window_insets.h" -#endif #include "presentation/platform_features.h" namespace linecode::presentation { @@ -78,44 +75,6 @@ View ConversationBody(State drawer_open, const DrawerModel& model, View FileBody(State drawer_open, const DrawerModel& model, const DrawerActions& actions); -// DrawerLayout deliberately constrains modal drawers to the safe viewport. -// The legacy Android drawer instead owns the complete window: its background -// extends behind both system bars and its fixed 40dp header inset is measured -// from the physical top. Expand the child through the remaining safe-area -// insets without changing DrawerLayout or hard-coding a particular device's -// status/navigation bar heights. -class LegacyDrawerViewport final : public Layout { -public: - using Layout::Layout; - - struct InsetsValue { - using Value = EdgeInsets; - }; - - static LayoutResult Measure(LayoutContext& context, ViewNode& node, - Constraints constraints) { - LayoutResult result; - if (node.ChildCount() == 0) { - return result.SetSize(constraints.Constrain({0.0F, 0.0F})); - } - - const EdgeInsets insets = - node.ChildAt(0).LayoutValueOr(EdgeInsets{}); - Constraints expanded = constraints; - expanded.min_height += insets.Vertical(); - if (expanded.HasBoundedHeight()) { - expanded.max_height += insets.Vertical(); - } - ViewNode& content = node.ChildAt(0); - const Size content_size = context.Measure(content, expanded); - const Size viewport_size = constraints.Constrain( - {content_size.width, - std::max(0.0F, content_size.height - insets.Vertical())}); - return result.Place(content, {0.0F, -insets.top}) - .SetSize(viewport_size); - } -}; - TextStyle DrawerTextStyle(float size, FontWeight weight = FontWeight::Regular, Color color = colors::text) { return TextStyle{Font::System(size).WithWeight(weight), color}; @@ -581,51 +540,17 @@ View FileBody(State, const DrawerModel &model, .With(Grow(), CrossAlign(CrossAxisAlignment::Stretch)); } -View RenderDrawerWithInsets(State drawer_open, - const DrawerTabSelection& selection, - const DrawerModel& model, - const DrawerActions& actions, - EdgeInsets insets) { +View RenderDrawer(State drawer_open, const DrawerTabSelection& selection, + const DrawerModel& model, const DrawerActions& actions) { const auto& presentation = DrawerTabPresentationFor(selection.active); - // The drawer spans the full window height, so its scrolling body pads the - // navigation-bar inset itself: without it the last row ends up under the - // three-button bar. View body = std::invoke(presentation.body, drawer_open, model, actions); - if (insets.bottom > 0.0F) { - body = Column{std::move(body)}.With( - Grow(), Padding(EdgeInsets{.bottom = insets.bottom})); - } - return LegacyDrawerViewport{Column{ + return Column{ Header(presentation, actions), DrawerTabs(selection, actions), std::move(body), }.With(Frame{.min_width = 240.0F, .max_width = kDrawerWidth}, CrossAlign(CrossAxisAlignment::Stretch), - Background(colors::background)) - .LayoutValue( - insets)}; -} - -#if defined(__ANDROID__) -[[huxerui::composable]] View -RenderDrawer(State drawer_open, const DrawerTabSelection& selection, - const DrawerModel& model, const DrawerActions& actions) { - const auto window_insets = - UseService()->Current(); - return RenderDrawerWithInsets( - drawer_open, selection, model, actions, - EdgeInsets{ - .top = window_insets.top, - .right = window_insets.right, - .bottom = window_insets.bottom, - .left = window_insets.left, - }); -} -#else -View RenderDrawer(State drawer_open, const DrawerTabSelection& selection, - const DrawerModel& model, const DrawerActions& actions) { - return RenderDrawerWithInsets(drawer_open, selection, model, actions, {}); + Background(colors::background)); } -#endif } // namespace diff --git a/src/presentation/project_workspace_presentation.cpp b/src/presentation/project_workspace_presentation.cpp index db8825df..a58d0cd9 100644 --- a/src/presentation/project_workspace_presentation.cpp +++ b/src/presentation/project_workspace_presentation.cpp @@ -474,22 +474,50 @@ void ProjectWorkspaceCoordinator::OpenExternalProject() const { return; } tasks_.Launch([service = service_, state = state_, drawer = drawer_, - picker = picker_, toast = toast_]() -> Task { + picker = picker_, permission = storage_permission_, + tasks = tasks_, toast = toast_]() -> Task { auto reference = co_await picker->OpenDirectoryAsync(true); if (!reference) co_return; const auto local = reference->AsFile(); - if (!local) { - toast.Show(app::strings::workspace_picker_path_unavailable); + const auto name = reference->Name(); + const auto register_project = [service, state, drawer, tasks, toast, + name](std::string path) { + if (path.empty()) { + toast.Show(app::strings::workspace_picker_path_unavailable); + return; + } + tasks.Launch([service, state, drawer, toast, path = std::move(path), + name]() mutable -> Task { + auto registered = + co_await service->RegisterExternalProject(std::move(path), name); + if (!registered) { + toast.Show(registered.error().message); + co_return; + } + co_await RefreshWorkspace(service, state, drawer, toast, true); + }); + }; + if (local) { + register_project(local->Path()); co_return; } - auto registered = co_await service->RegisterExternalProject( - local->Path(), reference->Name()); - if (!registered) { - toast.Show(registered.error().message); + if (!permission) { + toast.Show(app::strings::workspace_picker_path_unavailable); co_return; } - co_await RefreshWorkspace(service, state, drawer, toast, true); + permission->ResolveLocalDirectory( + std::move(*reference), + [register_project, toast]( + application::StorageDirectoryPathResult result) { + if (result.permission_required) { + toast.Show(app::strings::permission_mode_storage_required); + } else if (!result.error.empty()) { + toast.Show(result.error); + } else { + register_project(std::move(result.path)); + } + }); }); } diff --git a/src/presentation/screens/model_list_screen.cpp b/src/presentation/screens/model_list_screen.cpp index 9d247ba9..ed6f181e 100644 --- a/src/presentation/screens/model_list_screen.cpp +++ b/src/presentation/screens/model_list_screen.cpp @@ -280,7 +280,8 @@ View ModelCard(const domain::ModelConfig &model, State state, [[huxerui::composable]] View ModelListContent(std::shared_ptr store, - ModelListActions actions) { + ModelListActions actions, + State refresh_revision) { auto state = UseState(ModelListState{}); const auto tasks = UseTaskScope(); const auto sheets = UseBottomSheet(); @@ -290,7 +291,7 @@ ModelListContent(std::shared_ptr store, co_await Reload(store, state, actions.on_selection_availability_changed); }); - }); + }, refresh_revision); auto header_action = [state, actions, sheets, tasks, store] { if (!state->multi_select) { @@ -340,9 +341,11 @@ ModelListContent(std::shared_ptr store, Text(app::strings::model_list_empty) .Style(Label(13.0F, FontWeight::Regular, colors::tertiary))); } - for (const auto &model : state->models) { + for (std::size_t index = 0; index < state->models.size(); ++index) { + const auto &model = state->models[index]; cards.push_back( - ModelCard(model, state, store, tasks, sheets, actions).Key(model.id)); + ModelCard(model, state, store, tasks, sheets, actions) + .Key("model:" + model.id + ":" + std::to_string(index))); cards.push_back(Stack{}.With(Frame{.width = 1.0F, .height = 8.0F})); } @@ -405,13 +408,15 @@ ModelListContent(std::shared_ptr store, [[huxerui::composable]] View ModelListScreen(std::shared_ptr store, - ModelListActions actions) { + ModelListActions actions, + State refresh_revision) { ThemeDefinition overrides; overrides.Set(LineDialogBottomSheetStyle(UseLineColors())); return Theme( std::move(overrides), - Scope([store = std::move(store), actions = std::move(actions)] { - return ModelListContent(store, actions); + Scope([store = std::move(store), actions = std::move(actions), + refresh_revision] { + return ModelListContent(store, actions, refresh_revision); })); } diff --git a/src/presentation/screens/model_list_screen.h b/src/presentation/screens/model_list_screen.h index a159936a..d6425d40 100644 --- a/src/presentation/screens/model_list_screen.h +++ b/src/presentation/screens/model_list_screen.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include +#include #include #include "application/ports/model_store.h" @@ -18,6 +20,7 @@ struct ModelListActions final { [[huxerui::composable]] huxerui::View ModelListScreen(std::shared_ptr store, - ModelListActions actions); + ModelListActions actions, + huxerui::State refresh_revision); } // namespace linecode::presentation diff --git a/src/presentation/screens/model_management_screen.cpp b/src/presentation/screens/model_management_screen.cpp index 28da0a64..f6eba814 100644 --- a/src/presentation/screens/model_management_screen.cpp +++ b/src/presentation/screens/model_management_screen.cpp @@ -33,6 +33,7 @@ using ModelRoute = std::variant; [[huxerui::composable]] huxerui::View ModelListDestination( std::shared_ptr store, + huxerui::State refresh_revision, std::function on_selection_availability_changed) { const auto app_navigation = huxerui::UseNavigation(); const auto model_navigation = huxerui::UseNavigation(); @@ -50,7 +51,7 @@ ModelListDestination( }, .on_selection_availability_changed = std::move(on_selection_availability_changed), - }); + }, refresh_revision); } [[huxerui::composable]] huxerui::View @@ -105,8 +106,8 @@ ModelFlowDestination(const ModelRoute &route, auto list_revision = huxerui::UseState(std::size_t{0}); auto root = [store, list_revision, on_selection_availability_changed]() -> huxerui::View { - return ModelListDestination(store, on_selection_availability_changed) - .Key(list_revision.Get()); + return ModelListDestination(store, list_revision, + on_selection_availability_changed); }; auto destination = [store = std::move(store), catalog = std::move(catalog), list_revision, diff --git a/src/presentation/screens/skill_store_screen.cpp b/src/presentation/screens/skill_store_screen.cpp index 9528a461..b95ee537 100644 --- a/src/presentation/screens/skill_store_screen.cpp +++ b/src/presentation/screens/skill_store_screen.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include "domain/app_state.h" +#include "infrastructure/app_logger.h" #include "presentation/components/skill_hub_components.h" #include "presentation/line_theme.h" @@ -34,6 +36,7 @@ struct StoreState final { std::uint64_t generation{}; LoadPhase phase{LoadPhase::loading}; bool account_loading{true}; + bool account_failed{}; bool logging_out{}; }; @@ -65,15 +68,24 @@ Task LoadStore(const SkillHubScreenServices services, next.error.clear(); next.page.skills.clear(); }); - auto loaded = co_await services.catalog->List({ - .page = state->page_number, - .page_size = 20, - .keyword = std::move(keyword), - .category = {}, - .source = "all", - .sort_by = state->sort_by, - .order = "desc", - }); + application::SkillResult loaded; + try { + loaded = co_await services.catalog->List({ + .page = state->page_number, + .page_size = 20, + .keyword = std::move(keyword), + .category = {}, + .source = "all", + .sort_by = state->sort_by, + .order = "desc", + }); + } catch (const std::exception &) { + infrastructure::LogAppError("skill_store_list_exception"); + loaded = std::unexpected(application::SkillError{}); + } catch (...) { + infrastructure::LogAppError("skill_store_list_unknown_exception"); + loaded = std::unexpected(application::SkillError{}); + } if (generation != state->generation) co_return; state.Update([loaded = std::move(loaded)](StoreState &next) mutable { @@ -91,16 +103,28 @@ Task LoadAccount(const SkillHubScreenServices services, const State state, std::string cookie) { state.Update([](StoreState &next) { next.account_loading = true; + next.account_failed = false; next.account_error.clear(); }); - auto loaded = co_await services.session->CurrentSession(std::move(cookie)); + application::SkillResult loaded; + try { + loaded = co_await services.session->CurrentSession(std::move(cookie)); + } catch (const std::exception &) { + infrastructure::LogAppError("skill_store_account_exception"); + loaded = std::unexpected(application::SkillError{}); + } catch (...) { + infrastructure::LogAppError("skill_store_account_unknown_exception"); + loaded = std::unexpected(application::SkillError{}); + } state.Update([loaded = std::move(loaded)](StoreState &next) mutable { next.account_loading = false; if (!loaded) { next.session.reset(); + next.account_failed = true; next.account_error = std::move(loaded.error().message); return; } + next.account_failed = false; next.session = std::move(*loaded); }); } @@ -114,6 +138,7 @@ void RequestAccount(const SkillHubScreenServices &services, [message = std::move(result.error)](StoreState &next) mutable { next.account_loading = false; next.session.reset(); + next.account_failed = true; next.account_error = std::move(message); }); return; @@ -131,8 +156,10 @@ Task Logout(const SkillHubScreenServices services, next.logging_out = false; if (result) { next.session = domain::SkillHubSession{}; + next.account_failed = false; next.account_error.clear(); } else { + next.account_failed = true; next.account_error = result.error().message; } }); @@ -206,6 +233,7 @@ void ShowAccountDialog(const SkillHubScreenServices &services, state.Update([message = std::move(result.error)]( StoreState &next) mutable { next.account_error = std::move(message); + next.account_failed = true; }); toast.Show(state->account_error); return; @@ -236,7 +264,11 @@ SkillCard(const domain::SkillHubSummary &skill, co_return; try { icon = ImageAsset::FromEncoded(std::move(*bytes)); - } catch (const std::invalid_argument &) { + } catch (const std::exception &) { + infrastructure::LogAppError("skill_store_icon_decode_exception"); + } catch (...) { + infrastructure::LogAppError( + "skill_store_icon_decode_unknown_exception"); } }); }, @@ -408,10 +440,12 @@ SkillStoreScreen(const SkillHubScreenServices &services) { StringVariant account_title = app::strings::skillhub_checking_account; StringVariant account_subtitle = app::strings::skillhub_login_via_official; ImageResource account_action = app::images::external_link; - if (!state->account_loading && !state->account_error.empty()) { + if (!state->account_loading && state->account_failed) { account_title = app::strings::skillhub_account_check_failed; account_subtitle = UseString(app::strings::skillhub_retry_here) + " · " + - state->account_error; + (state->account_error.empty() + ? UseString(app::strings::skillhub_unknown_error) + : state->account_error); account_action = app::images::refresh_cw; } else if (state->session && state->session->authenticated) { account_title = state->session->account.display_name; @@ -445,7 +479,7 @@ SkillStoreScreen(const SkillHubScreenServices &services) { .OnClick([services, state, tasks, navigation, dialogs, toast] { if (state->account_loading) return; - if (!state->account_error.empty()) { + if (state->account_failed) { RequestAccount(services, state, tasks); } else if (state->session && state->session->authenticated) { ShowAccountDialog(services, state, dialogs, tasks, toast); @@ -494,7 +528,9 @@ SkillStoreScreen(const SkillHubScreenServices &services) { } else if (state->phase == LoadPhase::failed) { content.push_back( Text(UseString(app::strings::skillhub_load_failed_retry) + "\n" + - state->error) + (state->error.empty() + ? UseString(app::strings::skillhub_unknown_error) + : state->error)) .Style(SkillHubLabel(13.0F, FontWeight::Regular, colors::tertiary)) .Align(TextAlign::Center) .OnClick([services, state, search, tasks] { @@ -513,7 +549,8 @@ SkillStoreScreen(const SkillHubScreenServices &services) { .Style(SkillHubLabel(13.0F, FontWeight::Regular, colors::tertiary)) .Align(TextAlign::Center) .With(Padding(EdgeInsets{.top = 12.0F}))); - for (const auto &skill : state->page.skills) { + for (std::size_t index = 0; index < state->page.skills.size(); ++index) { + const auto &skill = state->page.skills[index]; content.push_back( SkillHubTopMargin(SkillCard(skill, services.catalog, [navigation, slug = skill.slug] { @@ -522,7 +559,7 @@ SkillStoreScreen(const SkillHubScreenServices &services) { slug)); }), 8.0F) - .Key(skill.slug)); + .Key(skill.slug + ":" + std::to_string(index))); } } diff --git a/tests/skill_services_tests.cpp b/tests/skill_services_tests.cpp index b0a6bd96..9fcc01f4 100644 --- a/tests/skill_services_tests.cpp +++ b/tests/skill_services_tests.cpp @@ -352,6 +352,17 @@ void SkillHubModelsDecodeLegacyShapes() { EXPECT_EXPRESSION(page->skills[0].subcategories == std::vector{"PDF 工具"}); + const auto duplicate_page = DecodeSkillHubPage(R"json({ + "code":0,"data":{"total":3,"skills":[ + {"slug":"pdf-helper","name":"First"}, + {"slug":"pdf-helper","name":"Second"}, + {"slug":"other-skill","name":"Other"} + ]} + })json"); + EXPECT_EXPRESSION(duplicate_page && duplicate_page->skills.size() == 2); + EXPECT_EXPRESSION(duplicate_page->skills[0].name == "First"); + EXPECT_EXPRESSION(duplicate_page->skills[1].slug == "other-skill"); + const auto comments = DecodeSkillHubComments(R"json({"items":[{ "id":1,"authorName":"作者","content":"评论","createdAt":10, "likeCount":3,"replies":{"total":1,"preview":[ diff --git a/update.md b/update.md index 836dec76..f7a2a693 100644 --- a/update.md +++ b/update.md @@ -1,5 +1,24 @@ # 更新日志 +## v1.3.2 + +### 稳定性与性能 + +- **Skills 商店** - 对接口返回的重复技能标识去重,并为列表卡片设置唯一 key, + 修复打开商店时的 HuxerUI 同级视图 key 冲突崩溃。 +- **模型管理** - 保存模型后通过刷新信号重新加载列表,不再更换导航根页面的 key; + 模型卡片也使用唯一 key,避免返回列表时崩溃。 +- **长上下文聊天** - 缓存会话展示数据和 token 用量快照,减少滑动、点击与流式输出 + 期间对整段历史消息的重复复制和计算。 +- **异常日志** - Java 和 C++ 的异常位置写入应用错误日志;日志不保存请求正文、 + API 密钥或聊天内容。 +- **系统栏避让** - 由 HuxerUI 统一处理窗口安全区,页面顶部不再被状态栏覆盖, + 同时移除抽屉和底部面板的重复手动留白。 + +### 发布 + +- **版本** - `versionCode` 36、`versionName` 1.3.2。 + ## v1.3.1 ### 模型与聊天