Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,14 @@ Progetto originale:
- Thanks to: Veteran Unix Admins group on Facebook


Android App
=========

## mannaggia.apk
App Android sviluppata da Opus 4.6 extended partendo dallo script. Testata su Google Pixel 9 Pro e Android 16.
L'app prevede la possibilità di usare il text-to-speech di Google, di triggerare un "Mannaggia" scuotendo il telefono (con l'app aperta) e include un widget col quale interagire direttamente dalla home screen.

## mannaggia-android


Nella cartella sono disponibili i file per poter testare ed effettuare la build dell'app su Android SDK.
10 changes: 10 additions & 0 deletions mannaggia-android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
120 changes: 120 additions & 0 deletions mannaggia-android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Mannaggia — Android port

Android port of [`mannaggia.sh`](https://github.com/LegolasTheElf/mannaggia/blob/master/mannaggia.sh) —
the automatic saint-invoker for depressed Veteran Unix Admins.

Tap the button → the app scrapes **santiebeati.it**, picks a random saint,
and shows you `Mannaggia <Saint>!`. Flip the **Pronuncia** toggle to also
hear it spoken via Google Translate TTS in Italian.

## Ways to invoke a saint

- **In-app button** — tap `Mannaggia!` on the main screen.
- **Shake the phone** — sharp movement (>2.7 G) while the app is in the
foreground triggers a new invocation. 1.5 s cooldown prevents spam.
- **Quick Settings tile** — long-press the notification shade, edit tiles,
drag **Mannaggia** into your active tiles. One tap from anywhere in the
system fetches a saint and shows it as a notification.
- **Home screen widget** — long-press the home screen → **Widgets** →
find **Mannaggia** → drag it somewhere. Tap it to re-roll; the text
updates in place.

## What was ported

| bash | Kotlin/Android |
| --- | --- |
| `curl` | `HttpURLConnection` |
| `iconv -f ISO-8859-1` | `String(bytes, Charsets.ISO_8859_1)` |
| `awk -F'…'` | `line.split(Regex("…"))` |
| `shuf -n1` | `List<String>.random()` |
| `mplayer <tts url>` | `MediaPlayer` pointed at the same Google TTS URL |
| CLI flags (`--spm`, `--nds`, `--wall`, `--shutdown` …) | **Not ported** — they don't make sense on a phone |

## Requirements

- **Android Studio** Hedgehog (2023.1) or newer — easiest path
- OR: JDK 17 + Android command-line tools + a local `gradle` ≥ 8.5

Target: Android 7.0 (API 24) and up.

## Build — easy path (Android Studio)

1. Unzip the project.
2. **File → Open** → select the `mannaggia-android` folder.
3. Let Gradle sync (first time downloads ~500 MB of Android deps).
4. **Build → Build Bundle(s) / APK(s) → Build APK(s)**.
5. When done, click **locate** in the toast — you'll find
`app/build/outputs/apk/debug/app-debug.apk`.
6. Copy it to your phone and install (enable "Install unknown apps" first).

## Build — command-line path

From inside the project folder:

```sh
# one-time: generate the gradle wrapper (requires a system gradle ≥ 8.5)
gradle wrapper --gradle-version 8.5

# then every build:
./gradlew assembleDebug
# APK ends up at: app/build/outputs/apk/debug/app-debug.apk
```

For a signed release build:

```sh
./gradlew assembleRelease
# you'll need to configure a keystore in app/build.gradle.kts first
```

## Build — CI path (no local setup)

1. Create a new GitHub repo and push this folder.
2. Add `.github/workflows/build.yml`:

```yaml
name: Build APK
on: [push, workflow_dispatch]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '17' }
- uses: gradle/actions/setup-gradle@v3
- run: gradle wrapper --gradle-version 8.5
- run: ./gradlew assembleDebug
- uses: actions/upload-artifact@v4
with:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk
```

3. Push → go to **Actions** tab → download the APK artifact.

## Install on your phone

1. Transfer the APK to the device (USB, email, Drive, whatever).
2. On the phone, tap the APK file.
3. Android will ask you to allow "Install unknown apps" for that source
(Settings → Apps → Special access → Install unknown apps).
4. Done.

## Known caveats

- **Parsing fragility.** The script (and this port) parse
`santiebeati.it`'s HTML using very specific `<FONT>` tag patterns.
If the site ever redesigns, both the original script AND this app
break. There's a `"Sant'Anonimo"` fallback if nothing is found.
- **Cleartext traffic allowed** (`usesCleartextTraffic="true"`) in case
the site ever drops to HTTP. It currently serves over HTTPS.
- **Google TTS** is an unofficial, undocumented endpoint. If Google
changes it (again), audio will silently fail — text-only still works.
- Dropped CLI features: `--audio` (replaced by the in-app toggle),
`--spm` / `--nds` / `--wall` / `--shutdown` / `--off`
(nonsensical on a phone).

## License

Same as the upstream script: **GPL-3.0**.
49 changes: 49 additions & 0 deletions mannaggia-android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}

