10 Commits

Author SHA1 Message Date
c972125fef release build v0.3.5
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
2026-08-19 11:51:36 +02:00
cd94721fc8 docs: add CLAUDE.md with build, signing, and on-device testing notes 2026-08-19 11:51:21 +02:00
8cb9166f41 fix: widen weather glyph/temperature gap; drop debug logs 2026-08-19 11:51:21 +02:00
010ff8a0c8 feat: current weather on home screen (MET/Yr API, no key)
The Yr app (no.nrk.yr) exposes no data to third parties; its backend,
api.met.no locationforecast 2.0, works with no API key (User-Agent header
only). New toggle 'Show Current Weather' under Display, right after 'Show
App Icons'. The home element is formatted exactly like the daily word and
shows a nerd-font weather glyph (nf-weather-*, from the installed
JetBrainsMonoNerdFont) plus the temperature in Celsius, e.g. '\uE312 16°'.

A 95KB subset of JetBrainsMonoNerdFont (ASCII + weather glyphs, OFL) is
bundled as R.font.jetbrains_mono_nf_weather so the glyphs render even if
the launcher font setting changes.

Uses the launcher's existing saved location (lat/lon prefs).
2026-08-19 11:15:21 +02:00
5288c980d0 feat: notification dots on app icons
New NotificationBadgeService (NotificationListenerService) tracks pending,
dismissible notifications per app. Home, favorite, and drawer icons get a
red dot in the top-right corner when the app has active notifications.

Settings: 'Notification Dots' toggle under Display features; enabling it
opens the system notification-access screen when permission is missing.
Dots update live via a StateFlow observed by the fragments.
2026-08-19 10:53:39 +02:00
0aaf00988d feat: scroll app list from anywhere on screen; gate search item animation
DrawFragment: app-list touch area now forwards vertical drags/flings to the
drawer RecyclerView, so the list scrolls even when grabbing empty space
right of the app names. OnSwipeTouchListener gained no-op vertical scroll
and fling hooks.

When animations are disabled, drop the RecyclerView item animator so
DiffUtil search updates no longer pan entries up into place.
2026-08-19 10:39:40 +02:00
e69796535f fix: next alarm time follows system 24-hour format setting
getNextAlarm hardcoded 'hh:mm a' (12-hour). Now uses
DateFormat.is24HourFormat to pick HH:mm vs hh:mm a.
2026-08-19 10:18:57 +02:00
16a75a9e7e release build v0.3.4
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
Nightly Release / release (push) Has been cancelled
Nightly Release / Build, Sign & Release (push) Has been cancelled
Delete Unused Caches / delete (push) Has been cancelled
Close Inactive Issues & Pull Requests / close-issues (push) Has been cancelled
2026-08-18 10:22:03 +02:00
bea62e8d88 better fuzzy search
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
2026-08-18 10:13:25 +02:00
e81b4e8ac1 ignore .pi 2026-08-18 10:13:11 +02:00
45 changed files with 1045 additions and 176 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ git-cliff*
/app/build
/app/debug
diff.*
/.pi

131
CLAUDE.md Normal file
View File

