diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d334ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Swoosh is an Android microblogging client for Ghost CMS. It provides offline-capable post creation, image uploads, link previews, and scheduled publishing via Ghost's Admin API. + +## Build & Test Commands + +```bash +./gradlew assembleDebug # Build debug APK +./gradlew assembleRelease # Build release APK (ProGuard enabled) +./gradlew test # Run all unit tests +./gradlew app:testDebugUnitTest # Run unit tests for debug variant +./gradlew test --tests "*.MobiledocBuilderTest" # Run a single test class +./gradlew test --tests "*.MobiledocBuilderTest.testBasicTextPost" # Run a single test method +``` + +Robolectric is used for tests that need Android framework classes. Unit tests include Android resources (`unitTests.isIncludeAndroidResources = true`). + +## Architecture + +MVVM with Repository pattern, single-module Gradle project. + +**Package: `com.swoosh.microblog`** + +- **`data/api/`** — Retrofit service (`GhostApiService`), JWT auth (`GhostJwtGenerator`, `GhostAuthInterceptor`), and `ApiClient` singleton with dynamic base URL +- **`data/db/`** — Room database with `LocalPost` entity and `LocalPostDao` +- **`data/model/`** — Three model layers: `GhostPost` (API), `LocalPost` (Room entity), `FeedPost` (UI display). Enums: `PostStatus`, `QueueStatus` +- **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup +- **`ui/`** — Jetpack Compose screens (Feed, Composer, Detail, Setup, Settings) with ViewModels using `StateFlow` +- **`worker/`** — `PostUploadWorker` (WorkManager) handles offline queue with exponential backoff + +**Key data flow:** Posts are saved to Room first → queued for upload → `PostUploadWorker` syncs to Ghost API when network is available. + +## Technical Details + +- **Auth:** Ghost Admin API keys are split into `id:secret`, secret is hex-decoded for HS256 JWT signing (5-min expiry) +- **Content format:** Posts use Ghost's mobiledoc JSON format, built by `MobiledocBuilder` (supports text paragraphs and bookmark cards) +- **Credentials:** Stored in `EncryptedSharedPreferences` (AES256-GCM) via `CredentialsManager` +- **API client:** Base URL is configured at runtime during setup; `ApiClient` rebuilds Retrofit instance when URL changes +- **Min SDK 26, Target/Compile SDK 34, Kotlin 1.9.22, Java 17** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4203a51..dfc34e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,7 +59,7 @@ android { dependencies { // Compose BOM - val composeBom = platform("androidx.compose:compose-bom:2024.01.00") + val composeBom = platform("androidx.compose:compose-bom:2024.02.00") implementation(composeBom) implementation("androidx.core:core-ktx:1.12.0") @@ -74,6 +74,7 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.material:material") // Navigation implementation("androidx.navigation:navigation-compose:2.7.6") diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index b51c641..9952c7c 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -5,15 +5,22 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -22,7 +29,7 @@ import coil.compose.AsyncImage import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.QueueStatus -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun FeedScreen( onSettingsClick: () -> Unit, @@ -33,6 +40,12 @@ fun FeedScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() + // Pull-to-refresh + val pullRefreshState = rememberPullRefreshState( + refreshing = state.isRefreshing, + onRefresh = { viewModel.refresh() } + ) + // Infinite scroll trigger val shouldLoadMore by remember { derivedStateOf { @@ -52,6 +65,9 @@ fun FeedScreen( TopAppBar( title = { Text("Swoosh") }, actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } IconButton(onClick = onSettingsClick) { Icon(Icons.Default.Settings, contentDescription = "Settings") } @@ -64,31 +80,68 @@ fun FeedScreen( } } ) { padding -> - Box(modifier = Modifier.fillMaxSize().padding(padding)) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .pullRefresh(pullRefreshState) + ) { if (state.posts.isEmpty() && !state.isRefreshing) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No posts yet", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Tap + to write your first post", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (state.isConnectionError && state.error != null) { + // Connection error empty state + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.error!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + FilledTonalButton(onClick = { viewModel.refresh() }) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Retry") + } + } + } else { + // Normal empty state + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No posts yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tap + to write your first post", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } - if (state.isRefreshing) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -114,10 +167,20 @@ fun FeedScreen( } } - if (state.error != null) { + PullRefreshIndicator( + refreshing = state.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + + // Show non-connection errors as snackbar (when posts are visible) + if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { Snackbar( modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), action = { + TextButton(onClick = { viewModel.refresh() }) { Text("Retry") } + }, + dismissAction = { TextButton(onClick = viewModel::clearError) { Text("Dismiss") } } ) { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 7d4a175..350de2c 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -7,10 +7,14 @@ import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.repository.PostRepository import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit +import javax.net.ssl.SSLException class FeedViewModel(application: Application) : AndroidViewModel(application) { @@ -41,7 +45,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } + _uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) } currentPage = 1 hasMorePages = true @@ -53,12 +57,32 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(isRefreshing = false) } }, onFailure = { e -> - _uiState.update { it.copy(isRefreshing = false, error = e.message) } + val (message, isConn) = classifyError(e) + _uiState.update { it.copy(isRefreshing = false, error = message, isConnectionError = isConn) } } ) } } + private fun classifyError(e: Throwable): Pair { + val cause = e.cause ?: e + return when { + cause is UnknownHostException || cause is ConnectException || + cause is SocketTimeoutException || cause is SSLException || + e is UnknownHostException || e is ConnectException || + e is SocketTimeoutException || e is SSLException -> + "Could not connect to your Ghost blog. Check your internet connection and try again." to true + + e.message?.contains("401") == true || e.message?.contains("403") == true -> + "Authentication failed. Your API key may be invalid. Check Settings \u2192 Integrations in your Ghost admin." to true + + e.message?.contains("404") == true -> + "Ghost API not found at this URL. Verify your blog address in Settings." to true + + else -> (e.message ?: "An unknown error occurred") to false + } + } + fun loadMore() { if (!hasMorePages || _uiState.value.isLoadingMore) return @@ -155,7 +179,8 @@ data class FeedUiState( val posts: List = emptyList(), val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, - val error: String? = null + val error: String? = null, + val isConnectionError: Boolean = false ) fun formatRelativeTime(isoString: String?): String { diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt index ad2c270..eb216d9 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt @@ -1,13 +1,35 @@ package com.swoosh.microblog.ui.setup +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -19,89 +41,287 @@ fun SetupScreen( viewModel: SetupViewModel = viewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current LaunchedEffect(state.isSuccess) { if (state.isSuccess) onSetupComplete() } - Scaffold( - topBar = { - TopAppBar(title = { Text("Setup Ghost Instance") }) + val primaryColor = MaterialTheme.colorScheme.primary + val tertiaryColor = MaterialTheme.colorScheme.tertiary + val secondaryColor = MaterialTheme.colorScheme.secondary + + // Determine if the URL looks valid enough to show the hint + val urlLooksValid = remember(state.url) { + val trimmed = state.url.trim() + trimmed.isNotEmpty() && ( + trimmed.startsWith("http://") || + trimmed.startsWith("https://") || + trimmed.contains(".") + ) + } + + // Build the normalized integrations URL from the user's input + val integrationsUrl = remember(state.url) { + val trimmed = state.url.trim().trimEnd('/') + val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" } - ) { padding -> - Column( + "$withScheme/ghost/#/settings/integrations" + } + + Scaffold { padding -> + Box( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "Swoosh", - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.primary + // Full-screen animated background behind all UI + PulsingCirclesBackground( + primaryColor = primaryColor, + tertiaryColor = tertiaryColor, + secondaryColor = secondaryColor, + modifier = Modifier.fillMaxSize() ) - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Connect to your Ghost instance", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(32.dp)) - - OutlinedTextField( - value = state.url, - onValueChange = viewModel::updateUrl, - label = { Text("Ghost Instance URL") }, - placeholder = { Text("https://your-blog.ghost.io") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = state.apiKey, - onValueChange = viewModel::updateApiKey, - label = { Text("Admin API Key") }, - placeholder = { Text("key_id:secret") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - - if (state.error != null) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = state.error!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Button( - onClick = viewModel::save, - enabled = !state.isTesting, - modifier = Modifier.fillMaxWidth() + // Content layered on top + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { - if (state.isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Testing connection...") - } else { - Text("Connect") + // Top area with branding + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Swoosh", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Connect to your Ghost instance", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } + + // Bottom area with inputs and button + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = state.url, + onValueChange = viewModel::updateUrl, + label = { Text("Ghost Instance URL") }, + placeholder = { Text("https://your-blog.ghost.io") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.apiKey, + onValueChange = viewModel::updateApiKey, + label = { Text("Admin API Key") }, + placeholder = { Text("key_id:secret") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + + // API key hint with link to Ghost integrations page + AnimatedVisibility( + visible = urlLooksValid, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(4.dp)) + + val linkColor = MaterialTheme.colorScheme.primary + val annotatedText = buildAnnotatedString { + withStyle( + SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) { + append("Find your API key at ") + } + pushStringAnnotation(tag = "URL", annotation = integrationsUrl) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline + ) + ) { + append("Settings \u2192 Integrations") + } + pop() + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.clickable { + uriHandler.openUri(integrationsUrl) + } + ) + } + } + + if (state.error != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = state.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::save, + enabled = !state.isTesting, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Testing connection...") + } else { + Text("Connect") + } + } + } } } } } + +@Composable +private fun PulsingCirclesBackground( + primaryColor: Color, + tertiaryColor: Color, + secondaryColor: Color, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + + val scale1 by infiniteTransition.animateFloat( + initialValue = 0.6f, targetValue = 1.4f, + animationSpec = infiniteRepeatable( + animation = tween(4000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "scale1" + ) + val alpha1 by infiniteTransition.animateFloat( + initialValue = 0.25f, targetValue = 0.55f, + animationSpec = infiniteRepeatable( + animation = tween(4000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "alpha1" + ) + + val scale2 by infiniteTransition.animateFloat( + initialValue = 1.3f, targetValue = 0.5f, + animationSpec = infiniteRepeatable( + animation = tween(5500, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "scale2" + ) + val alpha2 by infiniteTransition.animateFloat( + initialValue = 0.20f, targetValue = 0.50f, + animationSpec = infiniteRepeatable( + animation = tween(5500, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "alpha2" + ) + + val scale3 by infiniteTransition.animateFloat( + initialValue = 0.7f, targetValue = 1.5f, + animationSpec = infiniteRepeatable( + animation = tween(6500, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "scale3" + ) + val alpha3 by infiniteTransition.animateFloat( + initialValue = 0.18f, targetValue = 0.45f, + animationSpec = infiniteRepeatable( + animation = tween(6500, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "alpha3" + ) + + Canvas( + modifier = modifier.blur(40.dp) + ) { + val w = size.width + val h = size.height + + // Circle 1 — primary, upper-left + val radius1 = w * 0.50f * scale1 + drawCircle( + brush = Brush.radialGradient( + colors = listOf(primaryColor.copy(alpha = alpha1), Color.Transparent), + center = Offset(w * 0.3f, h * 0.35f), + radius = radius1 + ), + radius = radius1, + center = Offset(w * 0.3f, h * 0.35f) + ) + + // Circle 2 — tertiary, right + val radius2 = w * 0.55f * scale2 + drawCircle( + brush = Brush.radialGradient( + colors = listOf(tertiaryColor.copy(alpha = alpha2), Color.Transparent), + center = Offset(w * 0.75f, h * 0.45f), + radius = radius2 + ), + radius = radius2, + center = Offset(w * 0.75f, h * 0.45f) + ) + + // Circle 3 — secondary, bottom-center + val radius3 = w * 0.45f * scale3 + drawCircle( + brush = Brush.radialGradient( + colors = listOf(secondaryColor.copy(alpha = alpha3), Color.Transparent), + center = Offset(w * 0.5f, h * 0.7f), + radius = radius3 + ), + radius = radius3, + center = Offset(w * 0.5f, h * 0.7f) + ) + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..60d916c --- /dev/null +++ b/gradlew @@ -0,0 +1,198 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced functionality shells and target +# temporary focusing of this script. +# +# (2) You need a Java installation to run Gradle. +# JAVA_HOME is not required but JAVA is. It needs to be on your PATH. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://issues.gradle.org/browse/GRADLE-2360. +# are listed first to match what the user would pass directly to java. +# There is no hierarchical dependency between $DEFAULT_JVM_OPTS and $JAVA_OPTS. +# $DEFAULT_JVM_OPTS is the result of the defaults defined in Gradle's tooling model. +# $JAVA_OPTS is the result of the user's own JVM opts. +# $GRADLE_OPTS is the result of the user's own Gradle opts. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # temporary args, so each arg winds up back in the position where + # it started, but possibly modified. + # + # NB: aass://vbs://assorted://arg://loop://with://set://for://shift://won't://get://you://a://correct://result. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# temporary variables so that they are re-expanded; and +# * put everything else in double quotes to style normal arguments (even if +# those are not GNU options, e.g. paths). +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xeli" would cause for bad results. +if ! "$cygwin" && ! "$msys" ; then + case $( set -- $$; readlink /proc/$1/exe 2>/dev/null || true; ) in #( + */busybox) set -- "$@" ;; #( + esac +fi + +exec "$JAVACMD" "$@"