2 Commits

Author SHA1 Message Date
Sebastian Molenda 6c4b5f4adf 0.2.3
Deploy to FTP / deploy (push) Successful in 5s
Build APK / build (push) Successful in 1m58s
2026-05-27 14:57:44 +02:00
Sebastian Molenda b8b7b3860b 0.2.2
Deploy to FTP / deploy (push) Successful in 16s
Build APK / build (push) Successful in 2m8s
2026-05-27 14:21:02 +02:00
81 changed files with 253 additions and 95 deletions
-6
View File
@@ -35,9 +35,3 @@ jobs:
with:
name: app-release
path: app/build/outputs/apk/release/app-release.apk
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/apk/release/app-release.apk
+68
View File
@@ -0,0 +1,68 @@
name: Release APK on Tag
on:
push:
tags:
- '*'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build release APK
run: ./gradlew :app:assembleRelease --no-daemon --stacktrace
- name: Find APK
id: find_apk
run: |
set -e
APK=$(ls app/build/outputs/apk/release/*.apk | head -n1 || true)
if [ -z "$APK" ]; then
echo "No APK found in app/build/outputs/apk/release"
exit 1
fi
echo "APK_PATH=$APK" >> $GITHUB_ENV
echo "apk=$APK"
- name: Upload artifact (for debugging)
uses: actions/upload-artifact@v4
with:
name: app-release-apk
path: ${{ env.APK_PATH }}
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
body: Automated release for tag ${{ github.ref_name }}
draft: false
prerelease: false
- name: Upload APK to Release
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ env.APK_PATH }}
asset_name: app-release.apk
asset_content_type: application/vnd.android.package-archive
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -8
View File
@@ -4,14 +4,7 @@
<option name="autoReloadType" value="NONE" />
</component>
<component name="ChangeListManager">
<list default="true" id="51538617-7e5b-4e71-9f47-7bda274cf4cc" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.gradle/8.5/checksums/checksums.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/8.5/checksums/checksums.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gradle/8.5/fileHashes/fileHashes.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/8.5/fileHashes/fileHashes.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gradle/buildOutputCleanup/buildOutputCleanup.lock" beforeDir="false" afterPath="$PROJECT_DIR$/.gradle/buildOutputCleanup/buildOutputCleanup.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/MainActivity.kt" beforeDir="false" afterPath="$PROJECT_DIR$/MainActivity.kt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/build.gradle" beforeDir="false" afterPath="$PROJECT_DIR$/app/build.gradle" afterDir="false" />
</list>
<list default="true" id="51538617-7e5b-4e71-9f47-7bda274cf4cc" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
+30
View File
@@ -13,3 +13,33 @@ Pliki
Jak uruchomić
Otwórz plik `index.html` w przeglądarce (najlepiej na urządzeniu mobilnym lub w trybie responsywnym).
Signing a release APK/AAB
------------------------
Aby uniknąć ostrzeżeń "nieznany deweloper" lub instalatora na Androidzie, zbuduj podpisany pakiet APK lub AAB i zainstaluj go zamiast debugowego APK.
1. Utwórz magazyn kluczy (jeśli go nie masz):
```bash
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my_key_alias
```
2. Skopiuj `gradle.properties.example` do swojego osobistego pliku `~/.gradle/gradle.properties` i uzupełnij wartości (nie commituj prawdziwych haseł do kontroli wersji).
3. Zbuduj podpisany pakiet APK lub AAB:
```bash
./gradlew assembleRelease # podpisany APK (jeśli podpisywanie jest skonfigurowane)
./gradlew bundleRelease # AAB do Sklepu Play
```
4. Zainstaluj APK za pomocą adb:
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
Lub przesłać AAB do Google Play (testowanie wewnętrzne) — zalecane w celu najłatwiejszej dystrybucji.
Jeśli chcesz tylko przetestować lokalnie i zobaczyć ostrzeżenie "Nieznane źródła", możesz tymczasowo włączyć instalowanie z nieznanych źródeł na urządzeniu, ale dystrybucja podpisanego wydania jest bezpieczniejszym podejściem.
+29
View File
@@ -14,10 +14,39 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// Signing configuration for release builds.
// Configure the following properties in either your
// - ~/.gradle/gradle.properties (recommended for secrets), or
// - <project root>/gradle.properties (do NOT commit passwords)
//
// Example properties:
// MYAPP_STORE_FILE=keystores/my-release-key.jks
// MYAPP_STORE_PASSWORD=your_store_password
// MYAPP_KEY_ALIAS=my_key_alias
// MYAPP_KEY_PASSWORD=your_key_password
signingConfigs {
release {
// Only configure signing when the properties are provided.
if (project.hasProperty('MYAPP_STORE_FILE')) {
storeFile file(MYAPP_STORE_FILE)
storePassword MYAPP_STORE_PASSWORD
keyAlias MYAPP_KEY_ALIAS
keyPassword MYAPP_KEY_PASSWORD
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// Apply signing config if available. This keeps debug and CI builds
// working even when signing properties are absent locally.
if (project.hasProperty('MYAPP_STORE_FILE')) {
signingConfig signingConfigs.release
}
// Ensure release builds are not debuggable.
debuggable false
}
}
+23 -23
View File
@@ -55,19 +55,19 @@ const statusEl = document.getElementById('status');
// load settings from localStorage
function loadSettings(){
try{
let progressInner = null
const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem('matma:settings') : null
if (raw) {
const s = JSON.parse(raw)
state.settings = Object.assign(state.settings, s)
}
}catch(e){ console.warn('settings load failed', e) }
// reflect to inputs
settingTimed.value = state.settings.timedSeconds
settingMaxResult.value = state.settings.maxResult
settingMaxOperand.value = state.settings.maxOperand
settingSessionProblems.value = state.settings.sessionProblems
settingAllowNegative.checked = !!state.settings.allowNegative
settingAllowFraction.checked = !!state.settings.allowFraction
// reflect to inputs (guard in case some inputs are missing)
if (settingTimed) settingTimed.value = state.settings.timedSeconds
if (settingMaxResult) settingMaxResult.value = state.settings.maxResult
if (settingMaxOperand) settingMaxOperand.value = state.settings.maxOperand
if (settingSessionProblems) settingSessionProblems.value = state.settings.sessionProblems
if (settingAllowNegative) settingAllowNegative.checked = !!state.settings.allowNegative
if (settingAllowFraction) settingAllowFraction.checked = !!state.settings.allowFraction
}
function saveSettings(){
@@ -145,16 +145,16 @@ function startPlay(){
renderProblem()
if (state.mode === 'timed'){
progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds)
statusEl.textContent = 'Na czas';
timerEl.classList.remove('hidden');
if (progressInner) progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds)
if (statusEl) statusEl.textContent = 'Na czas';
if (timerEl) timerEl.classList.remove('hidden');
} else {
progressInner.style.width = '0%'
state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems)
updateProgress()
timerEl.classList.add('hidden');
if (progressInner) progressInner.style.width = '0%'
state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems)
updateProgress()
if (timerEl) timerEl.classList.add('hidden');
}
}
@@ -311,14 +311,14 @@ backspaceBtn.addEventListener('click', ()=>{
function startTimer(seconds){
state.timeLeft = seconds
timerEl.textContent = state.timeLeft
if (timerEl) timerEl.textContent = state.timeLeft
stopTimer();
state.timerId = setInterval(()=>{
state.timeLeft -= 1
timerEl.textContent = state.timeLeft
if (timerEl) timerEl.textContent = state.timeLeft
const pct = Math.max(0, (state.timeLeft / seconds) * 100)
progressInner.style.width = pct + '%'
if (progressInner) progressInner.style.width = pct + '%'
if (state.timeLeft <= 0) {
stopTimer()
endSession()
@@ -357,9 +357,9 @@ summaryBack.addEventListener('click', ()=>{
function updateProgress(){
if (state.mode === 'training'){
const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100))
progressInner.style.width = pct + '%'
statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}`
const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100))
if (progressInner) progressInner.style.width = pct + '%'
if (statusEl) statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}`
}
}
@@ -1,7 +1,20 @@
// Komponenty HTML loader
function loadComponent(path, replaceMap = {}) {
// If path is a full mapped origin used by WebViewAssetLoader, strip it to a relative path.
const mappedOrigin = 'https://appassets.androidplatform.net/assets/';
if (path.startsWith(mappedOrigin)) {
path = path.slice(mappedOrigin.length);
}
// If an absolute path starting with '/' is provided, remove leading slash to make it
// relative to the current document (works under local http server and WebViewAssetLoader).
if (path.startsWith('/')) path = path.slice(1);
return fetch(path)
.then(r => r.text())
.then(r => {
if (!r.ok) throw new Error('Failed to load component: ' + path);
return r.text();
})
.then(html => {
Object.entries(replaceMap).forEach(([key, val]) => {
html = html.replaceAll(key, val);
@@ -83,9 +83,8 @@
}
// ── Back to list ──────────────────────────────────────────────────────────
// Zakończ button: natychmiast wróć do listy bez potwierdzenia
readBackBtn.addEventListener('click', () => {
const active = yOffset > 0 || autoTimer !== null
if (active && !confirm('Wrócić do listy tekstów?')) return
clearInterval(autoTimer)
autoTimer = null
readWrap.classList.add('hidden')
@@ -296,9 +296,8 @@
}
// ── Navigation ────────────────────────────────────────────────────────────
// Zakończ button: natychmiast wracamy do listy bez potwierdzenia
playBackBtn.addEventListener('click', () => {
const inProgress = blanks.length > 0 && current < blanks.length
if (inProgress && !confirm('Przerwać dyktando i wrócić do listy?')) return
goToList()
})
@@ -136,7 +136,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) {
try {
const res = await fetch('/version.sha', { cache: 'no-cache' })
// use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) {
const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
@@ -136,7 +136,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) {
try {
const res = await fetch('/version.sha', { cache: 'no-cache' })
// use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) {
const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
@@ -4,14 +4,10 @@
const backBtn = document.getElementById('back-to-hub')
if (!backBtn) return
// Immediately navigate back to hub/menu without confirmation
backBtn.addEventListener('click', (e) => {
const playScreen = document.getElementById('play-screen')
const taskActive = playScreen && !playScreen.classList.contains('hidden')
if (taskActive) {
e.preventDefault()
const ok = confirm('Masz aktywne zadanie. Czy na pewno chcesz wyjść do menu?')
if (ok) window.location.href = backBtn.getAttribute('href')
}
const href = backBtn.getAttribute('href')
if (href) window.location.href = href
})
})
})()
@@ -1,4 +1,4 @@
#Wed May 27 14:04:13 CEST 2026
#Wed May 27 14:53:22 CEST 2026
base.2=/Users/aln/Work/Matma/app/build/intermediates/dex/debug/mergeProjectDexDebug/6/classes.dex
path.2=6/classes.dex
base.1=/Users/aln/Work/Matma/app/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex
@@ -1,2 +1,2 @@
3
4
0
Binary file not shown.
+23 -23
View File
@@ -55,19 +55,19 @@ const statusEl = document.getElementById('status');
// load settings from localStorage
function loadSettings(){
try{
let progressInner = null
const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem('matma:settings') : null
if (raw) {
const s = JSON.parse(raw)
state.settings = Object.assign(state.settings, s)
}
}catch(e){ console.warn('settings load failed', e) }
// reflect to inputs
settingTimed.value = state.settings.timedSeconds
settingMaxResult.value = state.settings.maxResult
settingMaxOperand.value = state.settings.maxOperand
settingSessionProblems.value = state.settings.sessionProblems
settingAllowNegative.checked = !!state.settings.allowNegative
settingAllowFraction.checked = !!state.settings.allowFraction
// reflect to inputs (guard in case some inputs are missing)
if (settingTimed) settingTimed.value = state.settings.timedSeconds
if (settingMaxResult) settingMaxResult.value = state.settings.maxResult
if (settingMaxOperand) settingMaxOperand.value = state.settings.maxOperand
if (settingSessionProblems) settingSessionProblems.value = state.settings.sessionProblems
if (settingAllowNegative) settingAllowNegative.checked = !!state.settings.allowNegative
if (settingAllowFraction) settingAllowFraction.checked = !!state.settings.allowFraction
}
function saveSettings(){
@@ -145,16 +145,16 @@ function startPlay(){
renderProblem()
if (state.mode === 'timed'){
progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds)
statusEl.textContent = 'Na czas';
timerEl.classList.remove('hidden');
if (progressInner) progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds)
if (statusEl) statusEl.textContent = 'Na czas';
if (timerEl) timerEl.classList.remove('hidden');
} else {
progressInner.style.width = '0%'
state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems)
updateProgress()
timerEl.classList.add('hidden');
if (progressInner) progressInner.style.width = '0%'
state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems)
updateProgress()
if (timerEl) timerEl.classList.add('hidden');
}
}
@@ -311,14 +311,14 @@ backspaceBtn.addEventListener('click', ()=>{
function startTimer(seconds){
state.timeLeft = seconds
timerEl.textContent = state.timeLeft
if (timerEl) timerEl.textContent = state.timeLeft
stopTimer();
state.timerId = setInterval(()=>{
state.timeLeft -= 1
timerEl.textContent = state.timeLeft
if (timerEl) timerEl.textContent = state.timeLeft
const pct = Math.max(0, (state.timeLeft / seconds) * 100)
progressInner.style.width = pct + '%'
if (progressInner) progressInner.style.width = pct + '%'
if (state.timeLeft <= 0) {
stopTimer()
endSession()
@@ -357,9 +357,9 @@ summaryBack.addEventListener('click', ()=>{
function updateProgress(){
if (state.mode === 'training'){
const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100))
progressInner.style.width = pct + '%'
statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}`
const pct = Math.min(100, Math.round((state.sessionSolved/state.sessionTarget)*100))
if (progressInner) progressInner.style.width = pct + '%'
if (statusEl) statusEl.textContent = `${state.sessionSolved}/${state.sessionTarget}`
}
}
+14 -1
View File
@@ -1,7 +1,20 @@
// Komponenty HTML loader
function loadComponent(path, replaceMap = {}) {
// If path is a full mapped origin used by WebViewAssetLoader, strip it to a relative path.
const mappedOrigin = 'https://appassets.androidplatform.net/assets/';
if (path.startsWith(mappedOrigin)) {
path = path.slice(mappedOrigin.length);
}
// If an absolute path starting with '/' is provided, remove leading slash to make it
// relative to the current document (works under local http server and WebViewAssetLoader).
if (path.startsWith('/')) path = path.slice(1);
return fetch(path)
.then(r => r.text())
.then(r => {
if (!r.ok) throw new Error('Failed to load component: ' + path);
return r.text();
})
.then(html => {
Object.entries(replaceMap).forEach(([key, val]) => {
html = html.replaceAll(key, val);
+1 -2
View File
@@ -83,9 +83,8 @@
}
// ── Back to list ──────────────────────────────────────────────────────────
// Zakończ button: natychmiast wróć do listy bez potwierdzenia
readBackBtn.addEventListener('click', () => {
const active = yOffset > 0 || autoTimer !== null
if (active && !confirm('Wrócić do listy tekstów?')) return
clearInterval(autoTimer)
autoTimer = null
readWrap.classList.add('hidden')
+1 -2
View File
@@ -296,9 +296,8 @@
}
// ── Navigation ────────────────────────────────────────────────────────────
// Zakończ button: natychmiast wracamy do listy bez potwierdzenia
playBackBtn.addEventListener('click', () => {
const inProgress = blanks.length > 0 && current < blanks.length
if (inProgress && !confirm('Przerwać dyktando i wrócić do listy?')) return
goToList()
})
+2 -1
View File
@@ -136,7 +136,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) {
try {
const res = await fetch('/version.sha', { cache: 'no-cache' })
// use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) {
const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
+2 -1
View File
@@ -136,7 +136,8 @@
let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) {
try {
const res = await fetch('/version.sha', { cache: 'no-cache' })
// use relative path so the request works under WebViewAssetLoader or a local server
const res = await fetch('version.sha', { cache: 'no-cache' })
if (res.ok) {
const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
+3 -7
View File
@@ -4,14 +4,10 @@
const backBtn = document.getElementById('back-to-hub')
if (!backBtn) return
// Immediately navigate back to hub/menu without confirmation
backBtn.addEventListener('click', (e) => {
const playScreen = document.getElementById('play-screen')
const taskActive = playScreen && !playScreen.classList.contains('hidden')
if (taskActive) {
e.preventDefault()
const ok = confirm('Masz aktywne zadanie. Czy na pewno chcesz wyjść do menu?')
if (ok) window.location.href = backBtn.getAttribute('href')
}
const href = backBtn.getAttribute('href')
if (href) window.location.href = href
})
})
})()
@@ -1,9 +1,12 @@
package com.example.app
import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import androidx.webkit.WebViewAssetLoader
class MainActivity : AppCompatActivity() {
@@ -11,16 +14,27 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
val webView = WebView(this)
// Enable remote debugging for WebView so we can inspect JS console via chrome://inspect
WebView.setWebContentsDebuggingEnabled(true)
// Enable remote debugging for WebView so we can inspect JS console via chrome://inspect
WebView.setWebContentsDebuggingEnabled(true)
webView.settings.javaScriptEnabled = true
webView.settings.allowFileAccess = true
webView.settings.domStorageEnabled = true
webView.webViewClient = WebViewClient()
// Use WebViewAssetLoader to serve files from /assets/ over a secure origin.
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this))
.build()
webView.loadUrl("file:///android_asset/index.html")
webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (request == null) return null
return assetLoader.shouldInterceptRequest(request.url)
}
}
// Load the app via the mapped secure origin so fetch() requests are allowed
webView.loadUrl("https://appassets.androidplatform.net/assets/index.html")
setContentView(webView)
}
+12
View File
@@ -0,0 +1,12 @@
# Example properties for signing your release APK/AAB.
# DO NOT commit real passwords. Copy this to gradle.properties in your home
# directory (~/.gradle/gradle.properties) or to a secure CI secret store.
# Path to your keystore file (relative to project root or absolute path)
MYAPP_STORE_FILE=keystores/my-release-key.jks
# Your keystore password
MYAPP_STORE_PASSWORD=
# Your key alias inside the keystore
MYAPP_KEY_ALIAS=
# Your key password (often same as store password)
MYAPP_KEY_PASSWORD=