android {
namespace = "com.mannaggia.app"
compileSdk = 34

defaultConfig {
applicationId = "com.mannaggia.app"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
}

buildFeatures {
viewBinding = true
}
}

dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
1 change: 1 addition & 0 deletions mannaggia-android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
53 changes: 53 additions & 0 deletions mannaggia-android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.Mannaggia"
android:usesCleartextTraffic="true">

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- Quick Settings Tile -->
<service
android:name=".MannaggiaTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<!-- Home Screen Widget -->
<receiver
android:name=".MannaggiaWidgetProvider"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.mannaggia.app.ACTION_INVOKE_WIDGET" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/mannaggia_widget_info" />
</receiver>

</application>
</manifest>
127 changes: 127 additions & 0 deletions mannaggia-android/app/src/main/java/com/mannaggia/app/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.mannaggia.app

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.Sensor
import android.hardware.SensorManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.mannaggia.app.databinding.ActivityMainBinding
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.net.URLEncoder

/**
* Main screen: tap the button (or shake the phone) to invoke a saint.
*/
class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding
private var mediaPlayer: MediaPlayer? = null

private lateinit var sensorManager: SensorManager
private var accelerometer: Sensor? = null
private lateinit var shakeDetector: ShakeDetector
private var currentJob: Job? = null

private val requestNotifPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.btnMannaggia.setOnClickListener { invokeSaint() }

sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
shakeDetector = ShakeDetector { invokeSaint() }

askForNotificationPermissionIfNeeded()
}

override fun onResume() {
super.onResume()
accelerometer?.let {
sensorManager.registerListener(shakeDetector, it, SensorManager.SENSOR_DELAY_UI)
}
}

override fun onPause() {
super.onPause()
sensorManager.unregisterListener(shakeDetector)
}

private fun invokeSaint() {
// Ignore re-entrant triggers while an invocation is still in flight.
if (currentJob?.isActive == true) return

binding.progress.visibility = View.VISIBLE
binding.btnMannaggia.isEnabled = false
binding.txtResult.text = ""

currentJob = lifecycleScope.launch {
val result = runCatching { Mannaggia.fetchRandomSaint() }
result.fold(
onSuccess = { saint ->
val phrase = Mannaggia.phraseOf(saint)
binding.txtResult.text = phrase
if (binding.chkAudio.isChecked) speakWithGoogleTts(phrase)
},
onFailure = { e -> binding.txtResult.text = "Errore: ${e.message}" }
)
binding.progress.visibility = View.GONE
binding.btnMannaggia.isEnabled = true
}
}

// ---------------------------------------------------------------
// Google Translate TTS (same endpoint the bash `say()` uses)
// ---------------------------------------------------------------

private fun speakWithGoogleTts(text: String) {
releasePlayer()
val encoded = URLEncoder.encode(text, "UTF-8")
val url =
"https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&q=$encoded&tl=it"
try {
mediaPlayer = MediaPlayer().apply {
val headers = mapOf("User-Agent" to "Mozilla/5.0")
setDataSource(this@MainActivity, Uri.parse(url), headers)
setOnPreparedListener { start() }
setOnCompletionListener { releasePlayer() }
setOnErrorListener { _, _, _ -> releasePlayer(); true }
prepareAsync()
}
} catch (e: Exception) {
e.printStackTrace()
}
}

private fun releasePlayer() {
mediaPlayer?.release()
mediaPlayer = null
}

private fun askForNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
if (!granted) requestNotifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}

override fun onDestroy() {
releasePlayer()
super.onDestroy()
}
}
Loading