@@ -0,0 +1,131 @@
# Working with this EasyLauncher fork
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
installed on the Unihertz Titan 2 Elite as `app.easy.launcher`).
**PROJECT.md in ~/titan2-elite is the source of truth for the phone itself.**
## Golden rules
1. The installed app id is **`app.easy.launcher`** — NOT upstream's
`com.github.droidworksstudio.launcher`. Every fork build must keep this id.
2. Release APKs are signed with `~/android-keystores/easylauncher-release.jks`
(alias `easylauncher`, pass in `easylauncher-release.pass`). Never commit an
unsigned or differently-signed APK to `dist/`.
3. `adb install -r dist/...-Signed.apk` updates the phone in place (same
signature). A debug build cannot be installed over it (signature mismatch).
4. After a phone reboot the phone is CE-locked until the user enters the PIN;
app prefs/location are not visible to the app until then. Ask the user to
unlock instead of debugging it.
5. Never fetch weather without a real location (lat/lon 0,0 must be treated as
"no location").
## Build
```bash
export JAVA_HOME=~/jdk21 # Gradle 8.13, compileSdk/targetSdk 36, minSdk 24
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
```
- Two flavors: `withInternet` (INTERNET + location permissions, "Easy Launcher")
and `withoutInternet`.
- Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
- `weather.properties` (OpenWeatherMap key) is optional and absent here — the
OWM weather widget is dead without it. The home-screen current-weather
element uses the MET/Yr API instead (no key needed, see below).
## Release flow (see git history for the v0.3.x pattern)
1. Bump `versionCode`/`versionName` in `app/build.gradle.kts`
(currently 35 / 0.3.5).
2. Build both flavors, then sign each:
```bash
PASS=$(cat ~/android-keystores/easylauncher-release.pass)
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
--out dist/EasyLauncher-Internet-v0.3.5-Signed.apk \
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.5-Release.apk
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.5-Signed.apk
```
3. `apksigner verify --print-certs` should show the keystore SHA-256.
4. Commit as `release build vX.Y.Z` (includes the dist APKs + .idsig files),
annotated tag `vX.Y.Z`, push `main` + tag to `origin` (Gitea), create a
Gitea release with both APKs as assets.
## On-device testing (Titan 2 Elite via adb)
- Wake: `input keyevent KEYCODE_WAKEUP`; go home: `input keyevent KEYCODE_HOME`.
- Open the app drawer: `input keyevent KEYCODE_SPACE` (the physical-keyboard
"key press -> app list" trigger; requires MainActivity focused).
- Scroll the drawer from the right edge: `input swipe 1000 850 1000 250 250`.
- Screenshot: `adb exec-out screencap -p > s.png`; view text via
`uiautomator dump`.
- Inspect the launcher's own state with `su -c '...'` (root via Magisk).
### Editing app prefs from the host (fragile — read carefully)
Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
1. Pull the file, edit locally, push back via stdin:
`adb shell "su -c 'cat > <path>'" < localfile`
2. **The file name must end in `.xml`** (`EasyLauncher.pref.xml`,
`EasyWeather.pref.xml`) — `getSharedPreferences("name")` appends `.xml`.
A missing suffix silently reads as an EMPTY map.
3. **Run `restorecon -F <file>`** — files created via `su cat` get the wrong
SELinux context (`s0` instead of `s0:c18,c257,c512,c768`) and the app can't
read them (silent empty map).
4. `am force-stop app.easy.launcher` then press HOME so the process restarts
and re-reads the file.
### Notification dots
- Service: `.service.NotificationBadgeService` (NotificationListenerService).
Users must grant notification access; the "Notification Dots" settings toggle
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
- Grant from adb:
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
- Test notification: `cmd notification post -t 'Title' tag 'body'` — posts as
`com.android.shell`, which has no launcher icon, so no dot is visible; verify
visually with a real app notification instead.
- Counts live in `NotificationBadgeService.notificationCounts`
(StateFlow, key `"userId/packageName"`); fragments rebind visible rows.
### Home current-weather element (MET/Yr)
- Source: `https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
key**, but REQUIRES a descriptive `User-Agent` header (see `MetApiService`).
Verified working from PC and phone network.
- `AppHelper.fetchMetWeather(context, lat, lon)` returns Celsius (rounded) +
MET `symbol_code`; 15-minute cache in `met_weather_prefs`.
- The icon is a **Nerd Font weather glyph** (`nf-weather-*`, U+E300+ PUA),
mapped from the symbol code in `MetSymbolMapper`. It renders with the bundled
`R.font.jetbrains_mono_nf_weather` — a ~95KB fontTools subset of
JetBrainsMonoNerdFont (ASCII + 38 weather glyphs + °). The phone's system
font IS `JetBrainsMonoNerdFont` (fonts.xml default), but the bundled subset
guarantees rendering even if the launcher-font setting changes. Regenerate
the subset with fontTools if more glyphs are ever needed (source:
`/system/fonts/JetBrainsMonoNerdFont-Regular.ttf`).
- Display mirrors the daily word (uses the daily-word color/size/alignment
prefs); text = `"$glyph $temp°"` (two spaces).
- Needs a real saved location: `EasyWeather.pref.xml` keys `LATITUDE`/
`LONGITUDE` (floats). MainActivity saves the real fix there; for testing you
can seed e.g. Haugesund 59.4138 / 5.2680 (remember the `.xml` suffix +
`restorecon`!).
## Known behavior quirks
- The accessibility-service dialog ("Please turn on accessibility service to
use double tap to lock") pops on home when `ActionService` isn't running.
Enable the service in system settings to suppress it during testing.
- `Application.setCustomFont` patches the `Typeface` DEFAULT/MONO/SERIF/SANS
static fields when a non-System launcher font is selected.
- With "Disable Animations" on, the drawer RecyclerView item animator is null
and navigation transitions are skipped.
- Drawer whole-screen scrolling: `appListTouchArea` forwards vertical drags/
flings through `OnSwipeTouchListener` hooks (`onVerticalScroll`/`onVerticalFling`).
Signs were verified against AOSP source: `scrollBy(0, distanceY)` is
pass-through; fling takes the NEGATED pointer velocity.

View File

@@ -19,8 +19,8 @@ android {
applicationId = "app.easy.launcher"
minSdk = 24
targetSdk = 36
versionCode = 33
versionName = "0.3.3"
versionCode = 35
versionName = "0.3.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"

View File

@@ -103,6 +103,15 @@
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
</service>
<service
android:name=".service.NotificationBadgeService"
android:exported="true"
android:label="@string/notification_badge_service_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -0,0 +1 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL

View File

@@ -2,65 +2,133 @@ package com.github.droidworksstudio.fuzzywuzzy
import com.github.droidworksstudio.launcher.data.entities.AppInfo
import java.text.Normalizer
import java.util.*
/**
* Fast in-memory fuzzy search index for app names.
*
* Matching runs on a normalized form of the app name: uppercase, without
* diacritical marks and without separators, so "fdroid" finds "F-Droid".
* A query matches when all of its characters appear in the name in order
* (a subsequence match), but not necessarily consecutively: "fro" finds
* "F-Droid" too.
*
* The normalized names and word boundaries are precomputed once per app list
* ([buildIndex]), so scoring one keystroke is a plain scan over plain strings
* — no regex, no allocations per app, no database query. A few hundred apps
* score in well under a millisecond.
*/
object FuzzyFinder {
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
val appChars = app.appName
val fuzzyScore = calculateFuzzyScore(
normalizeString(appChars),
normalizeString(searchChars)
)
/** One app with its precomputed search data. */
class IndexedApp(val app: AppInfo) {
/** App name, uppercased, without diacritics and separators. */
val normalizedName: String = normalize(app.appName)
return (fuzzyScore * topScore).toInt()
/** True at positions in [normalizedName] that begin a word. */
val wordStarts: BooleanArray = wordStartsOf(app.appName, normalizedName)
}
fun normalizeString(appLabel: String, searchChars: String): Boolean {
return (appLabel.contains(searchChars, true) or
Normalizer.normalize(appLabel, Normalizer.Form.NFD)
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "")
.replace(Regex("[-_+,. ]"), "")
.contains(searchChars, true))
/** Precomputes search data for a list of apps. Call once per app list change. */
fun buildIndex(apps: List<AppInfo>): List<IndexedApp> = apps.map(::IndexedApp)
/** Normalizes a name or a query: uppercase, no diacritics, no separators. */
fun normalize(input: String): String {
val nfd = Normalizer.normalize(input, Normalizer.Form.NFD)
val sb = StringBuilder(nfd.length)
for (ch in nfd) {
if (ch.isLetterOrDigit()) {
sb.append(ch.uppercaseChar())
}
}
return sb.toString()
}
private fun normalizeString(input: String): String {
// Remove diacritical marks and special characters, and convert to uppercase
return input
.uppercase(Locale.getDefault())
.replace(Regex("[\\p{InCombiningDiacriticalMarks}-_+,.]"), "")
}
private fun wordStartsOf(original: String, normalized: String): BooleanArray {
val nfd = Normalizer.normalize(original, Normalizer.Form.NFD)
val starts = BooleanArray(normalized.length)
var atWordStart = true
var prevWasLower = false
var normalizedIndex = 0
for (ch in nfd) {
when {
ch.isLetterOrDigit() -> {
// Camel case: "easyLauncher" starts a word at the uppercase letter.
starts[normalizedIndex] = atWordStart || (prevWasLower && ch.isUpperCase())
atWordStart = false
prevWasLower = ch.isLowerCase()
normalizedIndex++
}
private fun calculateFuzzyScore(s1: String, s2: String): Float {
val m = s1.length
val n = s2.length
var matchCount = 0
var s1Index = 0
Character.getType(ch) == Character.NON_SPACING_MARK.toInt() -> {
// Combining mark left over from NFD (the accent of "é") — ignore.
}
// Iterate over each character in s2 and check if it exists in s1
for (c2 in s2) {
var found = false
// Start searching for c2 from the current s1Index
for (j in s1Index until m) {
if (s1[j] == c2) {
found = true
// Update s1Index to the next position for the next iteration
s1Index = j + 1
break
else -> {
// Separator: the next letter begins a new word.
atWordStart = true
prevWasLower = false
}
}
}
return starts
}
// If the current character in s2 is not found in s1, return a score of 0
if (!found) {
return 0f
}
/**
* Scores [entry] against an already normalized [query] (see [normalize]).
*
* Returns 0 when the query is not a subsequence of the app name, otherwise
* a score from 1 to 100. Matches at the start of the name, consecutive
* runs and word boundaries score higher, so the best match ranks on top.
* An exact match always scores 100.
*/
fun score(entry: IndexedApp, query: String): Int {
val name = entry.normalizedName
val qLen = query.length
val nLen = name.length
if (qLen == 0) return 100
if (qLen > nLen) return 0
// Increment the match count
matchCount++
var nameIndex = 0
var prevMatch = -2
var run = 0
var runPoints = 0
var boundaryHits = 0
var firstMatch = -1
for (i in 0 until qLen) {
val c = query[i]
while (nameIndex < nLen && name[nameIndex] != c) nameIndex++
if (nameIndex == nLen) return 0
if (firstMatch < 0) firstMatch = nameIndex
run = if (nameIndex == prevMatch + 1) run + 1 else 1
runPoints += run
if (entry.wordStarts[nameIndex]) boundaryHits++
prevMatch = nameIndex
nameIndex++
}
// Calculate the score as the ratio of matched characters to the longer string length
return matchCount.toFloat() / maxOf(m, n)
if (firstMatch == 0 && qLen == nLen) return 100 // exact match
// Where does the match start? 40 points at the start, falling off quickly.
val startScore = when {
firstMatch == 0 -> 40
firstMatch == 1 -> 30
else -> (20 - firstMatch).coerceAtLeast(0)
}
// Are the query characters consecutive? Up to 30 points. runPoints is
// the sum of run lengths: 1 + 2 + ... + qLen for a perfectly
// consecutive match, qLen for a fully scattered one.
val consecutiveScore = if (qLen > 1) {
val scattered = runPoints.toDouble() / qLen
((scattered - 1.0) / ((qLen + 1.0) / 2.0 - 1.0)).coerceIn(0.0, 1.0)
} else {
0.0
}
// Do matches hit word starts? Up to 30 points.
val boundaryScore = boundaryHits.toDouble() / qLen
return (startScore + 30 * consecutiveScore + 30 * boundaryScore).toInt()
}
}

View File

@@ -1,6 +1,5 @@
package com.github.droidworksstudio.launcher.adapter.drawer
import android.annotation.SuppressLint
import android.view.LayoutInflater
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@@ -49,10 +48,4 @@ class DrawAdapter(
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
oldItem == newItem
}
@SuppressLint("NotifyDataSetChanged")
fun updateDataWithStateFlow(newData: List<AppInfo>) {
submitList(newData.toMutableList())
notifyDataSetChanged()
}
}

View File

@@ -49,9 +49,6 @@ interface AppInfoDAO {
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
fun getLockAppsFlow(): Flow<List<AppInfo>>
@Query("SELECT * FROM app WHERE is_hidden = 0 ORDER BY app_name COLLATE NOCASE ASC")
fun searchApps(): Flow<List<AppInfo>>
@Update
suspend fun updateAppInfo(appInfo: AppInfo)

View File

@@ -33,14 +33,18 @@ import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.accessibility.ActionService
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.MetApiService
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
import com.github.droidworksstudio.launcher.utils.WeatherApiService
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.UnknownHostException
@@ -264,7 +268,9 @@ class AppHelper @Inject constructor() {
if (nextAlarmClock == null) return "No alarm is set."
val alarmTime = nextAlarmClock.triggerTime
val formattedTime = SimpleDateFormat("EEE, MMM d hh:mm a", Locale.getDefault()).format(alarmTime)
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
val timePattern = if (is24Hour) "EEE, MMM d HH:mm" else "EEE, MMM d hh:mm a"
val formattedTime = SimpleDateFormat(timePattern, Locale.getDefault()).format(alarmTime)
val drawable = AppCompatResources.getDrawable(context, R.drawable.ic_alarm_clock)
val fontSize = TypedValue.applyDimension(
@@ -550,4 +556,91 @@ class AppHelper @Inject constructor() {
// Data class to hold cached weather data along with timestamp
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
sealed class MetWeatherResult {
data class Success(val temperature: Int, val symbolCode: String) : MetWeatherResult()
data class Failure(val errorMessage: String) : MetWeatherResult()
}
/**
* Fetches the current temperature and symbol from the MET/Yr API
* (api.met.no locationforecast). No API key needed - only a
* descriptive User-Agent header. Result is cached for 15 minutes.
*/
fun fetchMetWeather(
context: Context,
latitude: Float,
longitude: Float,
): MetWeatherResult {
if (latitude == 0f && longitude == 0f) {
return MetWeatherResult.Failure("No location available")
}
val cached = context.getMetWeatherFromCache()
if (cached?.let {
System.currentTimeMillis() - it.first < TimeUnit.MINUTES.toMillis(15)
} == true
) {
return cached.second
}
try {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.met.no/weatherapi/locationforecast/2.0/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(MetApiService::class.java)
val response = service.getCompact(latitude.toDouble(), longitude.toDouble()).execute()
if (response.isSuccessful) {
val body = response.body()
val timeseries = body?.properties?.timeseries
val first = timeseries?.firstOrNull()
if (first != null) {
val temperature = first.data.instant.details.airTemperature.roundToInt()
val symbolCode = first.data.next1Hours?.summary?.symbolCode
?: first.data.next6Hours?.summary?.symbolCode
?: "cloudy"
val result = MetWeatherResult.Success(temperature, symbolCode)
context.cacheMetWeather(result)
return result
}
return MetWeatherResult.Failure("Empty forecast")
} else {
return MetWeatherResult.Failure("MET API error: ${response.code()}")
}
} catch (e: UnknownHostException) {
Log.e("AppHelper", "MET fetch: unknown host api.met.no", e)
return MetWeatherResult.Failure("Unknown host: api.met.no")
} catch (e: Exception) {
Log.e("AppHelper", "MET fetch failed", e)
return MetWeatherResult.Failure(e.message ?: "MET fetch failed")
}
}
/**
* Builds the home-screen weather text: nerd-font weather glyph followed
* by the temperature in Celsius, e.g. "\uE312 16°".
*/
fun buildCurrentWeatherText(temperature: Int, symbolCode: String): String {
return "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
}
private fun Context.cacheMetWeather(result: MetWeatherResult.Success) {
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
sharedPreferences.edit()
.putLong(Constants.MET_WEATHER_TIMESTAMP, System.currentTimeMillis())
.putInt(Constants.MET_WEATHER_TEMPERATURE, result.temperature)
.putString(Constants.MET_WEATHER_SYMBOL, result.symbolCode)
.apply()
}
private fun Context.getMetWeatherFromCache(): Pair<Long, MetWeatherResult>? {
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
val timestamp = sharedPreferences.getLong(Constants.MET_WEATHER_TIMESTAMP, -1L)
if (timestamp == -1L) return null
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
val symbol = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL, "cloudy") ?: "cloudy"
return timestamp to MetWeatherResult.Success(temperature, symbol)
}
}

View File

@@ -198,6 +198,14 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
set(value) = prefs.edit().putBoolean(Constants.DISABLE_ANIMATIONS, value).apply()
var showNotificationDots: Boolean
get() = prefs.getBoolean(Constants.SHOW_NOTIFICATION_DOTS, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_NOTIFICATION_DOTS, value).apply()
var showCurrentWeather: Boolean
get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply()
var searchEngines: Constants.SearchEngines
get() {
return try {

View File

@@ -0,0 +1,46 @@
package com.github.droidworksstudio.launcher.helper.weather
import com.google.gson.annotations.SerializedName
/**
* Locationforecast 2.0 compact response (api.met.no) - only the fields the
* home-screen current-weather element needs.
*/
data class MetForecastResponse(
val properties: MetProperties
)
data class MetProperties(
val timeseries: List<MetTimeSeries>
)
data class MetTimeSeries(
val time: String,
val data: MetData
)
data class MetData(
val instant: MetInstant,
@SerializedName("next_1_hours")
val next1Hours: MetNextHours? = null,
@SerializedName("next_6_hours")
val next6Hours: MetNextHours? = null
)
data class MetInstant(
val details: MetDetails
)
data class MetDetails(
@SerializedName("air_temperature")
val airTemperature: Double = 0.0
)
data class MetNextHours(
val summary: MetSummary
)
data class MetSummary(
@SerializedName("symbol_code")
val symbolCode: String = ""
)

View File

@@ -90,6 +90,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
}
// Vertical swipe
else {
onVerticalScroll(distanceY)
if (abs(diffY) > swipeThreshold && abs(distanceY) > swipeScrollThreshold) {
if (diffY > 0) onSwipeDown() else onSwipeUp()
}
@@ -115,6 +116,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
}
} else {
if (abs(diffY) > swipeThreshold && abs(velocityY) > swipeVelocityThreshold) {
onVerticalFling(velocityY)
if (diffY < 0) onSwipeUp() else onSwipeDown()
}
}
@@ -129,6 +131,21 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
open fun onSwipeLeft() {}
open fun onSwipeUp() {}
open fun onSwipeDown() {}
/**
* Called for every vertical drag movement (per-event delta, same sign
* convention as RecyclerView's own touch handling: positive when the
* finger moves up). Default is a no-op.
*/
open fun onVerticalScroll(distanceY: Float) {}
/**
* Called on a vertical fling with the raw pointer velocity in pixels per
* second (positive when the finger moved down, same convention as
* VelocityTracker.getYVelocity()). Default is a no-op.
*/
open fun onVerticalFling(velocityY: Float) {}
open fun onLongClick() {}
open fun onDoubleClick() {}
open fun onTripleClick() {}

View File

@@ -53,10 +53,6 @@ class AppInfoRepository @Inject constructor(
}
}
fun searchNote(): Flow<List<AppInfo>> {
return appDao.searchApps()
}
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {
if (appInfo.favorite) {

View File

@@ -0,0 +1,76 @@
package com.github.droidworksstudio.launcher.service
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks pending (dismissible) notifications per app so the launcher can show
* a red dot on app icons.
*
* The user must grant notification access in the system settings
* (Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) before this service
* receives any events. Android rebinds it automatically after every boot.
*/
class NotificationBadgeService : NotificationListenerService() {
/** Notification key -> "userId/packageName". Keyed to dedupe updates. */
private val keyToPackage = HashMap<String, String>()
override fun onListenerConnected() {
super.onListenerConnected()
synchronized(keyToPackage) {
keyToPackage.clear()
activeNotifications.forEach { sbn ->
if (isCounted(sbn)) keyToPackage[sbn.key] = userIdKey(sbn)
}
publish()
}
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (!isCounted(sbn)) return
synchronized(keyToPackage) {
// A posted notification with an existing key is just an update.
if (keyToPackage.containsKey(sbn.key)) return
keyToPackage[sbn.key] = userIdKey(sbn)
publish()
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
synchronized(keyToPackage) {
if (keyToPackage.remove(sbn.key) != null) publish()
}
}
/** Only swipe-away, non-ongoing notifications get a dot. */
private fun isCounted(sbn: StatusBarNotification): Boolean {
val notification = sbn.notification
if (!sbn.isClearable) return false
if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return false
return true
}
private fun userIdKey(sbn: StatusBarNotification) =
"${sbn.userId}/" + sbn.packageName
private fun publish() {
val counts = HashMap<String, Int>()
keyToPackage.values.forEach { key ->
counts[key] = (counts[key] ?: 0) + 1
}
notificationCounts.value = counts
}
companion object {
/** Active notification count per app, keyed by "userId/packageName". */
val notificationCounts: MutableStateFlow<Map<String, Int>> =
MutableStateFlow(emptyMap())
fun hasNotifications(userId: Int, packageName: String): Boolean =
notificationCounts.value["$userId/$packageName"]?.let { it > 0 } ?: false
}
}

View File

@@ -40,6 +40,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import kotlin.math.roundToInt
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
@@ -81,6 +83,12 @@ class DrawFragment : Fragment(),
)
}
/**
* In-memory search index, rebuilt whenever the app list changes. Searching
* reads only from this index — no database query per keystroke.
*/
private var searchIndex: List<FuzzyFinder.IndexedApp> = emptyList()
private lateinit var context: Context
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@@ -104,6 +112,7 @@ class DrawFragment : Fragment(),
observeClickListener()
observeSwipeTouchListener()
observeScrollTouchListener()
observeNotificationBadges()
// Initialize observation of drawer apps
observeDrawerApps()
@@ -151,6 +160,30 @@ class DrawFragment : Fragment(),
setHasFixedSize(false)
layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
isNestedScrollingEnabled = false
// Search results update the list with DiffUtil. When animations
// are disabled, drop the item animator so entries don't "pan-up"
// into place while filtering.
if (preferenceHelper.disableAnimations) itemAnimator = null
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.drawAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is DrawViewHolder) {
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
holder.bind(drawAdapter.currentList[position])
}
}
}
}
}
}
}
@@ -165,10 +198,15 @@ class DrawFragment : Fragment(),
repeatOnLifecycle(Lifecycle.State.CREATED) {
// Collect the drawer apps from the ViewModel
viewModel.drawApps.collect { apps ->
// Update the adapter with the new list of apps
drawAdapter.submitList(apps)
// Update the adapter's data with the new state flow
drawAdapter.updateDataWithStateFlow(apps)
// Rebuild the search index from the fresh app list
searchIndex = FuzzyFinder.buildIndex(apps)
// Keep whatever search results are currently shown, or show the full list
val query = binding.searchViewText.query?.toString().orEmpty()
if (query.isBlank()) {
drawAdapter.submitList(apps)
} else {
drawAdapter.submitList(performSearch(query))
}
}
}
}
@@ -185,8 +223,15 @@ class DrawFragment : Fragment(),
val searchQuery = trimmedQuery.substringAfter("!")
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
} else {
searchApp(trimmedQuery, false)
return true // Exit the function
val results = performSearch(trimmedQuery)
if (results.isEmpty()) {
if (!requireContext().searchOnPlayStore(trimmedQuery)) {
requireContext().openSearch(trimmedQuery)
}
} else {
observeBioAuthCheck(results.first())
drawAdapter.submitList(results)
}
}
}
}
@@ -194,7 +239,13 @@ class DrawFragment : Fragment(),
}
override fun onQueryTextChange(newText: String?): Boolean {
searchApp(newText.toString(), true)
val query = newText.orEmpty()
val results = performSearch(query)
// Auto-open when exactly one app matches the query
if (query.isNotBlank() && results.size == 1 && preferenceHelper.automaticOpenApp) {
observeBioAuthCheck(results.first())
}
drawAdapter.submitList(results)
return true
}
})
@@ -213,7 +264,7 @@ class DrawFragment : Fragment(),
binding.apply {
mainView.setOnTouchListener(getSwipeGestureListener(context))
touchArea.setOnTouchListener(getSwipeGestureListener(context))
appListTouchArea.setOnTouchListener(getSwipeGestureListener(context))
appListTouchArea.setOnTouchListener(getAppListSwipeGestureListener(context))
}
}
@@ -278,123 +329,102 @@ class DrawFragment : Fragment(),
return object : OnSwipeTouchListener(context, preferenceHelper) {
override fun onSwipeLeft() {
super.onSwipeLeft()
val actionTypeNavOptions: NavOptions? =
if (preferenceHelper.disableAnimations) null
else appHelper.getActionType(Constants.Swipe.Left)
Handler(Looper.getMainLooper()).post {
findNavController().navigate(
R.id.HomeFragment,
null,
actionTypeNavOptions
)
}
navigateToHome(Constants.Swipe.Left)
}
override fun onSwipeRight() {
super.onSwipeRight()
val actionTypeNavOptions: NavOptions? =
if (preferenceHelper.disableAnimations) null
else appHelper.getActionType(Constants.Swipe.Right)
Handler(Looper.getMainLooper()).post {
findNavController().navigate(
R.id.HomeFragment,
null,
actionTypeNavOptions
)
}
navigateToHome(Constants.Swipe.Right)
}
}
}
private fun searchApp(query: String, isSearching: Boolean) {
// Launch a coroutine tied to the lifecycle of the view
viewLifecycleOwner.lifecycleScope.launch {
// Repeat the block when the lifecycle is at least CREATED
repeatOnLifecycle(Lifecycle.State.CREATED) {
val trimmedQuery = query.trim()
/**
* Swipe listener for the app list area.
*
* Behaves like [getSwipeGestureListener] (left/right swipes navigate
* home), but vertical drags and flings over the empty space around the
* list (e.g. right of the app names) scroll the list itself. This makes
* the drawer scrollable from the whole screen, not just the app rows.
*/
private fun getAppListSwipeGestureListener(context: Context): View.OnTouchListener {
return object : OnSwipeTouchListener(context, preferenceHelper) {
override fun onSwipeLeft() {
super.onSwipeLeft()
navigateToHome(Constants.Swipe.Left)
}
// Collect search results from the ViewModel
viewModel.searchAppInfo().collect { searchResults ->
// Filter and score results using FuzzyFinder
val filteredResults = searchResults
.map { appInfo ->
val score = FuzzyFinder.scoreApp(appInfo, trimmedQuery, Constants.FILTER_STRENGTH_MAX)
appInfo to score // Pairing app info with its score
}
.filter { it.second > 25 } // Only keep results with a positive score
.sortedByDescending { it.second } // Sort results by score, descending
override fun onSwipeRight() {
super.onSwipeRight()
navigateToHome(Constants.Swipe.Right)
}
// Applying additional filtering based on preferences
val scoredApps = filteredResults.toMap()
override fun onVerticalScroll(distanceY: Float) {
super.onVerticalScroll(distanceY)
binding.drawAdapter.scrollBy(0, distanceY.roundToInt())
}
val finalResults = if (preferenceHelper.filterStrength >= 1) {
// Filtering based on score strength
if (preferenceHelper.searchFromStart) {
// Filter apps that start with the search query and score higher than the filter strength
scoredApps.filter { (app, _) ->
app.appName.startsWith(trimmedQuery, ignoreCase = true)
}
.filter { (_, score) -> score > preferenceHelper.filterStrength }
.map { it.key }
.toMutableList()
} else {
// Filter based on score strength alone
scoredApps.filterValues { it > preferenceHelper.filterStrength }
.keys
.toMutableList()
}
} else {
if (preferenceHelper.searchFromStart) {
// Filter apps that start with the search query and score higher than the filter strength
searchResults.filter { app ->
FuzzyFinder.normalizeString(app.appName, trimmedQuery) ||
app.appName.startsWith(trimmedQuery, ignoreCase = true)
}.toMutableList()
} else {
// If filter strength is less than 1, normalize app names for both cases
searchResults.filter { app ->
FuzzyFinder.normalizeString(app.appName, trimmedQuery)
}.toMutableList()
}
}
val numberOfItemsLeft = finalResults.size
val appResults = finalResults.firstOrNull()
if (isSearching) {
when (numberOfItemsLeft) {
1 -> {
appResults?.let { appInfo ->
if (preferenceHelper.automaticOpenApp) observeBioAuthCheck(appInfo)
}
drawAdapter.submitList(finalResults)
}
else -> {
drawAdapter.submitList(finalResults)
}
}
if (trimmedQuery.isEmpty()) {
drawAdapter.submitList(searchResults)
}
} else {
if (numberOfItemsLeft == 0 && !requireContext().searchOnPlayStore(trimmedQuery)) {
requireContext().openSearch(trimmedQuery)
} else {
appResults?.let { appInfo ->
observeBioAuthCheck(appInfo)
}
drawAdapter.submitList(searchResults)
}
}
}
override fun onVerticalFling(velocityY: Float) {
super.onVerticalFling(velocityY)
// RecyclerView.fling expects the negated pointer velocity
// (the same negation RecyclerView applies to its own
// VelocityTracker value in onTouchEvent).
binding.drawAdapter.fling(0, -velocityY.roundToInt())
}
}
}
private fun navigateToHome(swipe: Constants.Swipe) {
val actionTypeNavOptions: NavOptions? =
if (preferenceHelper.disableAnimations) null
else appHelper.getActionType(swipe)
Handler(Looper.getMainLooper()).post {
findNavController().navigate(
R.id.HomeFragment,
null,
actionTypeNavOptions
)
}
}
/**
* Runs the fuzzy search synchronously over the in-memory index.
*
* Called on every keystroke, including the very first character. This is
* fast enough to run on the main thread — the index holds plain strings
* and a few hundred apps score in well under a millisecond — so results
* appear in the same frame the character is typed.
*/
private fun performSearch(query: String): List<AppInfo> {
val index = searchIndex
val trimmedQuery = query.trim()
if (trimmedQuery.isEmpty()) return index.map { it.app }
// Normalize the query once, not once per app
val normalizedQuery = FuzzyFinder.normalize(trimmedQuery)
val minScore = preferenceHelper.filterStrength
val searchFromStart = preferenceHelper.searchFromStart
val scored = ArrayList<Pair<FuzzyFinder.IndexedApp, Int>>()
for (entry in index) {
val score = FuzzyFinder.score(entry, normalizedQuery)
// Compare against the normalized name, so "fdroid" matches "F-Droid"
val matchesFromStart = !searchFromStart ||
entry.normalizedName.startsWith(normalizedQuery)
if (score > minScore && matchesFromStart) {
scored.add(entry to score)
}
}
// Best score first, alphabetical as a tie breaker
scored.sortWith(
compareByDescending<Pair<FuzzyFinder.IndexedApp, Int>> { it.second }
.thenBy { it.first.app.appName.lowercase() }
)
return scored.map { it.first.app }
}
private fun showSelectedApp(appInfo: AppInfo) {
binding.searchViewText.setQuery("", false)

View File

@@ -14,7 +14,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
class DrawViewHolder(
private val binding: ItemDrawBinding,
@@ -70,7 +72,20 @@ class DrawViewHolder(
}
appDrawIcon.layoutParams = layoutParams
appDrawIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
appDrawIcon.setImageDrawable(
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(itemView.context, baseIcon)
} else {
baseIcon
}
)
appDrawIcon.visibility = View.VISIBLE
val parentLayout = appDrawName.parent as LinearLayoutCompat

View File

@@ -11,7 +11,9 @@ import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@@ -28,6 +30,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.ui.favorite.FavoriteViewHolder
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -82,6 +86,7 @@ class FavoriteFragment : Fragment(),
setupRecyclerView()
observeFavorite()
observeHomeAppOrder()
observeNotificationBadges()
observeSwipeTouchListener()
}
@@ -128,6 +133,25 @@ class FavoriteFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.favoriteAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is FavoriteViewHolder) {
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
holder.bind(favoriteAdapter.currentList[position])
}
}
}
}
}
}
}
private fun observeHomeAppOrder() {
binding.favoriteAdapter.adapter = favoriteAdapter
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter

View File

@@ -11,6 +11,8 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
@SuppressLint("ClickableViewAccessibility")
class FavoriteViewHolder(
@@ -48,7 +50,18 @@ class FavoriteViewHolder(
if (preferenceHelper.showAppIcon) {
val appIcon =
binding.root.context.packageManager.getApplicationIcon(appInfo.packageName)
appFavoriteLeftIcon.setImageDrawable(appIcon)
val baseIcon: android.graphics.drawable.Drawable =
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(binding.root.context, appIcon)
} else {
appIcon
}
appFavoriteLeftIcon.setImageDrawable(baseIcon)
appFavoriteLeftIcon.layoutParams.width =
preferenceHelper.appTextSize.toInt() * 3
appFavoriteLeftIcon.layoutParams.height =

View File

@@ -22,9 +22,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatTextView
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.StaggeredGridLayoutManager
@@ -46,6 +49,7 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
@@ -54,6 +58,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -111,6 +116,12 @@ class HomeFragment : Fragment(),
setupRecyclerView()
observeSwipeTouchListener()
observeUserInterfaceSettings()
// Nerd-font weather glyphs (nf-weather-*) live in the bundled
// subset font; the system font here is JetBrainsMonoNerdFont but
// this guarantees rendering even if the launcher font is changed.
binding.currentWeather.typeface =
ResourcesCompat.getFont(requireContext(), R.font.jetbrains_mono_nf_weather)
}
@SuppressLint("ClickableViewAccessibility")
@@ -232,6 +243,25 @@ class HomeFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.appListAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is HomeViewHolder) {
val position = holder.bindingAdapterPosition
if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) {
holder.bind(homeAdapter.currentList[position])
}
}
}
}
}
}
}
@SuppressLint("ClickableViewAccessibility", "InflateParams")
private fun observeSwipeTouchListener() {
binding.apply {
@@ -250,6 +280,7 @@ class HomeFragment : Fragment(),
preferenceViewModel.setShowDate(preferenceHelper.showDate)
preferenceViewModel.setShowAlarmClock(preferenceHelper.showAlarmClock)
preferenceViewModel.setShowDailyWord(preferenceHelper.showDailyWord)
preferenceViewModel.setShowCurrentWeather(preferenceHelper.showCurrentWeather)
preferenceViewModel.setShowBattery(preferenceHelper.showBattery)
preferenceViewModel.showTimeLiveData.observe(viewLifecycleOwner) {
@@ -301,6 +332,17 @@ class HomeFragment : Fragment(),
)
}
preferenceViewModel.showCurrentWeatherLiveData.observe(viewLifecycleOwner) {
appHelper.updateUI(
binding.currentWeather,
preferenceHelper.homeDailyWordAlignment,
preferenceHelper.dailyWordColor,
preferenceHelper.dailyWordTextSize,
it
)
if (it) loadCurrentWeather()
}
binding.apply {
mainView.hideKeyboard()
@@ -321,6 +363,41 @@ class HomeFragment : Fragment(),
}
}
/**
* Fetches the current temperature from the MET/Yr API and shows it as a
* nerd-font weather glyph plus Celsius value, e.g. "\uE312 16°".
* Formatted like the daily word (same alignment/color/size settings).
*/
private fun loadCurrentWeather() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val sharedPreferences =
requireContext().getSharedPreferences(Constants.WEATHER_PREFS, Context.MODE_PRIVATE)
val latitude = sharedPreferences.getFloat(Constants.LATITUDE, 0f)
val longitude = sharedPreferences.getFloat(Constants.LONGITUDE, 0f)
val result = appHelper.fetchMetWeather(requireContext(), latitude, longitude)
withContext(Dispatchers.Main) {
when (result) {
is AppHelper.MetWeatherResult.Success -> {
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
result.temperature,
result.symbolCode
)
binding.currentWeather.visibility = View.VISIBLE
}
is AppHelper.MetWeatherResult.Failure -> {
// No data (offline, no location): keep any cached text,
// otherwise hide the element.
if (binding.currentWeather.text.isNullOrEmpty()) {
binding.currentWeather.visibility = View.GONE
}
}
}
}
}
}
private fun observeBioAuthCheck(appInfo: AppInfo) {
if (!appInfo.lock)
context.launchApp(appInfo)
@@ -584,6 +661,8 @@ class HomeFragment : Fragment(),
binding.mainView.hideKeyboard()
observeUserInterfaceSettings()
observeFavoriteAppList()
observeNotificationBadges()
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
}
override fun onAppClicked(appInfo: AppInfo) {

View File

@@ -15,7 +15,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
import javax.inject.Inject
class HomeViewHolder @Inject constructor(
@@ -70,7 +72,20 @@ class HomeViewHolder @Inject constructor(
}
appHomeIcon.layoutParams = layoutParams
appHomeIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
appHomeIcon.setImageDrawable(
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(itemView.context, baseIcon)
} else {
baseIcon
}
)
appHomeIcon.visibility = View.VISIBLE
val parentLayout = appHomeName.parent as LinearLayoutCompat

View File

@@ -1,8 +1,11 @@
package com.github.droidworksstudio.launcher.ui.settings
import android.content.Context
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -18,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.github.droidworksstudio.common.getAppNameFromPackageName
import com.github.droidworksstudio.common.showLongToast
import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
import com.github.droidworksstudio.launcher.helper.AppHelper
@@ -25,6 +29,7 @@ import com.github.droidworksstudio.launcher.helper.AppReloader
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -129,6 +134,7 @@ class SettingsFeaturesFragment : Fragment(),
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots
}
}
@@ -450,10 +456,37 @@ class SettingsFeaturesFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
showNotificationDotsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowNotificationDots(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
if (isChecked && !isNotificationAccessGranted()) {
openNotificationAccessSettings()
}
}
}
}
private fun isNotificationAccessGranted(): Boolean {
val componentName = ComponentName(requireContext(), NotificationBadgeService::class.java)
val notificationManager =
requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
return notificationManager.isNotificationListenerAccessGranted(componentName)
}
private fun openNotificationAccessSettings() {
try {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
} catch (_: Exception) {
requireContext().showLongToast(
getString(R.string.toast_cannot_open_notification_access_settings)
)
}
}
private var swipeActionDialog: AlertDialog? = null
@RequiresApi(Build.VERSION_CODES.Q)

View File

@@ -95,6 +95,7 @@ class SettingsLookFeelFragment : Fragment(),
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
currentWeatherSwitchCompat.isChecked = preferenceHelper.showCurrentWeather
}
}
@@ -188,6 +189,12 @@ class SettingsLookFeelFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
currentWeatherSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowCurrentWeather(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
}
}

View File

@@ -20,6 +20,10 @@ object Constants {
const val WIDGET_BATTERY = "WIDGET_BATTERY"
const val WEATHER_PREFS = "EasyWeather.pref"
const val MET_WEATHER_PREFS = "met_weather_prefs"
const val MET_WEATHER_TIMESTAMP = "MET_WEATHER_TIMESTAMP"
const val MET_WEATHER_TEMPERATURE = "MET_WEATHER_TEMPERATURE"
const val MET_WEATHER_SYMBOL = "MET_WEATHER_SYMBOL"
const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
const val WEATHER_UNITS = "WEATHER_UNITS"
const val LATITUDE = "LATITUDE"
@@ -70,6 +74,8 @@ object Constants {
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS"
const val SHOW_CURRENT_WEATHER = "SHOW_CURRENT_WEATHER"
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"

View File

@@ -0,0 +1,19 @@
package com.github.droidworksstudio.launcher.utils
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface MetApiService {
@Headers(
"User-Agent: app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
)
@GET("compact")
fun getCompact(
@Query("lat") latitude: Double,
@Query("lon") longitude: Double
): Call<MetForecastResponse>
}

View File

@@ -0,0 +1,57 @@
package com.github.droidworksstudio.launcher.utils
/**
* Maps MET/Yr symbol codes to Nerd Font weather glyphs
* (nf-weather-* codepoints from the Weather Icons font embedded in
* JetBrainsMonoNerdFont). Rendered with R.font.jetbrains_mono_nf_weather.
*/
object MetSymbolMapper {
/**
* Returns the nf-weather glyph char for a MET `symbol_code`
* (e.g. "partlycloudy_day"). Unknown codes fall back to a cloud.
*/
fun glyphFor(symbolCode: String): Char {
val normalized = symbolCode.removeSuffix("_polartwilight")
val night = normalized.endsWith("_night")
val base = normalized
.removeSuffix("_day")
.removeSuffix("_night")
return when (base) {
"clearsky", "fair" -> if (night) '\uE32B' else '\uE30D'
"partlycloudy" -> if (night) '\uE379' else '\uE302'
"cloudy" -> '\uE312'
"fog" -> '\uE313'
"lightrain" -> '\uE31B'
"rain" -> '\uE318'
"heavyrain" -> '\uE319'
"lightrainshowers" -> if (night) '\uE328' else '\uE30B'
"rainshowers" -> if (night) '\uE326' else '\uE309'
"heavyrainshowers" -> if (night) '\uE324' else '\uE307'
"lightrainandthunder" -> '\uE315'
"rainandthunder" -> '\uE31D'
"heavyrainandthunder" -> '\uE31C'
"lightrainshowersandthunder" -> if (night) '\uE322' else '\uE305'
"rainshowersandthunder" -> if (night) '\uE32A' else '\uE30F'
"heavyrainshowersandthunder" -> if (night) '\uE329' else '\uE30E'
"sleet" -> '\uE316'
"lightssleetshowers", "sleetshowers" -> if (night) '\uE323' else '\uE306'
"heavysleetshowers" -> if (night) '\uE364' else '\uE362'
"sleetandthunder",
"lightsleetshowersandthunder",
"sleetshowersandthunder",
-> if (night) '\uE364' else '\uE362'
"lightssnow", "lightsnow", "snow" -> '\uE31A'
"heavysnow" -> '\uE35E'
"lightssnowshowers", "snowshowers" -> if (night) '\uE327' else '\uE30A'
"heavysnowshowers" -> if (night) '\uE361' else '\uE35F'
"snowandthunder",
"snowshowersandthunder",
-> if (night) '\uE367' else '\uE365'
"thunder" -> '\uE31D'
"wind" -> '\uE34B'
else -> '\uE312'
}
}
}

View File

@@ -0,0 +1,51 @@
package com.github.droidworksstudio.launcher.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import com.github.droidworksstudio.common.ColorIconsExtensions
object NotificationDotHelper {
/**
* Returns a copy of [icon] with a red dot drawn in its top-right corner,
* surrounded by a thin white border so it stays visible on busy icons.
*/
fun withDot(context: Context, icon: Drawable): Drawable {
val source = ColorIconsExtensions.drawableToBitmap(icon)
val width = source.width
val height = source.height
if (width <= 0 || height <= 0) return icon
val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f)
val centerX = width - dotRadius * 0.75f
val centerY = dotRadius * 0.75f
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawBitmap(source, 0f, 0f, null)
val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
canvas.drawCircle(centerX, centerY, dotRadius, dotPaint)
val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = (dotRadius * 0.28f).coerceAtLeast(1.5f)
}
canvas.drawCircle(
centerX,
centerY,
dotRadius - borderPaint.strokeWidth / 2f,
borderPaint
)
return BitmapDrawable(context.resources, bitmap)
}
}

View File

@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
appInfoRepository.updateInfo(appInfo)
}
}
fun searchAppInfo() = appInfoRepository.searchNote()
}

View File

@@ -49,6 +49,8 @@ class PreferenceViewModel @Inject constructor(
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData()
val showCurrentWeatherLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
@@ -297,6 +299,16 @@ class PreferenceViewModel @Inject constructor(
disableAnimationsLiveData.postValue((preferenceHelper.disableAnimations))
}
fun setShowNotificationDots(showNotificationDots: Boolean) {
preferenceHelper.showNotificationDots = showNotificationDots
showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots))
}
fun setShowCurrentWeather(showCurrentWeather: Boolean) {
preferenceHelper.showCurrentWeather = showCurrentWeather
showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather))
}
fun setAppLanguage(appLanguage: Constants.Language) {
preferenceHelper.appLanguage = appLanguage
appLanguageLiveData.postValue((preferenceHelper.appLanguage))

Binary file not shown.

View File

@@ -78,6 +78,14 @@
android:textSize="32sp"
android:visibility="gone" />
<TextView
android:id="@+id/currentWeather"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="32sp"
android:visibility="gone" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -238,6 +238,37 @@
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/showNotificationDots_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_display_notification_dots"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/showNotificationDots_switchCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0.7"
android:scaleY="0.8"
android:thumb="@drawable/shape_switch_thumb"
app:track="@drawable/selector_switch"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -300,6 +300,37 @@
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/currentWeather_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_display_current_weather"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/currentWeather_switchCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0.7"
android:scaleY="0.8"
android:thumb="@drawable/shape_switch_thumb"
app:track="@drawable/selector_switch"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -104,12 +104,15 @@
<string name="settings_display_alarm_clock">Show Alarm Clock</string>
<string name="settings_display_daily_word">Show Daily Word</string>
<string name="settings_display_app_icons">Show App Icons</string>
<string name="settings_display_current_weather">Show Current Weather</string>
<string name="settings_display_automatic_keyboard">Auto Show Keyboard</string>
<string name="settings_display_auto_open_apps">Auto Open Last App</string>
<string name="settings_home_alignment_bottom">Home Alignment Bottom</string>
<string name="settings_search_from_start">Search From Start</string>
<string name="settings_display_lock_settings">Lock Settings</string>
<string name="settings_display_disable_animations">Disable Animations</string>
<string name="settings_display_notification_dots">Notification Dots</string>
<string name="toast_cannot_open_notification_access_settings">Cannot open notification access settings.</string>
<string name="settings_appearance_text_size_title">Size</string>
<string name="settings_appearance_color_title">Color</string>
@@ -200,6 +203,7 @@
<string name="accessibility_settings_disable">Disable</string>
<string name="accessibility_service_name">Easy Launcher Actions Service</string>
<string name="notification_badge_service_label">Easy Launcher - notification dots</string>
<string name="accessibility_service_desc">Please turn on accessibility service to use double tap to lock feature in Easy Launcher.\n\nThis permission is used only to turn off your screen. Our
accessibility service does not collect or share any data.</string>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.3-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.4-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.5-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.