Initial commit

This commit is contained in:
weishaupt
2026-10-04 17:34:29 -04:00
commit 197214ae25
30 changed files with 2868 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Gradle
.gradle/
build/
**/build/
.kotlin/
# Android Studio / IntelliJ
.idea/
*.iml
*.iws
*.ipr
.navigation/
captures/
.externalNativeBuild/
.cxx/
# Local config (SDK path, etc.)
local.properties
# Build artifacts
*.apk
*.aab
*.ap_
*.dex
output-metadata.json
# Signing keys
*.jks
*.keystore
keystore.properties
# OS / misc
.DS_Store
Thumbs.db
*.log
+125
View File
@@ -0,0 +1,125 @@
# wIRC
A native Android IRC client: mIRC colors, timestamps, OP powers (kick/ban/op),
Ctrl+K/B/I formatting shortcuts, saved servers with on-connect identify
commands, and every channel *and* private message opening as its own
independently-styled window (1px black border, adjustable background
transparency, black/white text toggle).
## What's built so far (MVP)
- Raw socket IRC connection (`irc/IrcConnection.kt`) with auto-reconnect/backoff,
optional TLS, auto-join on connect.
- Full mIRC control-code parser (`irc/MircColorParser.kt`) — colors, bold,
underline, reverse, reset — rendered as a Compose `AnnotatedString`.
- Protocol line parser (`irc/IrcLineParser.kt`) for PRIVMSG/JOIN/PART/KICK/MODE/
TOPIC/NOTICE/QUIT/NAMES.
- `ConnectionManager` — app-wide singleton so every open window reads from the
same shared state (messages, user list) regardless of which window is active.
- `IrcConnectionService` — foreground service that keeps the socket(s) alive
when the app is backgrounded (Android kills background network connections
aggressively without this).
- `ChannelScreen` — the actual window UI: scrollback with mIRC colors + per-line
timestamps, input bar, long-press a nick for a Kick/Ban/Op/De-op menu, and a
settings panel (gear icon) with the transparency slider and text color toggle.
- `ChannelWindowActivity` — one instance per channel. Uses
`documentLaunchMode="always"` + `resizeableActivity="true"` in the manifest
and `FLAG_ACTIVITY_NEW_DOCUMENT` on launch, which is the standard way an app
can offer real independent, draggable/resizable OS-level windows on devices
that support freeform multi-window (large tablets, Samsung DeX, etc).
- `MainActivity` / `MainScreen` — the main window. Lists saved servers as
cards (Connect / Edit / Delete), and per-connected-server a box to join +
open a channel window. "+ Add Server" opens `ServerEditDialog` with host,
port, TLS, nick, auto-join channels, and a multi-line "on-connect / identify
commands" field — e.g. `PRIVMSG NickServ :IDENTIFY hunter2` — sent
automatically right after registration on every connect, before auto-join.
- `ServerRepository` — persists the saved server list (including on-connect
commands) to `servers.json` in app-private storage, so it survives app
restarts. This is the "file that saves that information" you asked for.
- Private messages: `ConnectionManager` now keys PMs by the *other person's*
nick (not our own), and emits a `newQueryBuffers` event the first time a PM
from someone new arrives. `IrcConnectionService` listens for that and
auto-launches a `ChannelWindowActivity` for it — so a PM pops its own window
the same way tapping a channel does, no different from every other window
in the app.
- Ctrl+K / Ctrl+B / Ctrl+I: hardware-keyboard shortcuts wired into the input
field in `ChannelScreen.kt`. Ctrl+B/I insert the raw bold (`\u0002`) /
italic (`\u001D`) toggle control chars at the cursor. Ctrl+K opens a 16-swatch
mIRC color picker dialog (foreground, then optional background) and inserts
the real `\u0003FG,BG` wire code — same bytes mIRC/HexChat send.
## Known limitations / what's still rough
- **"Minimize" isn't an app feature** — on devices with freeform multi-window
(large tablets, Samsung DeX, etc.) the OS itself draws minimize/maximize/
close chrome around any `resizeableActivity="true"` window. Both
`MainActivity` and `ChannelWindowActivity` are marked resizable so they get
that treatment automatically; there's no button to build on the app side.
- **No SASL handshake yet** — `ServerConfig` has `saslUser`/`saslPass` fields
but `IrcConnection` only sends `CAP REQ :sasl` and stops there. The
"on-connect commands" field is the current way to identify (it just sends a
plain `PRIVMSG NickServ :IDENTIFY ...` after registration, which works fine
on Rizon/most networks even without real SASL).
- **Multi-window is best-effort**, same as before — genuinely dependent on
device/OEM support for freeform windowing. This is a platform limitation,
not something the app can force.
- **No notifications for mentions when a window isn't focused yet.**
- **Channel list per server isn't persisted** — only the server configs
(including identify commands) are saved; which channels you had open resets
each app restart. Worth adding to `ServerRepository` next if it bugs you.
## Building — Claude Code / PowerShell, no Android Studio
This project lives at `C:\wallpapers\wIRC`. Since you're picking this up
with Claude Code the same way as Cart & Dagger: it needs the Android SDK
command-line tools + Gradle, same setup you've already got at
`C:\Users\admin\AppData\Local\Android\Sdk`.
From PowerShell, in `C:\wallpapers\wIRC`:
```powershell
cd C:\wallpapers\wIRC
# One-time: point Gradle at your existing SDK install
$env:ANDROID_HOME = "C:\Users\admin\AppData\Local\Android\Sdk"
"sdk.dir=C:\\Users\\admin\\AppData\\Local\\Android\\Sdk" | Out-File -Encoding ascii local.properties
# Generate the Gradle wrapper jar (not shipped in this zip — it's a binary)
# Requires a local Gradle install once, just to bootstrap the wrapper:
gradle wrapper --gradle-version 8.9
# Build
.\gradlew.bat assembleDebug
```
The debug APK lands at `C:\wallpapers\wIRC\app\build\outputs\apk\debug\app-debug.apk`.
From there, install it the same way you've been doing for Cart & Dagger —
`adb install`, or transfer it over your own hosting and install directly on
the tablet/phone.
If Claude Code hits a missing `gradle` command for that one-time wrapper
bootstrap, the SDK's `cmdline-tools` don't include Gradle itself — grab it
separately (`choco install gradle` if you use Chocolatey, or download the
binary distribution and add it to PATH) just for that first `gradle wrapper`
call; every build after that uses `.\gradlew.bat` and doesn't need a system
Gradle install anymore.
## Transferring to tablet/phone
Once you've got an APK (built on the laptop in Android Studio, or later
directly on-device via Claude Code in Termux/proot once that's sorted out):
- Simplest: `adb install app-debug.apk` if the device is plugged in / on the
same network with ADB over Wi-Fi enabled.
- Or just copy the APK over via your own file transfer (your hosting, a
shared folder, whatever you'd normally use) and install it directly on
the device — you'll need "install unknown apps" allowed for whichever app
you use to open the APK file.
## Suggested next steps
1. Wire up persistence for server/channel lists (Room or DataStore) so
`MainActivity` remembers your setup between launches.
2. Finish SASL PLAIN auth in `IrcConnection`.
3. Add a notification path for mentions when a channel window isn't focused.
4. Decide on nick auto-complete / tab-complete in the input bar.
+62
View File
@@ -0,0 +1,62 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.tpuahsiew.ircwindows"
compileSdk = 35
defaultConfig {
applicationId = "com.tpuahsiew.ircwindows"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.activity:activity-compose:1.9.1")
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.lifecycle:lifecycle-service:2.8.4")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("io.coil-kt:coil-compose:2.6.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+49
View File
@@ -0,0 +1,49 @@
<?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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".IrcWindowsApp"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.IrcWindows.Transparent"
android:supportsRtl="true">
<!-- Main window: server / channel list, add-connection screen -->
<activity
android:name=".MainActivity"
android:exported="true"
android:resizeableActivity="true"
android:theme="@style/Theme.IrcWindows.Transparent">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- One of these gets launched per channel. documentLaunchMode="always" +
resizeableActivity lets Android treat each launch as its own task, which is
what allows multiple independent, draggable/resizable windows on devices
that support freeform / multi-window (large tablets, DeX, etc). -->
<activity
android:name=".ChannelWindowActivity"
android:exported="false"
android:resizeableActivity="true"
android:documentLaunchMode="always"
android:launchMode="standard"
android:theme="@style/Theme.IrcWindows.Transparent"
android:windowSoftInputMode="adjustResize"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />
<service
android:name=".irc.IrcConnectionService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,141 @@
package com.tpuahsiew.ircwindows
import android.app.ActivityManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope
import com.tpuahsiew.ircwindows.data.BufferKey
import com.tpuahsiew.ircwindows.data.WindowDisplaySettings
import com.tpuahsiew.ircwindows.data.WindowPrefsRepository
import com.tpuahsiew.ircwindows.data.isChannelName
import com.tpuahsiew.ircwindows.data.storageId
import com.tpuahsiew.ircwindows.irc.ConnectionManager
import com.tpuahsiew.ircwindows.irc.IrcConnectionService
import com.tpuahsiew.ircwindows.ui.ChannelScreen
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Each channel a user opens gets its own instance of this Activity (see manifest:
* documentLaunchMode="always", resizeableActivity="true"). On devices with freeform /
* multi-window support this lets multiple channels sit on screen as separate,
* independently draggable/resizable windows rather than tabs inside one window.
*/
class ChannelWindowActivity : ComponentActivity() {
companion object {
const val EXTRA_SERVER_ID = "server_id"
const val EXTRA_CHANNEL_NAME = "channel_name"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val serverId = intent.getStringExtra(EXTRA_SERVER_ID) ?: run { finish(); return }
val channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: run { finish(); return }
val bufferKey = BufferKey(serverId, channelName)
val windowTitle = if (isChannelName(channelName)) channelName else "PM: $channelName"
setTaskDescription(ActivityManager.TaskDescription(windowTitle))
val prefsRepo = WindowPrefsRepository(applicationContext)
val displaySettingsFlow: StateFlow<WindowDisplaySettings> =
MutableStateFlow(WindowDisplaySettings())
lifecycleScope.launch {
prefsRepo.settingsFor(bufferKey.storageId()).collect { settings ->
(displaySettingsFlow as MutableStateFlow).value = settings
}
}
setContent {
val buffers by ConnectionManager.buffers.collectAsState()
val displaySettings by displaySettingsFlow.collectAsState()
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
val buffer = buffers[bufferKey]
ChannelScreen(
channelName = windowTitle,
buffer = buffer,
displaySettings = displaySettings,
ownNick = ownNicks[serverId],
onSend = { text ->
if (text.startsWith("/")) {
val withoutSlash = text.substring(1)
val spaceIdx = withoutSlash.indexOf(' ')
val command = (if (spaceIdx >= 0) withoutSlash.substring(0, spaceIdx) else withoutSlash).uppercase()
val rawArgs = if (spaceIdx >= 0) withoutSlash.substring(spaceIdx + 1) else ""
val args = rawArgs.trim()
when (command) {
"ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args)
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
"PART", "EXIT", "CLOSE" -> {
val target = args.ifBlank { channelName }
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
if (args.isBlank() || target == channelName) finish()
}
"QUIT" -> {
if (rawArgs.isBlank()) {
ConnectionManager.disconnectAll()
stopService(Intent(this, IrcConnectionService::class.java))
kotlin.system.exitProcess(0)
} else {
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
}
}
"JOIN" -> {
ConnectionManager.sendRaw(serverId, "JOIN $args")
val joinIntent = Intent(this, ChannelWindowActivity::class.java).apply {
putExtra(EXTRA_SERVER_ID, serverId)
putExtra(EXTRA_CHANNEL_NAME, args)
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
}
startActivity(joinIntent)
}
"MSG" -> {
val msgSpaceIdx = args.indexOf(' ')
if (msgSpaceIdx >= 0) {
val target = args.substring(0, msgSpaceIdx)
val message = args.substring(msgSpaceIdx + 1)
ConnectionManager.sendChannelMessage(serverId, target, message)
}
}
else -> ConnectionManager.sendRaw(serverId, "$command $args".trim())
}
} else {
ConnectionManager.sendChannelMessage(serverId, channelName, text)
}
},
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) },
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") },
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) },
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) },
onAlphaChange = { alpha ->
lifecycleScope.launch { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
},
onTextColorChange = { index ->
lifecycleScope.launch { prefsRepo.setTextColorMircIndex(bufferKey.storageId(), index) }
},
onFontSizeChange = { size ->
lifecycleScope.launch { prefsRepo.setFontSize(bufferKey.storageId(), size) }
},
onShowUserListChange = { show ->
lifecycleScope.launch { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
},
onNickColorsToggle = { enabled ->
lifecycleScope.launch { prefsRepo.setNickColorsEnabled(bufferKey.storageId(), enabled) }
},
onMentionHighlightColorChange = { colorArgb ->
lifecycleScope.launch { prefsRepo.setMentionHighlightColor(bufferKey.storageId(), colorArgb) }
},
onShowImagePreviewsChange = { show ->
lifecycleScope.launch { prefsRepo.setShowImagePreviews(bufferKey.storageId(), show) }
}
)
}
}
}
@@ -0,0 +1,20 @@
package com.tpuahsiew.ircwindows
import android.app.Application
import android.content.Intent
import android.os.Build
import com.tpuahsiew.ircwindows.irc.ConnectionManager
import com.tpuahsiew.ircwindows.irc.IrcConnectionService
class IrcWindowsApp : Application() {
override fun onCreate() {
super.onCreate()
ConnectionManager.init(this)
val intent = Intent(this, IrcConnectionService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
@@ -0,0 +1,346 @@
package com.tpuahsiew.ircwindows
import android.app.ActivityManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.tpuahsiew.ircwindows.data.ServerConfig
import com.tpuahsiew.ircwindows.data.ServerRepository
import com.tpuahsiew.ircwindows.irc.ConnectionManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
// (OutlinedTextField borders, Button/TextButton labels, etc.) neutral black-and-white
// to match the rest of the app's deliberately plain look.
private val neutralDarkScheme = darkColorScheme(
primary = Color.White,
onPrimary = Color.Black,
secondary = Color.White,
onSecondary = Color.Black,
background = Color.Black,
onBackground = Color.White,
surface = Color.Black,
onSurface = Color.White,
outline = Color.White
)
/**
* Main window. Resizable like every other window in the app (see manifest), so on
* devices with freeform multi-window support it gets the OS's own window chrome -
* including minimize - for free; there's no app-level "minimize" API to build.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTaskDescription(ActivityManager.TaskDescription("wIRC - Main"))
val repo = ServerRepository(applicationContext)
setContent {
MaterialTheme(colorScheme = neutralDarkScheme) {
Surface {
MainScreen(
repo = repo,
onOpenChannel = { serverId, channelName ->
val intent = Intent(this, ChannelWindowActivity::class.java).apply {
putExtra(ChannelWindowActivity.EXTRA_SERVER_ID, serverId)
putExtra(ChannelWindowActivity.EXTRA_CHANNEL_NAME, channelName)
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
}
startActivity(intent)
}
)
}
}
}
}
}
@Composable
fun MainScreen(repo: ServerRepository, onOpenChannel: (serverId: String, channelName: String) -> Unit) {
var servers by remember { mutableStateOf(repo.loadAll()) }
var showAddDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
var connectedIds by remember { mutableStateOf(setOf<String>()) }
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("wIRC", style = MaterialTheme.typography.headlineSmall)
Button(onClick = { editingServer = null; showAddDialog = true }) {
Text("+ Add Server")
}
}
Spacer(Modifier.height(16.dp))
if (servers.isEmpty()) {
Text("No saved servers yet. Tap \"+ Add Server\" to set one up.")
}
LazyColumn(modifier = Modifier.weight(1f)) {
items(servers) { server ->
ServerCard(
server = server,
isConnected = server.id in connectedIds,
channelInput = channelInputs[server.id] ?: "#",
openedChannels = openedChannels[server.id] ?: emptyList(),
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
onConnect = {
ConnectionManager.connect(server)
connectedIds = connectedIds + server.id
if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual
// "Join + Open" flow does - so give the server a moment to
// actually join us before opening windows for them.
coroutineScope.launch {
delay(500)
server.autoJoinChannels.forEach { ch ->
val current = openedChannels[server.id] ?: emptyList()
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
onOpenChannel(server.id, ch)
}
}
}
},
onEdit = { editingServer = server; showAddDialog = true },
onDelete = {
repo.remove(server.id)
servers = repo.loadAll()
},
onJoinAndOpen = { ch ->
if (ch.isNotBlank()) {
ConnectionManager.connectionFor(server.id)?.joinChannel(ch)
val current = openedChannels[server.id] ?: emptyList()
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
onOpenChannel(server.id, ch)
}
},
onReopenWindow = { ch -> onOpenChannel(server.id, ch) }
)
Spacer(Modifier.height(12.dp))
}
}
}
if (showAddDialog) {
ServerEditDialog(
existing = editingServer,
onDismiss = { showAddDialog = false },
onSave = { config ->
repo.upsert(config)
servers = repo.loadAll()
showAddDialog = false
}
)
}
}
@Composable
private fun ServerCard(
server: ServerConfig,
isConnected: Boolean,
channelInput: String,
openedChannels: List<String>,
onChannelInputChange: (String) -> Unit,
onConnect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onJoinAndOpen: (String) -> Unit,
onReopenWindow: (String) -> Unit
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(server.label, style = MaterialTheme.typography.titleMedium)
Text(
"${server.host}:${server.port}${if (server.useTls) " (TLS)" else ""} \u00b7 nick: ${server.nick}",
style = MaterialTheme.typography.bodySmall
)
if (server.onConnectCommands.isNotEmpty()) {
Text(
"${server.onConnectCommands.size} on-connect command(s) saved",
style = MaterialTheme.typography.bodySmall
)
}
}
Row {
TextButton(onClick = onEdit) { Text("Edit") }
TextButton(onClick = onDelete) { Text("Delete") }
}
}
Spacer(Modifier.height(8.dp))
if (!isConnected) {
Button(onClick = onConnect) { Text("Connect") }
} else {
Text("Connected", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
Row {
OutlinedTextField(
value = channelInput,
onValueChange = onChannelInputChange,
label = { Text("Channel") },
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
Button(onClick = { onJoinAndOpen(channelInput.trim()) }) {
Text("Join + Open")
}
}
if (openedChannels.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text("Windows:", style = MaterialTheme.typography.labelSmall)
openedChannels.forEach { ch ->
TextButton(onClick = { onReopenWindow(ch) }) { Text(ch) }
}
}
}
}
}
}
@Composable
private fun ServerEditDialog(
existing: ServerConfig?,
onDismiss: () -> Unit,
onSave: (ServerConfig) -> Unit
) {
var label by remember { mutableStateOf(existing?.label ?: "") }
var host by remember { mutableStateOf(existing?.host ?: "") }
var port by remember { mutableStateOf((existing?.port ?: 6667).toString()) }
var useTls by remember { mutableStateOf(existing?.useTls ?: false) }
var nick by remember { mutableStateOf(existing?.nick ?: "") }
var autoJoin by remember { mutableStateOf(existing?.autoJoinChannels?.joinToString(", ") ?: "") }
// One raw IRC command per line, e.g.:
// PRIVMSG NickServ :IDENTIFY hunter2
var onConnectCommands by remember {
mutableStateOf(existing?.onConnectCommands?.joinToString("\n") ?: "")
}
val focusManager = LocalFocusManager.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (existing == null) "Add Server" else "Edit Server") },
text = {
Column(
modifier = Modifier.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) {
focusManager.moveFocus(if (event.isShiftPressed) FocusDirection.Up else FocusDirection.Down)
true
} else false
}
) {
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Display name") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
Text("Use TLS")
}
OutlinedTextField(
value = nick,
onValueChange = { nick = it },
label = { Text("Nick") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = autoJoin,
onValueChange = { autoJoin = it },
label = { Text("Auto-join channels (comma separated)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = onConnectCommands,
onValueChange = { onConnectCommands = it },
label = { Text("On-connect / identify commands, one per line") },
placeholder = { Text("PRIVMSG NickServ :IDENTIFY yourpassword") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
Text(
"Saved (encrypted) and sent automatically every time you connect, " +
"right after registering and before auto-join.",
style = MaterialTheme.typography.labelSmall
)
}
},
confirmButton = {
TextButton(onClick = {
val id = existing?.id ?: host.replace(Regex("[^a-zA-Z0-9]"), "_")
onSave(
ServerConfig(
id = id,
label = label.ifBlank { host },
host = host,
port = port.toIntOrNull() ?: 6667,
useTls = useTls,
nick = nick.ifBlank { "guest${(1000..9999).random()}" },
autoJoinChannels = autoJoin.split(",").map { it.trim() }.filter { it.isNotBlank() },
onConnectCommands = onConnectCommands.split("\n").map { it.trim() }.filter { it.isNotBlank() }
)
)
}, enabled = host.isNotBlank()) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@@ -0,0 +1,58 @@
package com.tpuahsiew.ircwindows.data
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Persists each buffer's scrollback to a JSON file under filesDir/history/, so messages
* survive an app restart. Capped at 500 messages per buffer, matching the in-memory cap
* ConnectionManager already applies.
*/
class MessageHistoryRepository(private val context: Context) {
private fun fileFor(key: BufferKey): File {
val dir = File(context.filesDir, "history")
if (!dir.exists()) dir.mkdirs()
val safeName = key.name.replace(Regex("[^a-zA-Z0-9]"), "_")
return File(dir, "${key.serverId}__${safeName}.json")
}
fun loadMessages(key: BufferKey): List<IrcMessage> {
val file = fileFor(key)
if (!file.exists()) return emptyList()
return try {
val arr = JSONArray(file.readText())
(0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) }
} catch (e: Exception) {
emptyList()
}
}
fun saveMessages(key: BufferKey, messages: List<IrcMessage>) {
val arr = JSONArray()
messages.takeLast(500).forEach { arr.put(toJson(it)) }
fileFor(key).writeText(arr.toString())
}
private fun toJson(m: IrcMessage): JSONObject = JSONObject().apply {
put("timestampMillis", m.timestampMillis)
put("kind", m.kind.name)
put("sender", m.sender ?: JSONObject.NULL)
put("rawText", m.rawText)
put("target", m.target ?: JSONObject.NULL)
}
private fun fromJson(o: JSONObject): IrcMessage = IrcMessage(
timestampMillis = o.optLong("timestampMillis", 0L),
kind = try {
MessageKind.valueOf(o.optString("kind", "SYSTEM"))
} catch (e: Exception) {
MessageKind.SYSTEM
},
sender = o.optString("sender", null).takeIf { it != "null" },
rawText = o.optString("rawText", ""),
target = o.optString("target", null).takeIf { it != "null" }
)
}
@@ -0,0 +1,48 @@
package com.tpuahsiew.ircwindows.data
/** A configured IRC server/network connection. */
data class ServerConfig(
val id: String, // stable key, e.g. "rizon"
val label: String, // display name, e.g. "Rizon"
val host: String,
val port: Int = 6667,
val useTls: Boolean = false,
val nick: String,
val autoJoinChannels: List<String> = emptyList(),
val saslUser: String? = null,
val saslPass: String? = null,
// Raw IRC commands sent right after registration (001), before auto-join channels.
// e.g. "PRIVMSG NickServ :IDENTIFY hunter2" or "NS IDENTIFY hunter2".
// This is the "on join perform" list — saved to disk and replayed every connect.
val onConnectCommands: List<String> = emptyList()
)
/** True for channel-style targets ('#', '&', '+', '!' prefixes per the IRC spec). */
fun isChannelName(name: String): Boolean =
name.isNotEmpty() && name[0] in "#&+!"
enum class MessageKind { CHAT, ACTION, JOIN, PART, QUIT, KICK, MODE, NOTICE, NICK, TOPIC, SYSTEM }
data class IrcMessage(
val timestampMillis: Long,
val kind: MessageKind,
val sender: String?, // nick, or null for system/server lines
val rawText: String, // still contains mIRC control codes; parsed at render time
val target: String? = null // e.g. for KICK, the nick that got kicked
)
/** Buffer key: server id + channel/query name (channel names include '#'). */
data class BufferKey(val serverId: String, val name: String)
data class ChannelUser(
val nick: String,
val isOp: Boolean = false,
val isVoice: Boolean = false
)
data class ChannelBuffer(
val key: BufferKey,
val messages: List<IrcMessage> = emptyList(),
val users: List<ChannelUser> = emptyList(),
val topic: String = ""
)
@@ -0,0 +1,95 @@
package com.tpuahsiew.ircwindows.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONArray
import org.json.JSONObject
/**
* Saves the server list — including each server's "on join perform" commands
* (NickServ/ChanServ identify, etc.) — as a single JSON blob inside an
* EncryptedSharedPreferences store backed by an Android Keystore master key,
* so identify passwords never sit in plaintext on disk. Loaded once at app
* start and replayed automatically on every connect, so you only ever type
* your identify commands in once.
*/
class ServerRepository(context: Context) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"servers_encrypted",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun loadAll(): List<ServerConfig> {
val json = prefs.getString(SERVERS_KEY, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) }
} catch (e: Exception) {
emptyList()
}
}
fun saveAll(servers: List<ServerConfig>) {
val arr = JSONArray()
servers.forEach { arr.put(toJson(it)) }
prefs.edit().putString(SERVERS_KEY, arr.toString()).apply()
}
fun upsert(server: ServerConfig) {
val current = loadAll().toMutableList()
val idx = current.indexOfFirst { it.id == server.id }
if (idx >= 0) current[idx] = server else current.add(server)
saveAll(current)
}
fun remove(serverId: String) {
saveAll(loadAll().filterNot { it.id == serverId })
}
private fun toJson(s: ServerConfig): JSONObject = JSONObject().apply {
put("id", s.id)
put("label", s.label)
put("host", s.host)
put("port", s.port)
put("useTls", s.useTls)
put("nick", s.nick)
put("autoJoinChannels", JSONArray(s.autoJoinChannels))
put("saslUser", s.saslUser ?: JSONObject.NULL)
put("saslPass", s.saslPass ?: JSONObject.NULL)
put("onConnectCommands", JSONArray(s.onConnectCommands))
}
private fun fromJson(o: JSONObject): ServerConfig {
fun strArray(key: String): List<String> {
val arr = o.optJSONArray(key) ?: return emptyList()
return (0 until arr.length()).map { arr.getString(it) }
}
return ServerConfig(
id = o.getString("id"),
label = o.getString("label"),
host = o.getString("host"),
port = o.optInt("port", 6667),
useTls = o.optBoolean("useTls", false),
nick = o.getString("nick"),
autoJoinChannels = strArray("autoJoinChannels"),
saslUser = o.optString("saslUser", null).takeIf { it != "null" },
saslPass = o.optString("saslPass", null).takeIf { it != "null" },
onConnectCommands = strArray("onConnectCommands")
)
}
companion object {
private const val SERVERS_KEY = "servers_json"
}
}
@@ -0,0 +1,78 @@
package com.tpuahsiew.ircwindows.data
import android.content.Context
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
val Context.windowPrefsStore by preferencesDataStore(name = "window_prefs")
data class WindowDisplaySettings(
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
val fontSize: Float = 14f,
val showUserList: Boolean = true,
val mentionHighlightColorArgb: Int = 0x40FFA500, // translucent orange
val nickColorsEnabled: Boolean = true,
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
)
/**
* Settings are keyed per-buffer (server+channel) so each floating window can be
* configured independently, matching how the windows themselves are independent.
*/
class WindowPrefsRepository(private val context: Context) {
private fun alphaKey(bufferKeyId: String) = floatPreferencesKey("alpha_$bufferKeyId")
private fun textColorMircIndexKey(bufferKeyId: String) = intPreferencesKey("textcolormircindex_$bufferKeyId")
private fun fontSizeKey(bufferKeyId: String) = floatPreferencesKey("fontsize_$bufferKeyId")
private fun showUserListKey(bufferKeyId: String) = booleanPreferencesKey("showuserlist_$bufferKeyId")
private fun mentionColorKey(bufferKeyId: String) = intPreferencesKey("mentioncolor_$bufferKeyId")
private fun nickColorsEnabledKey(bufferKeyId: String) = booleanPreferencesKey("nickcolors_$bufferKeyId")
private fun showImagePreviewsKey(bufferKeyId: String) = booleanPreferencesKey("showimagepreviews_$bufferKeyId")
fun settingsFor(bufferKeyId: String): Flow<WindowDisplaySettings> {
return context.windowPrefsStore.data.map { prefs ->
WindowDisplaySettings(
backgroundAlpha = prefs[alphaKey(bufferKeyId)] ?: 0.85f,
textColorMircIndex = prefs[textColorMircIndexKey(bufferKeyId)] ?: 0,
fontSize = prefs[fontSizeKey(bufferKeyId)] ?: 14f,
showUserList = prefs[showUserListKey(bufferKeyId)] ?: true,
mentionHighlightColorArgb = prefs[mentionColorKey(bufferKeyId)] ?: 0x40FFA500,
nickColorsEnabled = prefs[nickColorsEnabledKey(bufferKeyId)] ?: true,
showImagePreviews = prefs[showImagePreviewsKey(bufferKeyId)] ?: false
)
}
}
suspend fun setAlpha(bufferKeyId: String, alpha: Float) {
context.windowPrefsStore.edit { it[alphaKey(bufferKeyId)] = alpha }
}
suspend fun setTextColorMircIndex(bufferKeyId: String, index: Int) {
context.windowPrefsStore.edit { it[textColorMircIndexKey(bufferKeyId)] = index }
}
suspend fun setFontSize(bufferKeyId: String, fontSize: Float) {
context.windowPrefsStore.edit { it[fontSizeKey(bufferKeyId)] = fontSize }
}
suspend fun setShowUserList(bufferKeyId: String, show: Boolean) {
context.windowPrefsStore.edit { it[showUserListKey(bufferKeyId)] = show }
}
suspend fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) {
context.windowPrefsStore.edit { it[mentionColorKey(bufferKeyId)] = colorArgb }
}
suspend fun setNickColorsEnabled(bufferKeyId: String, enabled: Boolean) {
context.windowPrefsStore.edit { it[nickColorsEnabledKey(bufferKeyId)] = enabled }
}
suspend fun setShowImagePreviews(bufferKeyId: String, show: Boolean) {
context.windowPrefsStore.edit { it[showImagePreviewsKey(bufferKeyId)] = show }
}
}
fun BufferKey.storageId(): String = "${serverId}__${name}"
@@ -0,0 +1,250 @@
package com.tpuahsiew.ircwindows.irc
import android.content.Context
import com.tpuahsiew.ircwindows.data.*
import com.tpuahsiew.ircwindows.data.isChannelName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
* windows (Activities) read from the same source of truth via StateFlow.
*/
object ConnectionManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val connections = ConcurrentHashMap<String, IrcConnection>()
private val ownNicks = ConcurrentHashMap<String, String>() // serverId -> our current nick
private var historyRepo: MessageHistoryRepository? = null
private val _buffers = MutableStateFlow<Map<BufferKey, ChannelBuffer>>(emptyMap())
val buffers: StateFlow<Map<BufferKey, ChannelBuffer>> = _buffers
private val _statuses = MutableStateFlow<Map<String, String>>(emptyMap())
val statuses: StateFlow<Map<String, String>> = _statuses
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
fun init(context: Context) {
historyRepo = MessageHistoryRepository(context.applicationContext)
}
private fun setOwnNick(serverId: String, nick: String) {
ownNicks[serverId] = nick
_ownNicks.update { it + (serverId to nick) }
}
// Fires once for every brand-new private-message buffer (a query with someone
// who wasn't already an open window). IrcConnectionService listens to this to
// auto-launch a window for it, same as tapping a channel manually.
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
fun connect(config: ServerConfig) {
if (connections.containsKey(config.id)) return
setOwnNick(config.id, config.nick)
val conn = IrcConnection(
config = config,
scope = scope,
onLine = { line -> handleLine(config.id, line) },
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
)
connections[config.id] = conn
conn.connect()
}
fun disconnectAll() {
connections.values.forEach { it.disconnect() }
connections.clear()
}
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
// added to our own buffer locally - otherwise it would never show up in the window
// that sent it.
fun sendChannelMessage(serverId: String, target: String, text: String) {
connectionFor(serverId)?.sendMessage(target, text)
val myNick = ownNicks[serverId] ?: return
addMessage(
BufferKey(serverId, target),
IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
)
}
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
fun sendChannelAction(serverId: String, target: String, action: String) {
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
val myNick = ownNicks[serverId] ?: return
addMessage(
BufferKey(serverId, target),
IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
)
}
// Raw passthrough for slash commands that don't have dedicated handling.
fun sendRaw(serverId: String, raw: String) {
connectionFor(serverId)?.send(raw)
}
private fun addMessage(key: BufferKey, msg: IrcMessage) {
val isNewBuffer = !_buffers.value.containsKey(key)
// Do the file I/O (seeding a brand-new buffer from disk, then persisting the
// updated list) off the caller's thread - addMessage gets called both from the
// IO-dispatched read loop and directly from UI-thread local-echo sends.
scope.launch(Dispatchers.IO) {
val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList()
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key, messages = seeded)
val trimmed = (existing.messages + msg).takeLast(500)
map + (key to existing.copy(messages = trimmed))
}
val toSave = _buffers.value[key]?.messages ?: emptyList()
historyRepo?.saveMessages(key, toSave)
}
if (isNewBuffer && !isChannelName(key.name)) {
scope.launch { _newQueryBuffers.emit(key) }
}
}
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key)
map + (key to existing.copy(users = transform(existing.users)))
}
}
private fun handleLine(serverId: String, line: ParsedLine) {
val now = System.currentTimeMillis()
val nick = nickFromPrefix(line.prefix) ?: line.prefix
when (line.command) {
"PRIVMSG" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
val isAction = text.startsWith("\u0001ACTION") && text.endsWith("\u0001")
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
// Channel messages are keyed by the channel name. Private messages arrive
// with target == our own nick, so key those by the *sender* instead -
// that's the conversation identity a query window should use.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(
BufferKey(serverId, bufferName),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
}
"JOIN" -> {
val channel = line.params.getOrNull(0) ?: return
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.JOIN, nick, "$nick joined $channel"))
if (nick != null) updateUsers(BufferKey(serverId, channel)) { it + ChannelUser(nick) }
}
"PART" -> {
val channel = line.params.getOrNull(0) ?: return
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.PART, nick, "$nick left $channel"))
if (nick != null) updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == nick } }
}
"KICK" -> {
val channel = line.params.getOrNull(0) ?: return
val kicked = line.params.getOrNull(1) ?: return
val reason = line.params.getOrNull(2) ?: ""
addMessage(
BufferKey(serverId, channel),
IrcMessage(now, MessageKind.KICK, nick, "$kicked was kicked by $nick ($reason)", target = kicked)
)
updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == kicked } }
}
"MODE" -> {
val channel = line.params.getOrNull(0) ?: return
if (!channel.startsWith("#")) return
val modeStr = line.params.getOrNull(1) ?: ""
val modeTarget = line.params.getOrNull(2)
addMessage(
BufferKey(serverId, channel),
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
)
if (modeTarget != null && (modeStr == "+o" || modeStr == "-o" || modeStr == "+v" || modeStr == "-v")) {
updateUsers(BufferKey(serverId, channel)) { users ->
users.map { u ->
if (u.nick != modeTarget) u
else when (modeStr) {
"+o" -> u.copy(isOp = true)
"-o" -> u.copy(isOp = false)
"+v" -> u.copy(isVoice = true)
"-v" -> u.copy(isVoice = false)
else -> u
}
}
}
}
}
"TOPIC" -> {
val channel = line.params.getOrNull(0) ?: return
val topic = line.params.getOrNull(1) ?: ""
_buffers.update { map ->
val key = BufferKey(serverId, channel)
val existing = map[key] ?: ChannelBuffer(key)
map + (key to existing.copy(topic = topic))
}
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic"))
}
"353" -> { // NAMES reply
val channel = line.params.getOrNull(2) ?: return
val names = line.params.getOrNull(3)?.split(" ")?.filter { it.isNotBlank() } ?: return
val parsedUsers = names.map { raw ->
when {
raw.startsWith("@") -> ChannelUser(raw.removePrefix("@"), isOp = true)
raw.startsWith("+") -> ChannelUser(raw.removePrefix("+"), isVoice = true)
else -> ChannelUser(raw)
}
}
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
}
"NOTICE" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
}
"NICK" -> {
val oldNick = nick ?: return
val newNick = line.params.getOrNull(0) ?: return
val affectedKeys = _buffers.value.filterKeys { it.serverId == serverId }
.filterValues { buf -> buf.users.any { it.nick == oldNick } }
.keys
_buffers.update { map ->
map.mapValues { (key, buf) ->
if (key.serverId == serverId && buf.users.any { it.nick == oldNick }) {
buf.copy(users = buf.users.map { u -> if (u.nick == oldNick) u.copy(nick = newNick) else u })
} else buf
}
}
affectedKeys.forEach { key ->
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "$oldNick is now known as $newNick"))
}
if (ownNicks[serverId] == oldNick) {
setOwnNick(serverId, newNick)
}
}
"QUIT" -> {
val reason = line.params.getOrNull(0) ?: ""
// Remove nick from every channel buffer on this server it appears in.
_buffers.update { map ->
map.mapValues { (key, buf) ->
if (key.serverId == serverId && nick != null && buf.users.any { it.nick == nick }) {
buf.copy(users = buf.users.filterNot { it.nick == nick })
} else buf
}
}
}
}
}
}
@@ -0,0 +1,159 @@
package com.tpuahsiew.ircwindows.irc
import com.tpuahsiew.ircwindows.data.ServerConfig
import kotlinx.coroutines.*
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.Socket
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* Owns one socket to one IRC server. Emits raw parsed lines via onLine.
* Handles registration (NICK/USER), and basic auto-reconnect with backoff.
*/
class IrcConnection(
val config: ServerConfig,
private val scope: CoroutineScope,
private val onLine: (ParsedLine) -> Unit,
private val onStatus: (String) -> Unit
) {
private var socket: Socket? = null
private var writer: PrintWriter? = null
private var readJob: Job? = null
private var wantConnected = false
private var backoffMillis = 2000L
// Every channel we're currently in, whether from config.autoJoinChannels or joined
// manually mid-session - so a reconnect rejoins everything, not just the original
// auto-join list.
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>()
fun connect() {
wantConnected = true
readJob?.cancel()
readJob = scope.launch(Dispatchers.IO) {
while (wantConnected && isActive) {
try {
onStatus("Connecting to ${config.host}:${config.port}...")
val sock: Socket = if (config.useTls) {
val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket
val sslParams = sslSocket.sslParameters
sslParams.endpointIdentificationAlgorithm = "HTTPS"
sslSocket.sslParameters = sslParams
sslSocket.startHandshake()
sslSocket
} else {
Socket(config.host, config.port)
}
socket = sock
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
writer = out
if (!config.saslUser.isNullOrBlank()) {
out.println("CAP REQ :sasl")
}
out.println("NICK ${config.nick}")
out.println("USER ${config.nick} 0 * :${config.nick}")
backoffMillis = 2000L
onStatus("Connected, registering...")
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
while (isActive) {
val line = reader.readLine() ?: break
handleRawLine(line)
}
} catch (e: Exception) {
onStatus("Connection error: ${e.message}")
} finally {
closeSocketQuietly()
}
if (wantConnected) {
onStatus("Reconnecting in ${backoffMillis / 1000}s...")
delay(backoffMillis)
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
}
}
}
}
private fun handleRawLine(line: String) {
if (line.startsWith("PING")) {
val token = line.substringAfter("PING ")
send("PONG $token")
return
}
val parsed = parseIrcLine(line) ?: return
// CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets
// handled right here so it never shows up in a channel/PM window - real
// clients answer these silently too.
if (parsed.command == "PRIVMSG") {
val text = parsed.params.getOrNull(1)
if (text != null && text.startsWith("\u0001") && text.endsWith("\u0001") && text.length > 1) {
val ctcpBody = text.removePrefix("\u0001").removeSuffix("\u0001")
val ctcpCommand = ctcpBody.substringBefore(' ').uppercase()
if (ctcpCommand == "VERSION") {
val replyTo = nickFromPrefix(parsed.prefix)
if (replyTo != null) {
send("NOTICE $replyTo :\u0001VERSION It's an irc client, fucko!\u0001")
}
return
}
// Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal -
// ACTION in particular still needs to reach ConnectionManager to render as /me.
}
}
if (parsed.command == "001") {
// Registered. Run "on join perform" commands first (NickServ/ChanServ
// identify, etc.) so we're identified before joining anything that
// might require it, then rejoin every channel we were actually in -
// the originally configured auto-join list plus anything joined
// manually mid-session, so a reconnect doesn't forget those.
config.onConnectCommands.forEach { cmd -> if (cmd.isNotBlank()) send(cmd) }
(config.autoJoinChannels + joinedChannels).distinct().forEach { ch -> joinChannel(ch) }
}
onLine(parsed)
}
fun send(raw: String) {
scope.launch(Dispatchers.IO) { writer?.println(raw) }
}
fun sendMessage(target: String, text: String) {
send("PRIVMSG $target :$text")
}
fun joinChannel(channel: String) {
joinedChannels.add(channel)
send("JOIN $channel")
}
fun partChannel(channel: String, reason: String = "") {
joinedChannels.remove(channel)
send("PART $channel :$reason")
}
fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason")
fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick")
fun ban(channel: String, mask: String) = send("MODE $channel +b $mask")
fun disconnect() {
wantConnected = false
scope.launch(Dispatchers.IO) {
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
readJob?.cancel()
closeSocketQuietly()
}
}
private fun closeSocketQuietly() {
try { socket?.close() } catch (_: Exception) {}
socket = null
writer = null
}
}
@@ -0,0 +1,77 @@
package com.tpuahsiew.ircwindows.irc
import android.app.*
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.tpuahsiew.ircwindows.ChannelWindowActivity
import com.tpuahsiew.ircwindows.MainActivity
import com.tpuahsiew.ircwindows.R
import kotlinx.coroutines.launch
/**
* Foreground service whose only job is to keep the process (and therefore every
* IrcConnection socket held by ConnectionManager) alive while the app is backgrounded.
* Android aggressively kills backgrounded network connections without this.
*/
class IrcConnectionService : LifecycleService() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification())
// Auto-open a window for every brand-new private message, the same way
// tapping a channel does. Uses NEW_TASK since we're launching from a
// Service context, not an Activity.
lifecycleScope.launch {
ConnectionManager.newQueryBuffers.collect { key ->
val intent = Intent(this@IrcConnectionService, ChannelWindowActivity::class.java).apply {
putExtra(ChannelWindowActivity.EXTRA_SERVER_ID, key.serverId)
putExtra(ChannelWindowActivity.EXTRA_CHANNEL_NAME, key.name)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
}
startActivity(intent)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
return START_STICKY
}
private fun buildNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("wIRC")
.setContentText("Connected")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "IRC connection", NotificationManager.IMPORTANCE_LOW
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
companion object {
const val CHANNEL_ID = "irc_connection"
const val NOTIFICATION_ID = 1001
}
}
@@ -0,0 +1,47 @@
package com.tpuahsiew.ircwindows.irc
/** A minimally-parsed raw IRC protocol line. */
data class ParsedLine(
val prefix: String?, // e.g. "nick!user@host" or a server name
val command: String, // e.g. "PRIVMSG", "JOIN", "376", etc.
val params: List<String> // trailing param (after ':') is params.last() if present
)
fun parseIrcLine(line: String): ParsedLine? {
if (line.isBlank()) return null
var rest = line
var prefix: String? = null
if (rest.startsWith(":")) {
val spaceIdx = rest.indexOf(' ')
if (spaceIdx == -1) return null
prefix = rest.substring(1, spaceIdx)
rest = rest.substring(spaceIdx + 1)
}
val trailingSplit = rest.indexOf(" :")
val paramsStr: String
val trailing: String?
if (trailingSplit != -1) {
paramsStr = rest.substring(0, trailingSplit)
trailing = rest.substring(trailingSplit + 2)
} else {
paramsStr = rest
trailing = null
}
val parts = paramsStr.split(" ").filter { it.isNotEmpty() }
if (parts.isEmpty()) return null
val command = parts[0].uppercase()
val params = parts.drop(1).toMutableList()
if (trailing != null) params.add(trailing)
return ParsedLine(prefix, command, params)
}
/** Extracts just the nick portion from an IRC prefix like "nick!user@host". */
fun nickFromPrefix(prefix: String?): String? {
if (prefix == null) return null
val bangIdx = prefix.indexOf('!')
return if (bangIdx != -1) prefix.substring(0, bangIdx) else prefix
}
@@ -0,0 +1,165 @@
package com.tpuahsiew.ircwindows.irc
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
// Standard 16-color mIRC palette (index 0-15).
val mircPalette = listOf(
Color(0xFFFFFFFF), // 0 white
Color(0xFF000000), // 1 black
Color(0xFF00007F), // 2 blue (navy)
Color(0xFF009300), // 3 green
Color(0xFFFF0000), // 4 red
Color(0xFF7F0000), // 5 brown (maroon)
Color(0xFF9C009C), // 6 purple
Color(0xFFFC7F00), // 7 orange
Color(0xFFFFFF00), // 8 yellow
Color(0xFF00FC00), // 9 light green
Color(0xFF00939C), // 10 cyan
Color(0xFF00FFFF), // 11 light cyan
Color(0xFF0000FC), // 12 light blue
Color(0xFFFF00FF), // 13 pink
Color(0xFF7F7F7F), // 14 grey
Color(0xFFD2D2D2) // 15 light grey
)
private const val BOLD = '\u0002'
private const val COLOR = '\u0003'
private const val RESET = '\u000F'
private const val REVERSE = '\u0016'
private const val UNDERLINE = '\u001F'
private const val ITALIC = ''
private val urlRegex = Regex("""https?://\S+""")
private val urlColor = Color(0xFF4EA1FF)
private val imageUrlRegex = Regex("""https?://[^\s]+""")
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
/**
* Extracts URLs from raw message text that look like they point at an image,
* based on the file extension in the path (ignoring query string/fragment).
*/
fun extractImageUrls(text: String): List<String> {
return imageUrlRegex.findAll(text).map { it.value }.filter { url ->
val cleanUrl = url.substringBefore('?').substringBefore('#')
val ext = cleanUrl.substringAfterLast('.', "").lowercase()
ext in imageExtensions
}.toList()
}
/**
* Parses a raw IRC line (which may contain mIRC control codes) into an AnnotatedString
* ready to render in Compose. baseColor is applied where no explicit foreground is set,
* so it follows the user's chosen black/white text setting.
*/
fun parseMircToAnnotatedString(raw: String, baseColor: Color): AnnotatedString {
val builder = AnnotatedString.Builder()
var bold = false
var underline = false
var italic = false
var reverse = false
var fg: Color? = null
var bg: Color? = null
fun currentStyle(): SpanStyle {
val effectiveFg = if (reverse) (bg ?: baseColor) else (fg ?: baseColor)
val effectiveBg = if (reverse) (fg ?: baseColor) else bg
return SpanStyle(
color = effectiveFg,
background = effectiveBg ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
textDecoration = if (underline) TextDecoration.Underline else TextDecoration.None
)
}
var i = 0
var runStart = 0
fun flush(endExclusive: Int) {
if (endExclusive > runStart) {
builder.withStyle(currentStyle()) {
append(raw.substring(runStart, endExclusive))
}
}
}
fun readColorDigits(startIdx: Int): Pair<Int?, Int> {
// Reads up to 2 digits starting at startIdx, returns (colorIndexOrNull, nextIndex)
var idx = startIdx
var numStr = ""
while (idx < raw.length && raw[idx].isDigit() && numStr.length < 2) {
numStr += raw[idx]
idx++
}
if (numStr.isEmpty()) return Pair(null, startIdx)
val n = numStr.toInt().coerceIn(0, 15)
return Pair(n, idx)
}
while (i < raw.length) {
when (raw[i]) {
BOLD -> {
flush(i); bold = !bold; i++; runStart = i
}
UNDERLINE -> {
flush(i); underline = !underline; i++; runStart = i
}
ITALIC -> {
flush(i); italic = !italic; i++; runStart = i
}
REVERSE -> {
flush(i); reverse = !reverse; i++; runStart = i
}
RESET -> {
flush(i)
bold = false; underline = false; italic = false; reverse = false; fg = null; bg = null
i++; runStart = i
}
COLOR -> {
flush(i)
i++
val (fgIdx, nextI) = readColorDigits(i)
i = nextI
if (fgIdx == null) {
fg = null; bg = null
} else {
fg = mircPalette[fgIdx]
if (i < raw.length && raw[i] == ',') {
val (bgIdx, nextI2) = readColorDigits(i + 1)
if (bgIdx != null) {
bg = mircPalette[bgIdx]
i = nextI2
}
}
}
runStart = i
}
else -> i++
}
}
flush(raw.length)
val parsed = builder.toAnnotatedString()
val matches = urlRegex.findAll(parsed.text).toList()
if (matches.isEmpty()) return parsed
// Layer link styling/annotations on top of the existing mIRC-colored string rather
// than replacing it, so control codes elsewhere in the line are unaffected.
val linked = AnnotatedString.Builder(parsed)
matches.forEach { match ->
val start = match.range.first
val end = match.range.last + 1
linked.addStyle(SpanStyle(color = urlColor, textDecoration = TextDecoration.Underline), start, end)
linked.addStringAnnotation(tag = "URL", annotation = match.value, start = start, end = end)
}
return linked.toAnnotatedString()
}
@@ -0,0 +1,707 @@
package com.tpuahsiew.ircwindows.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.tpuahsiew.ircwindows.data.ChannelBuffer
import com.tpuahsiew.ircwindows.data.ChannelUser
import com.tpuahsiew.ircwindows.data.MessageKind
import com.tpuahsiew.ircwindows.data.WindowDisplaySettings
import com.tpuahsiew.ircwindows.irc.extractImageUrls
import com.tpuahsiew.ircwindows.irc.mircPalette
import com.tpuahsiew.ircwindows.irc.parseMircToAnnotatedString
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.floor
// Tracks an in-progress nick tab-completion so a repeated Tab press (with the
// input still exactly as this completion left it) cycles to the next match
// instead of redoing the same one.
private data class TabCompletionState(
val wordStart: Int,
val matches: List<String>,
val index: Int,
val textAfter: String,
val cursorAfter: Int
)
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun ChannelScreen(
channelName: String,
buffer: ChannelBuffer?,
displaySettings: WindowDisplaySettings,
ownNick: String?,
onSend: (String) -> Unit,
onKick: (String) -> Unit,
onBan: (String) -> Unit,
onOp: (String) -> Unit,
onDeop: (String) -> Unit,
onAlphaChange: (Float) -> Unit,
onTextColorChange: (Int) -> Unit,
onFontSizeChange: (Float) -> Unit,
onShowUserListChange: (Boolean) -> Unit,
onNickColorsToggle: (Boolean) -> Unit,
onMentionHighlightColorChange: (Int) -> Unit,
onShowImagePreviewsChange: (Boolean) -> Unit
) {
var input by remember { mutableStateOf(TextFieldValue("")) }
var showSettings by remember { mutableStateOf(false) }
var contextMenuUser by remember { mutableStateOf<ChannelUser?>(null) }
var showColorPicker by remember { mutableStateOf(false) }
var tabState by remember { mutableStateOf<TabCompletionState?>(null) }
val history = remember { mutableStateListOf<String>() }
var historyIndex by remember { mutableStateOf(-1) }
var draftBeforeHistory by remember { mutableStateOf("") }
val inputFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val context = LocalContext.current
// Inserts a raw mIRC/IRCv3 formatting control char at the current cursor
// position (or wraps the selection, if any), same behavior as toggling
// bold/italic in a real mIRC client - it's a toggle marker, not a wrapper tag.
fun insertControlChar(char: Char) {
val text = input.text
val sel = input.selection
val newText = text.substring(0, sel.start) + char + text.substring(sel.end)
val newCursor = sel.start + 1
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
}
fun insertColorCode(fg: Int, bg: Int?) {
val code = "\u0003" + fg.toString().padStart(2, '0') + (bg?.let { "," + it.toString().padStart(2, '0') } ?: "")
val text = input.text
val sel = input.selection
val newText = text.substring(0, sel.start) + code + text.substring(sel.end)
val newCursor = sel.start + code.length
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
}
// Tab-completes the nick fragment before the cursor against buffer.users.
// A repeated Tab press against the exact text/cursor state this function
// last produced cycles to the next match; anything else (fresh typing,
// moved cursor) starts a new completion from scratch.
fun tryTabComplete(): Boolean {
val text = input.text
val cursor = input.selection.start
val state = tabState
if (state != null && text == state.textAfter && cursor == state.cursorAfter) {
val nextIndex = (state.index + 1) % state.matches.size
val nick = state.matches[nextIndex]
val completion = "$nick "
val newText = text.substring(0, state.wordStart) + completion + text.substring(state.cursorAfter)
val newCursor = state.wordStart + completion.length
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
tabState = state.copy(index = nextIndex, textAfter = newText, cursorAfter = newCursor)
return true
}
var wordStart = cursor
while (wordStart > 0 && !text[wordStart - 1].isWhitespace()) wordStart--
val partial = text.substring(wordStart, cursor)
val matches = (buffer?.users ?: emptyList()).map { it.nick }
.filter { it.startsWith(partial, ignoreCase = true) }
if (matches.isEmpty()) {
tabState = null
return false
}
val nick = matches[0]
val completion = "$nick "
val newText = text.substring(0, wordStart) + completion + text.substring(cursor)
val newCursor = wordStart + completion.length
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
tabState = TabCompletionState(wordStart, matches, 0, newText, newCursor)
return true
}
// Pushes a sent line onto the up/down-arrow recall history (skipping an exact repeat
// of the last entry) and resets navigation back to "not browsing history".
fun recordHistory(text: String) {
if (history.isEmpty() || history.last() != text) {
history.add(text)
}
historyIndex = -1
}
fun sendCurrentInput() {
if (input.text.isNotBlank()) {
recordHistory(input.text)
onSend(input.text)
input = TextFieldValue("")
}
}
fun isMention(text: String): Boolean {
val nick = ownNick
if (nick.isNullOrBlank()) return false
return Regex("(?i)\\b${Regex.escape(nick)}\\b").containsMatchIn(text)
}
val textColor = mircPalette[displaySettings.textColorMircIndex]
val bgColor = Color.Black.copy(alpha = displaySettings.backgroundAlpha)
val listState = rememberLazyListState()
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
LaunchedEffect(buffer?.messages?.size) {
if ((buffer?.messages?.size ?: 0) > 0) {
listState.animateScrollToItem((buffer!!.messages.size - 1).coerceAtLeast(0))
}
}
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
inputFocusRequester.requestFocus()
keyboardController?.show()
}
.background(bgColor)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Title bar: channel name + settings gear. Deliberately plain, no theming.
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(channelName, color = textColor, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall)
Row {
IconButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp))
}
IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
}
}
}
if (showSettings) {
WindowSettingsPanel(
displaySettings = displaySettings,
textColor = textColor,
onAlphaChange = onAlphaChange,
onTextColorChange = onTextColorChange,
onFontSizeChange = onFontSizeChange,
onNickColorsToggle = onNickColorsToggle,
onMentionHighlightColorChange = onMentionHighlightColorChange,
onShowImagePreviewsChange = onShowImagePreviewsChange
)
}
// Scrollback + user list, side by side, filling the available height
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Scrollback height is snapped down to a whole multiple of one line's height so a
// resize never leaves a partially-rendered line visible - the leftover fractional
// space is absorbed by the Spacer below instead, leaving the rest of the layout
// (nicklist, input bar) unaffected.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val lineHeightPx = remember(displaySettings.fontSize) {
textMeasurer.measure(
text = "Mg",
style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
).size.height
}
val lineHeightDp = with(density) { lineHeightPx.toDp() }
val snappedHeight = if (lineHeightDp > 0.dp) {
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
} else {
maxHeight
}
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.height(snappedHeight)
.padding(horizontal = 6.dp)
) {
items(buffer?.messages ?: emptyList()) { msg ->
val ts = timeFormat.format(Date(msg.timestampMillis))
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
val mentioned = isMention(msg.rawText)
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
val mutedColor = textColor.copy(alpha = 0.7f)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
) {
when (msg.kind) {
MessageKind.CHAT -> {
Text(
text = "[$ts] ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
Text(
text = "<${msg.sender}>",
color = nickColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
MessageKind.ACTION -> {
Text(
text = "[$ts] * ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
Text(
text = "${msg.sender} ",
color = nickColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
else -> {
Text(
text = "[$ts] * ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
}
ClickableText(
text = bodyAnnotated,
style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
),
onClick = { offset ->
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
}
}
)
}
if (displaySettings.showImagePreviews) {
extractImageUrls(msg.rawText).forEach { url ->
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(4.dp))
.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
contentScale = ContentScale.Fit
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
// User list (tap and hold for OP actions)
if (displaySettings.showUserList && !buffer?.users.isNullOrEmpty()) {
LazyColumn(
modifier = Modifier
.width(140.dp)
.fillMaxHeight()
.padding(4.dp)
) {
items(buffer!!.users) { user ->
Text(
text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick,
color = textColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.combinedClickable(
onClick = {
val prefill = "/msg ${user.nick} "
input = TextFieldValue(prefill, androidx.compose.ui.text.TextRange(prefill.length))
inputFocusRequester.requestFocus()
},
onLongClick = { contextMenuUser = user }
)
)
}
}
}
}
// Input bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 32.dp, max = 40.dp)
.padding(vertical = 2.dp),
contentAlignment = Alignment.CenterStart
) {
BasicTextField(
value = input,
onValueChange = { input = it },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
color = textColor,
fontSize = displaySettings.fontSize.sp
),
cursorBrush = SolidColor(textColor),
modifier = Modifier
.fillMaxWidth()
.focusRequester(inputFocusRequester)
.onPreviewKeyEvent { event ->
// Hardware-keyboard shortcuts: Ctrl+K color picker,
// Ctrl+B bold, Ctrl+I italic. Tablet-with-keyboard friendly.
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed) {
when (event.key) {
Key.K -> { showColorPicker = true; true }
Key.B -> { insertControlChar('\u0002'); true }
Key.I -> { insertControlChar('\u001D'); true }
else -> false
}
} else if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isCtrlPressed) {
// Hardware Enter sends, same as the software keyboard's
// ImeAction.Send below. Ctrl+Enter is left alone in case
// it's wanted for something else later.
sendCurrentInput()
true
} else if (event.type == KeyEventType.KeyDown && event.key == Key.Tab && !event.isCtrlPressed) {
tryTabComplete()
} else if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) {
if (history.isEmpty()) {
false
} else {
if (historyIndex == -1) draftBeforeHistory = input.text
historyIndex = (historyIndex + 1).coerceAtMost(history.size - 1)
val entry = history[history.size - 1 - historyIndex]
input = TextFieldValue(entry, androidx.compose.ui.text.TextRange(entry.length))
true
}
} else if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
if (historyIndex == -1) {
false
} else {
historyIndex--
if (historyIndex < 0) {
input = TextFieldValue(draftBeforeHistory, androidx.compose.ui.text.TextRange(draftBeforeHistory.length))
} else {
val entry = history[history.size - 1 - historyIndex]
input = TextFieldValue(entry, androidx.compose.ui.text.TextRange(entry.length))
}
true
}
} else false
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { sendCurrentInput() })
)
}
}
}
contextMenuUser?.let { user ->
OpActionMenu(
user = user,
onDismiss = { contextMenuUser = null },
onKick = { onKick(user.nick); contextMenuUser = null },
onBan = { onBan(user.nick); contextMenuUser = null },
onOp = { onOp(user.nick); contextMenuUser = null },
onDeop = { onDeop(user.nick); contextMenuUser = null }
)
}
if (showColorPicker) {
MircColorPickerDialog(
onDismiss = { showColorPicker = false },
onPick = { fg, bg ->
insertColorCode(fg, bg)
showColorPicker = false
}
)
}
}
}
// Standard 16-color mIRC palette swatches, same indices the wire protocol uses
// (\u0003FG,BG), so picking a swatch here inserts the exact code mIRC/HexChat/etc send.
private val mircSwatches = mircPalette.mapIndexed { index, color -> index to color }
// Deterministic per-nick color so the same nick always renders the same color within a
// session, without needing to track color assignments anywhere.
private val nickColorPalette = listOf(
Color(0xFFFF6B6B), Color(0xFF4ECDC4), Color(0xFFFFD93D), Color(0xFF95E1D3),
Color(0xFFF38181), Color(0xFFAA96DA), Color(0xFFFCBAD3), Color(0xFFA8D8EA),
Color(0xFFFFAAA5), Color(0xFF88D8B0)
)
private fun colorForNick(nick: String): Color =
nickColorPalette[(nick.hashCode().and(0x7FFFFFFF)) % nickColorPalette.size]
@Composable
private fun MircColorPickerDialog(onDismiss: () -> Unit, onPick: (fg: Int, bg: Int?) -> Unit) {
var selectedFg by remember { mutableStateOf<Int?>(null) }
var selectedBg by remember { mutableStateOf<Int?>(null) }
var pickingBg by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (pickingBg) "Background (optional)" else "Text color") },
text = {
Column {
FlowSwatchGrid(
selected = if (pickingBg) selectedBg else selectedFg,
onSelect = { idx -> if (pickingBg) selectedBg = idx else selectedFg = idx }
)
Spacer(Modifier.height(8.dp))
if (!pickingBg) {
TextButton(onClick = { pickingBg = true }, enabled = selectedFg != null) {
Text("Next: pick background (optional) \u2192")
}
}
}
},
confirmButton = {
TextButton(
onClick = { if (selectedFg != null) onPick(selectedFg!!, selectedBg) },
enabled = selectedFg != null
) { Text("Insert") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun FlowSwatchGrid(selected: Int?, onSelect: (Int) -> Unit) {
Column {
mircSwatches.chunked(4).forEach { row ->
Row {
row.forEach { (idx, color) ->
Box(
modifier = Modifier
.padding(2.dp)
.size(36.dp)
.background(color)
.border(if (selected == idx) 3.dp else 1.dp, Color.Black)
.clickableNoRipple { onSelect(idx) }
)
}
}
}
}
}
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = this.then(
Modifier.combinedClickable(onClick = onClick, onLongClick = {})
)
// Reuses the same mIRC swatch grid as Ctrl+K, but picks a single color and converts it
// to a translucent (~25% alpha) ARGB int suitable for a message-row background tint.
@Composable
private fun MentionColorPickerDialog(onDismiss: () -> Unit, onPick: (Int) -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mention highlight color") },
text = {
FlowSwatchGrid(
selected = null,
onSelect = { idx ->
val rgb = mircSwatches.first { it.first == idx }.second.toArgb()
val translucentArgb = (0x40 shl 24) or (rgb and 0x00FFFFFF)
onPick(translucentArgb)
onDismiss()
}
)
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
}
)
}
@Composable
private fun WindowSettingsPanel(
displaySettings: WindowDisplaySettings,
textColor: Color,
onAlphaChange: (Float) -> Unit,
onTextColorChange: (Int) -> Unit,
onFontSizeChange: (Float) -> Unit,
onNickColorsToggle: (Boolean) -> Unit,
onMentionHighlightColorChange: (Int) -> Unit,
onShowImagePreviewsChange: (Boolean) -> Unit
) {
var showMentionColorPicker by remember { mutableStateOf(false) }
val sliderColors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
)
Column(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, Color.Black)
.padding(8.dp)
) {
Text("Background transparency", color = textColor, style = MaterialTheme.typography.bodySmall)
Slider(
value = displaySettings.backgroundAlpha,
onValueChange = onAlphaChange,
valueRange = 0f..1f,
colors = sliderColors
)
Spacer(Modifier.height(4.dp))
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
Slider(
value = displaySettings.fontSize,
onValueChange = onFontSizeChange,
valueRange = 10f..24f,
colors = sliderColors
)
Spacer(Modifier.height(4.dp))
Text("Text color:", color = textColor, style = MaterialTheme.typography.bodySmall)
FlowSwatchGrid(
selected = displaySettings.textColorMircIndex,
onSelect = onTextColorChange
)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Nick colors", color = textColor, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.width(8.dp))
Switch(
checked = displaySettings.nickColorsEnabled,
onCheckedChange = onNickColorsToggle
)
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Mention highlight color:", color = textColor, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(24.dp)
.background(Color(displaySettings.mentionHighlightColorArgb))
.border(1.dp, Color.White)
.clickableNoRipple { showMentionColorPicker = true }
)
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Show image previews", color = textColor, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.width(8.dp))
Switch(
checked = displaySettings.showImagePreviews,
onCheckedChange = onShowImagePreviewsChange
)
}
if (showMentionColorPicker) {
MentionColorPickerDialog(
onDismiss = { showMentionColorPicker = false },
onPick = onMentionHighlightColorChange
)
}
}
}
@Composable
private fun OpActionMenu(
user: ChannelUser,
onDismiss: () -> Unit,
onKick: () -> Unit,
onBan: () -> Unit,
onOp: () -> Unit,
onDeop: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(user.nick) },
text = {
Column {
TextButton(onClick = onKick) { Text("Kick") }
TextButton(onClick = onBan) { Text("Ban") }
if (user.isOp) {
TextButton(onClick = onDeop) { Text("De-op") }
} else {
TextButton(onClick = onOp) { Text("Op") }
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
}
)
}
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#000000" android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M20,30 L88,30 L88,70 L45,70 L28,86 L28,70 L20,70 Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M4,4 L20,4 L20,16 L10,16 L6,20 L6,16 L4,16 Z" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">wIRC</string>
</resources>
+17
View File
@@ -0,0 +1,17 @@
<resources>
<!-- Base app theme, plain Material3 dark, no bloggin.space branding -->
<style name="Theme.IrcWindows" parent="android:Theme.Material.NoActionBar">
</style>
<!-- Transparent variant used by every Activity so the Compose layer fully controls
its own background alpha, border, and chrome instead of the system decorations. -->
<style name="Theme.IrcWindows.Transparent" parent="Theme.IrcWindows">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowAnimationStyle">@android:style/Animation</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsFloating">false</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.6.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew 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 gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# 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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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
# 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, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
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
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
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 optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "wIRC"
include(":app")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB