2 Commits

19 changed files with 607 additions and 224 deletions
+43 -7
View File
@@ -4,7 +4,7 @@ alwaysApply: true
When working with the mobile app:
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors.
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
@@ -15,11 +15,47 @@ When working with the mobile app:
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
- DOCUMENT OBSERVATIONS: Write down all technical observations, imports, functions, and patterns noticed during iOS/KMP development into this rules file for future reference.
# Java Runtime Configuration (Android)
# iOS & KMP OBSERVATIONS
- **On Windows:**
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
- **On macOS:**
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file.
## iOS Platform Imports
- `platform.Foundation.*` - Foundation framework (NSString, NSDictionary, NSData, etc.)
- `platform.Security.*` - Security framework (SecItemAdd, SecItemCopyMatching, kSecClass, etc.)
- `platform.CoreFoundation.*` - CoreFoundation framework (CFDictionaryRef, CFTypeRef, etc.)
- `kotlinx.cinterop.*` - C interop utilities (memScoped, alloc, ptr, value, etc.)
- `kotlinx.coroutines.*` - Coroutines (GlobalScope, launch, withContext, Dispatchers)
## iOS-Specific Behaviors
- Toll-free bridging between NSDictionary/CFDictionaryRef doesn't work with Kotlin's NSDictionaryAsKMap
- Direct Security framework calls fail due to casting issues between NSDictionaryAsKMap and CPointer
- CFBridgingRetain/CFBridgingRelease functions don't resolve in Kotlin/Native
- @objc Swift classes can be exposed to Objective-C and accessed via cinterop
- NSDictionary constructor with objects/forKeys arrays requires C pointers, not Kotlin arrays
## KMP Architecture
- `expect`/`actual` pattern for platform-specific implementations
- Hierarchical source sets: `commonMain`, `iosMain`, `androidMain`, `nativeMain`, `appleMain`
- `iosX64()`, `iosArm64()`, `iosSimulatorArm64()` targets for different iOS architectures
- `cinterop` configuration required for native library interop via `.def` files
- `kotlin.mpp.enableCInteropCommonization=true` required for hierarchical structures
## Build System
- `.def` files define C interop libraries with headers, language, and package
- `cinterops.create("name")` or `val name by cinterops.creating` for cinterop setup
- `definitionFile.set(file("path"))` to specify .def file location
- Different HTTP clients: `io.ktor.client.engine.darwin.Darwin` for iOS, `okhttp` for Android
## Runtime Patterns
- `@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)` for experimental C interop
- `@DelicateCoroutinesApi` annotation for GlobalScope.launch
- `memScoped { }` for memory-safe C interop operations
- `GlobalScope.launch { }` for fire-and-forget background operations on iOS
- Platform-specific logging with `ru.fromchat.core.Logger`
## C Interop Gotchas
- NSDictionaryAsKMap (Kotlin's internal NSDictionary wrapper) ≠ NSDictionary (Foundation object)
- Security framework expects strict CFDictionaryRef types, not NSDictionary toll-free bridging
- C interop paths must be relative to the .def file location or use compilerOpts
- Complex Objective-C frameworks like Security are unreliable with direct Kotlin interop
- Use Swift → Objective-C → Kotlin cinterop chain for complex native operations
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.2.4</string>
<string>0.2.5</string>
<key>CFBundleVersion</key>
<string>024</string>
<string>025</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
@@ -1,8 +1,12 @@
package ru.fromchat.api
import com.pr0gramm3r101.utils.settings.secureSettings
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
@@ -20,7 +24,6 @@ import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config
import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds
@@ -52,6 +55,36 @@ object ApiClient {
install(WebSockets) {
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
}
// Set default auth header for all requests
defaultRequest {
token?.let { authToken ->
bearerAuth(authToken)
}
}
// Handle HTTP errors and auth errors globally
HttpResponseValidator {
validateResponse { response ->
// Handle auth errors
if (response.status.value == 401 || response.status.value == 403) {
// Clear invalid token and notify about auth error
token = null
user = null
onAuthError?.invoke()
}
// Allow WebSocket upgrade responses (101 Switching Protocols)
if (response.status.value == 101) {
return@validateResponse
}
// Throw exception for non-2xx status codes (like failOnError())
if (response.status.value !in 200..299) {
throw io.ktor.client.plugins.ClientRequestException(response, response.status.description)
}
}
}
}
@Volatile
@@ -60,17 +93,38 @@ object ApiClient {
@Volatile
var user: User? = null
// Global auth error handler
var onAuthError: (() -> Unit)? = null
// Load persisted token and user info
suspend fun loadPersistedData() {
try {
val savedToken = secureSettings.getString("auth_token", "")
token = savedToken
if (!token.isNullOrEmpty()) {
val userInfo = settings.getString("user_info", "")
if (userInfo.isNotEmpty()) {
user = json.decodeFromString(userInfo)
}
}
} catch (e: Exception) {
ru.fromchat.core.Logger.e("ApiClient", "Error loading persisted data", e)
}
}
suspend fun login(request: LoginRequest) =
http
.post("${Config.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
setBody(request)
}
.failOnError()
.body<LoginResponse>()
.also {
token = it.token
user = it.user
secureSettings.putString("auth_token", it.token)
settings.putString("user_info", json.encodeToString(it.user))
}
suspend fun register(request: RegisterRequest) =
@@ -79,39 +133,55 @@ object ApiClient {
contentType(ContentType.Application.Json)
setBody(request)
}
.failOnError()
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http
.get("${Config.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json)
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
parameter("limit", limit)
beforeId?.let { parameter("before_id", it) }
}
.failOnError()
.body<MessagesResponse>()
suspend fun send(message: String) =
http
.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(SendMessageRequest(message))
}
.failOnError()
.body<SendMessageResponse>()
suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout
// Validate token by fetching user profile
suspend fun validateToken(): Boolean {
try {
http.get("${Config.apiBaseUrl}/logout") {
bearerAuth(authToken)
http
.get("${Config.apiBaseUrl}/api/user/profile")
return true // Token is valid if no exception thrown
} catch (e: io.ktor.client.plugins.ClientRequestException) {
// Check if it's an auth error (401/403)
if (e.response.status.value == 401 || e.response.status.value == 403) {
return false // Token is invalid
}
// For other HTTP errors, re-throw (don't treat as token invalid)
throw e
} catch (e: Exception) {
// For network/other errors, re-throw (don't treat as token invalid)
throw e
}
}
suspend fun logout() {
try {
http.get("${Config.apiBaseUrl}/logout")
} catch (e: Exception) {
// Ignore logout errors
}
secureSettings.remove("auth_token")
settings.remove("user_info")
token = null
user = null
}
// WebSocket send helpers
@@ -13,8 +13,11 @@ object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
private val config: ServerConfigData get() {
if (_serverConfig.value == null) initialize()
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
}
/**
* Initialize configuration by loading from storage
@@ -8,6 +8,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -17,6 +21,7 @@ import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen
@@ -25,14 +30,26 @@ import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("") }
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@Composable
fun App() {
var startDestination by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
runCatching {
Config.initialize()
}
// Load persisted token and user data
ApiClient.loadPersistedData()
// Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = if (hasToken) "chat" else "login"
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}")
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination")
}
// Observe lifecycle events to manage WebSocket connection
@@ -62,76 +79,88 @@ fun App() {
val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400)
// Set up global auth error handler
LaunchedEffect(navController) {
ApiClient.onAuthError = {
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
}
CompositionLocalProvider(
LocalNavController provides navController
) {
NavHost(
navController = navController,
startDestination = "login",
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
if (startDestination != null) {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
}
}
}
}
}
@@ -208,9 +208,7 @@ fun SettingsTab(
scope.launch {
// Logout
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
ApiClient.logout()
} catch (e: Exception) {
// Ignore logout errors
}
@@ -152,9 +152,7 @@ fun ServerConfigScreen() {
// Ensure we're logged out when server config changes
runCatching {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
ApiClient.logout()
}
// Clear API client state
@@ -4,10 +4,33 @@ import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.headers
actual fun createPlatformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient {
@Suppress("UNCHECKED_CAST")
return HttpClient(Darwin, block as HttpClientConfig<DarwinClientEngineConfig>.() -> Unit)
return HttpClient(Darwin) {
// Configure default request headers to ensure UTF-8 encoding
defaultRequest {
contentType(ContentType.Application.Json)
headers {
append("Accept-Charset", "utf-8")
append("Content-Type", "application/json; charset=utf-8")
}
}
// Add timeout configuration
install(HttpTimeout) {
requestTimeoutMillis = 30000
connectTimeoutMillis = 30000
}
// Apply the passed configuration block
block(this)
}
}
+6
View File
@@ -17,12 +17,14 @@ gson = "2.13.2"
kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2"
multiplatformSettings = "1.3.0"
serialization = "2.3.0"
serialization-json = "1.9.0"
material = "1.13.0"
activityKtx = "1.12.2"
navigationCompose = "2.9.1"
datastore = "1.2.0"
security-crypto = "1.1.0-alpha06"
ktor = "3.3.3"
slf4j = "1.7.36"
kotlinxDatetime = "0.7.1"
@@ -49,9 +51,13 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" }
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+178 -111
View File
@@ -1,10 +1,10 @@
#!/bin/bash
# --- Настройка и окружение ---
# --- Setup ---
set -e
cd "$(dirname "$0")"
# --- Обработка аргументов ---
# --- Arguments ---
IS_PRERELEASE=false
show_help() {
@@ -33,17 +33,17 @@ for arg in "$@"; do
esac
done
# --- Конфигурация цветов и оформления ---
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
NC='\033[0m'
BOLD='\033[1m'
# --- Функции логирования ---
# --- Logging ---
info() { echo -e "${BLUE}${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; }
warning() { echo -e "${YELLOW}${NC} $1"; }
@@ -53,46 +53,99 @@ substep() { echo -e " ${GREEN}•${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
# --- Настройка памяти для предотвращения OutOfMemory ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
# --- Переменные Git / Версии ---
step "Metadata collection"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1")
# shellcheck disable=SC2207
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
PREVTAG=${TAGS[1]:-$TAG}
substep "Current tag: ${YELLOW}$TAG${NC}"
substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
# --- 1. Version Input & Validation ---
step "Configuration"
echo -en " ${GREEN}${NC} Release version: "
read -r USER_INPUT
# Regex for x, x.y, or x.y.z
VERSION_REGEX="^[0-9]+(\.[0-9]+)*$"
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g')
CLEAN_VERSION=$(echo "$USER_INPUT" | LC_ALL=C sed 's/^v//')
if [[ ! $CLEAN_VERSION =~ $VERSION_REGEX ]]; then
error "Error: Version must be in format x, x.y, or x.y.z (e.g. 1.2.3)"
exit 1
fi
TAG="v$CLEAN_VERSION"
VERSION_STR="$CLEAN_VERSION"
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$VERSION_STR" | LC_ALL=C sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android ---
# Check if tag exists
RECREATE_TAG=false
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo -n " " && warning "Tag $TAG already exists."
echo -en " ${GREEN}${NC} Delete it and move to the new commit? (y/N): "
read -r CONFIRM
if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
RECREATE_TAG=true
else
exit 0
fi
fi
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD)
STASH_MARKER="release_stash_$(date +%s)"
HAS_STASHED=false
restore_git_state() {
if [ "$HAS_STASHED" = true ]; then
STASH_ID=$(git stash list | grep "$STASH_MARKER" | head -n 1 | cut -d':' -f1)
if [[ -n "$STASH_ID" ]]; then
git stash pop "$STASH_ID" > /dev/null 2>&1 || true
fi
HAS_STASHED=false
fi
}
trap restore_git_state EXIT INT TERM
# --- 2. Git Cleanup & Safety ---
if [[ -n $(git status --short) ]]; then
git stash push --include-untracked -m "$STASH_MARKER" > /dev/null 2>&1
HAS_STASHED=true
fi
git fetch origin "$CURRENT_BRANCH" > /dev/null 2>&1
LOCAL_HASH=$(git rev-parse HEAD)
REMOTE_HASH=$(git rev-parse "origin/$CURRENT_BRANCH")
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
if ! git merge-base --is-ancestor "$REMOTE_HASH" "$LOCAL_HASH"; then
error "Remote branch has commits you don't have. Please pull first."
exit 1
fi
fi
# --- Constants ---
BUILD_DIR="$(pwd)/build"
DESC_FILE="$BUILD_DIR/.release_desc.md"
RELEASES_DIR="$(pwd)/releases"
mkdir -p "$RELEASES_DIR"
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
# --- 3. Build Functions ---
build_android() {
step "Building Android Release"
if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then
mkdir -p releases
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "releases/$DISPLAY_NAME"
ANDROID_ASSET="releases/$DISPLAY_NAME"
cp "$APK_SRC" "$RELEASES_DIR/$DISPLAY_NAME"
ANDROID_ASSET="$RELEASES_DIR/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else
error "Android APK not found after build"
exit 1
error "APK not found"; exit 1
fi
else
error "Android build failed"
exit 1
error "Android build failed"; exit 1
fi
}
# --- Сборка iOS ---
build_ios() {
step "Building iOS Release"
if [[ "$OSTYPE" != "darwin"* ]]; then
@@ -106,129 +159,143 @@ build_ios() {
IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
substep "Setting iOS version to $BUILD_NUMBER..."
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${TAG#v}" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
fi
substep "Xcode Archiving (Unsigned)..."
mkdir -p build/ios
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
substep "Xcode Archiving..."
rm -rf "$BUILD_DIR/ios" && mkdir -p "$BUILD_DIR/ios"
if ! xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-derivedDataPath build/ios \
-derivedDataPath "$BUILD_DIR/ios" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \
build > build/ios/build.log 2>&1; then
success "Xcode build successful."
else
error "Xcode build failed. Check build/ios/build.log"
exit 1
clean build > "$BUILD_DIR/ios/build.log" 2>&1
then
error "iOS build failed. Check: $BUILD_DIR/ios/build.log"; exit 1
fi
substep "Packaging IPA for TrollStore..."
mkdir -p build/ios/Payload
APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then
cp -r "$APP_PATH" build/ios/Payload/
substep "Packaging to ${CYAN}.ipa${NC}..."
APP_BUNDLE_PATH=$(find "$BUILD_DIR/ios/Build/Products/Release-iphoneos" -name "*.app" -type d | head -n 1)
if [[ -n "$APP_BUNDLE_PATH" ]]; then
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null
rm -rf build/ios/Payload
IOS_ASSET="releases/$IPA_NAME"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
IPA_PATH="$RELEASES_DIR/$IPA_NAME"
# Create Payload structure inside build dir
PAYLOAD_STAGE="$BUILD_DIR/ios/ipa_stage"
rm -rf "$PAYLOAD_STAGE" && mkdir -p "$PAYLOAD_STAGE/Payload"
# Use cp -R to dereference symlinks and copy actual files into the stage
# This is safe because it's only the final bundle
cp -R "$APP_BUNDLE_PATH" "$PAYLOAD_STAGE/Payload/"
# Zip from the stage directory
(cd "$PAYLOAD_STAGE" && zip -r "$IPA_PATH" Payload > /dev/null 2>&1)
# Cleanup stage
rm -rf "$PAYLOAD_STAGE"
if [[ -f "$IPA_PATH" ]]; then
IOS_ASSET="$IPA_PATH"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else
error "IPA generation failed (file not found in releases)"
exit 1
fi
else
error "Could not find .app bundle"
exit 1
error "Could not find .app bundle"; exit 1
fi
git checkout -- "$PLIST_PATH" > /dev/null 2>&1 || true
}
# --- Подготовка описания ---
build_android
build_ios
# --- 4. Git Finalizing ---
IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
git add "$PLIST_PATH" > /dev/null 2>&1
if ! git diff --cached --quiet; then
if git commit --amend --no-edit > /dev/null 2>&1; then
substep "Info.plist updated (amend)."
else
error "Failed to amend commit."
fi
fi
fi
if [[ "$RECREATE_TAG" == true ]]; then
git tag -d "$TAG" > /dev/null 2>&1 || true
git push origin :refs/tags/"$TAG" > /dev/null 2>&1 || true
fi
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "Release $TAG" > /dev/null 2>&1
fi
git push origin "$CURRENT_BRANCH" --force-with-lease --tags > /dev/null 2>&1
# --- 5. GitHub Release ---
prepare_description() {
DESC_FILE=".release_desc.md"
echo -e "<!--\nEnter the release description. Leave empty for no description.\n-->" > "$DESC_FILE"
# Открываем nano. Пользователь должен отредактировать файл.
echo -e "<!-- Release Description -->" > "$DESC_FILE"
nano "$DESC_FILE"
# Удаляем комментарии <!-- ... --> и лишние пробелы/пустые строки
# Используем perl для многострочного удаления комментариев
CLEAN_DESC=$(perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
CLEAN_DESC=$(LC_ALL=C perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | LC_ALL=C sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
rm -f "$DESC_FILE"
if [[ -n "$CLEAN_DESC" ]]; then
echo "$CLEAN_DESC" > .final_notes.md
NOTES_ARG=("-F" "./.final_notes.md")
echo "$CLEAN_DESC" > "$DESC_FILE"
NOTES_ARG=("-F" "$DESC_FILE")
else
NOTES_ARG=("--generate-notes")
fi
}
# --- Публикация в GitHub ---
publish_github() {
step "GitHub Deployment"
if ! command -v gh &> /dev/null; then
warning "GitHub CLI (gh) not found. Skipping upload."
return
fi
substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true
prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease"
fi
step "GitHub Release"
if ! command -v gh &> /dev/null; then return; fi
ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
[ ${#ASSETS[@]} -eq 0 ] && return
if [ ${#ASSETS[@]} -eq 0 ]; then
error "No assets found to upload."
return
fi
# Подготавливаем описание перед созданием
prepare_description
if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag"
# Если есть кастомные ноты, обновляем их
if [[ -f .final_notes.md ]]; then
gh release edit "$TAG" -F ./.final_notes.md
fi
gh release upload "$TAG" "${ASSETS[@]}" --clobber
substep "Editing existing release..."
EDIT_ARGS=("--draft=false")
[[ "$IS_PRERELEASE" == true ]] && EDIT_ARGS+=("--prerelease") || EDIT_ARGS+=("--prerelease=false")
[[ -f "$DESC_FILE" ]] && EDIT_ARGS+=("-F" "$DESC_FILE")
gh release edit "$TAG" "${EDIT_ARGS[@]}" 1> /dev/null
substep "Uploading files..."
gh release upload "$TAG" "${ASSETS[@]}" --clobber 1> /dev/null
else
substep "Creating new release ${YELLOW}$TAG${NC}..."
# gh release create принимает либо --generate-notes, либо --notes-file
gh release create "$TAG" \
"${NOTES_ARG[@]}" \
--notes-start-tag "${PREVTAG:-$TAG}" \
$prerelease_flag \
"${ASSETS[@]}"
substep "Creating the release..."
PR_FLAG=""
[[ "$IS_PRERELEASE" == true ]] && PR_FLAG="--prerelease"
gh release create "$TAG" "${NOTES_ARG[@]}" $PR_FLAG --draft=false "${ASSETS[@]}" 1> /dev/null
fi
rm -f .final_notes.md
success "Assets and notes uploaded to GitHub Release"
rm -f "$DESC_FILE"
success "Success!"
}
# --- Основной процесс ---
mkdir -p releases
build_android
build_ios
publish_github
restore_git_state
trap - EXIT INT TERM
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
+11 -1
View File
@@ -40,6 +40,7 @@ kotlin {
implementation(compose.materialIconsExtended)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.navigation.compose)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
@@ -49,10 +50,19 @@ kotlin {
implementation(libs.biometric)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.security.crypto)
}
iosMain.dependencies {
// nothing
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.darwin)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.kotlinx.datetime)
// Multiplatform settings for iOS Keychain support
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.multiplatform.settings.serialization)
}
}
}
@@ -0,0 +1,83 @@
package com.pr0gramm3r101.utils.settings
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.pr0gramm3r101.utils.UtilsLibrary.context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidSecureSettings : Settings {
private val masterKey by lazy {
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val encryptedPrefs by lazy {
EncryptedSharedPreferences.create(
context,
"secure_storage",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
override suspend fun putString(key: String, value: String) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putString(key, value) }
}
override suspend fun getString(key: String, default: String) = withContext(Dispatchers.IO) {
encryptedPrefs.getString(key, default) ?: default
}
override suspend fun putInt(key: String, value: Int) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putInt(key, value) }
}
override suspend fun getInt(key: String, default: Int) = withContext(Dispatchers.IO) {
encryptedPrefs.getInt(key, default)
}
override suspend fun putLong(key: String, value: Long) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putLong(key, value) }
}
override suspend fun getLong(key: String, default: Long) = withContext(Dispatchers.IO) {
encryptedPrefs.getLong(key, default)
}
override suspend fun putFloat(key: String, value: Float) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putFloat(key, value) }
}
override suspend fun getFloat(key: String, default: Float) = withContext(Dispatchers.IO) {
encryptedPrefs.getFloat(key, default)
}
override suspend fun putBoolean(key: String, value: Boolean) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putBoolean(key, value) }
}
override suspend fun getBoolean(key: String, default: Boolean) = withContext(Dispatchers.IO) {
encryptedPrefs.getBoolean(key, default)
}
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { putStringSet(key, value) }
}
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
encryptedPrefs.getStringSet(key, default) ?: default
}
override suspend fun remove(key: String) = withContext(Dispatchers.IO) {
encryptedPrefs.edit { remove(key) }
}
override suspend fun contains(key: String) = withContext(Dispatchers.IO) {
encryptedPrefs.contains(key)
}
}
@@ -79,10 +79,6 @@ class AndroidSettings(): Settings {
preferences[stringSetPreferencesKey(key)] ?: default
}.first()
override suspend fun putStringList(key: String, value: List<String>) = putStringSet(key, value.toSet())
override suspend fun getStringList(key: String, default: List<String>) = getStringSet(key, default.toSet()).toList()
override suspend fun remove(key: String) {
dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key))
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
object DataStoreSingleton {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val dataStore: DataStore<Preferences>
get() = UtilsLibrary.context.dataStore
val dataStore get() = UtilsLibrary.context.dataStore
}
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = AndroidSettings()
actual val secureSettings: Settings
get() = TODO("Not yet implemented")
get() = AndroidSecureSettings()
@@ -2,6 +2,10 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
interface Settings {
companion object {
inline fun get() = settings
@@ -26,8 +30,23 @@ interface Settings {
suspend fun putStringSet(key: String, value: Set<String>)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
suspend fun putStringList(key: String, value: List<String>)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun putStringList(key: String, value: List<String>) = withContext(Dispatchers.Default) {
putString(key, Json.encodeToString(value))
}
suspend fun getStringList(key: String, default: List<String> = emptyList()) = withContext(Dispatchers.Default) {
try {
getString(key, "").let {
if (it.isEmpty()) {
default
} else {
Json.decodeFromString<List<String>>(it)
}
}
} catch (_: Exception) {
default
}
}
suspend fun remove(key: String)
@@ -0,0 +1,46 @@
package com.pr0gramm3r101.utils.settings
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.KeychainSettings
import com.russhwolf.settings.coroutines.toSuspendSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class)
class IosSecureSettings : Settings {
private val suspendSettings = KeychainSettings(service = "ru.fromchat.secure").toSuspendSettings()
override suspend fun putString(key: String, value: String) = suspendSettings.putString(key, value)
override suspend fun getString(key: String, default: String) = suspendSettings.getString(key, default)
override suspend fun putInt(key: String, value: Int) = suspendSettings.putInt(key, value)
override suspend fun getInt(key: String, default: Int) = suspendSettings.getInt(key, default)
override suspend fun putLong(key: String, value: Long) = suspendSettings.putLong(key, value)
override suspend fun getLong(key: String, default: Long) = suspendSettings.getLong(key, default)
override suspend fun putFloat(key: String, value: Float) = suspendSettings.putFloat(key, value)
override suspend fun getFloat(key: String, default: Float) = suspendSettings.getFloat(key, default)
override suspend fun putBoolean(key: String, value: Boolean) = suspendSettings.putBoolean(key, value)
override suspend fun getBoolean(key: String, default: Boolean) = suspendSettings.getBoolean(key, default)
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
putStringList(key, value.toList())
}
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
getStringList(key, default.toList()).toSet()
}
override suspend fun remove(key: String) = suspendSettings.remove(key)
override suspend fun contains(key: String) = suspendSettings.hasKey(key)
}
@@ -1,10 +1,10 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults
// TODO fix
class IosSettings : Settings {
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
@@ -45,7 +45,7 @@ class IosSettings : Settings {
override suspend fun remove(key: String) = defaults.removeObjectForKey(key)
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.IO) {
defaults.objectForKey(key) != null
}
}
@@ -2,4 +2,4 @@ package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = IosSettings()
actual val secureSettings: Settings
get() = TODO("Not yet implemented")
get() = IosSecureSettings()