Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
id("kotlin-android-extensions")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
implementation(libs.textdrawable)
|
||||
|
||||
api(project(":search"))
|
||||
implementation(project(":preferences"))
|
||||
implementation(project(":ktx"))
|
||||
implementation(project(":base"))
|
||||
implementation(project(":hiddenitems"))
|
||||
implementation(project(":permissions"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.mm20.launcher2.calendar">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import de.mm20.launcher2.hiddenitems.HiddenItemsRepository
|
||||
import de.mm20.launcher2.preferences.LauncherPreferences
|
||||
import de.mm20.launcher2.search.BaseSearchableRepository
|
||||
import de.mm20.launcher2.search.data.CalendarEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class CalendarRepository private constructor(val context: Context) : BaseSearchableRepository() {
|
||||
|
||||
val calendarEvents = MediatorLiveData<List<CalendarEvent>?>()
|
||||
val upcomingCalendarEvents = MutableLiveData<List<CalendarEvent>>(emptyList())
|
||||
|
||||
private val allEvents = MutableLiveData<List<CalendarEvent>?>(emptyList())
|
||||
private val hiddenItemKeys = HiddenItemsRepository.getInstance(context).hiddenItemsKeys
|
||||
|
||||
init {
|
||||
calendarEvents.addSource(hiddenItemKeys) { keys ->
|
||||
calendarEvents.value = allEvents.value?.filter { !keys.contains(it.key) }
|
||||
}
|
||||
calendarEvents.addSource(allEvents) { e ->
|
||||
calendarEvents.value = e?.filter { hiddenItemKeys.value?.contains(it.key) != true }
|
||||
}
|
||||
hiddenItemKeys.observeForever {
|
||||
requestCalendarUpdate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun requestCalendarUpdate() {
|
||||
launch {
|
||||
val unselectedCalendars = LauncherPreferences.instance.unselectedCalendars
|
||||
val hideAlldayEvents = LauncherPreferences.instance.calendarHideAllday
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val end = now + 14 * 24 * 60 * 60 * 1000L
|
||||
val events = withContext(Dispatchers.IO) {
|
||||
CalendarEvent.search(
|
||||
context = context,
|
||||
query = "",
|
||||
intervalStart = now,
|
||||
intervalEnd = end,
|
||||
limit = 700,
|
||||
hideAllDayEvents = hideAlldayEvents,
|
||||
unselectedCalendars = unselectedCalendars,
|
||||
hiddenEvents = hiddenItemKeys.value?.mapNotNull {
|
||||
if (it.startsWith("calendar")) it.substringAfterLast("/").toLong()
|
||||
else null
|
||||
} ?: emptyList()
|
||||
)
|
||||
}
|
||||
upcomingCalendarEvents.value = events
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun search(query: String) {
|
||||
if (query.isBlank()) {
|
||||
allEvents.value = null
|
||||
return
|
||||
}
|
||||
val startTime = System.currentTimeMillis()
|
||||
val endTime = System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000
|
||||
val events = withContext(Dispatchers.IO) {
|
||||
CalendarEvent.search(context, query, startTime, endTime)
|
||||
}
|
||||
allEvents.value = events
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private lateinit var instance: CalendarRepository
|
||||
fun getInstance(context: Context): CalendarRepository {
|
||||
if (!::instance.isInitialized) instance = CalendarRepository(context.applicationContext)
|
||||
return instance
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import de.mm20.launcher2.search.data.CalendarEvent
|
||||
|
||||
class CalendarViewModel(app:Application): AndroidViewModel(app) {
|
||||
val calendarEvents: LiveData<List<CalendarEvent>?> = CalendarRepository.getInstance(app).calendarEvents
|
||||
val upcomingCalendarEvents: LiveData<List<CalendarEvent>> = CalendarRepository.getInstance(app).upcomingCalendarEvents
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.amulyakhare.textdrawable.TextDrawable
|
||||
import de.mm20.launcher2.calendar.R
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.icons.LauncherIcon
|
||||
import de.mm20.launcher2.ktx.checkPermission
|
||||
import de.mm20.launcher2.ktx.dp
|
||||
import de.mm20.launcher2.preferences.LauncherPreferences
|
||||
import org.json.JSONObject
|
||||
import java.lang.NullPointerException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class CalendarEvent(
|
||||
override val label: String,
|
||||
val id: Long,
|
||||
val color: Int,
|
||||
val startTime: Long,
|
||||
val endTime: Long,
|
||||
val allDay: Boolean,
|
||||
val location: String,
|
||||
val attendees: List<String>,
|
||||
val description: String,
|
||||
val calendar: Long
|
||||
) : Searchable() {
|
||||
|
||||
override fun serialize(): String {
|
||||
val json = JSONObject()
|
||||
json.put("id", id)
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
override val key: String
|
||||
get() = "calendar://$id"
|
||||
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): LauncherIcon {
|
||||
val df = SimpleDateFormat("d")
|
||||
val day = df.format(startTime)
|
||||
df.applyPattern("MMM")
|
||||
val month = df.format(startTime)
|
||||
val fgLayers = arrayOf(
|
||||
TextDrawable
|
||||
.builder()
|
||||
.beginConfig()
|
||||
.textColor(Color.WHITE)
|
||||
.useFont(Typeface.DEFAULT_BOLD)
|
||||
.fontSize((36 * context.dp).toInt())
|
||||
.endConfig()
|
||||
.buildRect(day, 0),
|
||||
TextDrawable
|
||||
.builder()
|
||||
.beginConfig()
|
||||
.textColor(Color.WHITE)
|
||||
.bold()
|
||||
.fontSize((26 * context.dp).toInt())
|
||||
.endConfig()
|
||||
.buildRect(month, 0)
|
||||
)
|
||||
val foreground = LayerDrawable(fgLayers)
|
||||
foreground.setLayerInset(0, 0, 0, 0, (26 * context.dp).toInt())
|
||||
foreground.setLayerInset(1, 0, (36 * context.dp).toInt(), 0, 0)
|
||||
val background = ColorDrawable(getDisplayColor(context, color))
|
||||
return LauncherIcon(
|
||||
foreground = foreground,
|
||||
background = background,
|
||||
foregroundScale = 0.74f
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLaunchIntent(context: Context): Intent? {
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun search(context: Context,
|
||||
query: String,
|
||||
intervalStart: Long,
|
||||
intervalEnd: Long,
|
||||
limit: Int = 10,
|
||||
hideAllDayEvents: Boolean = false,
|
||||
unselectedCalendars: List<Long> = emptyList(),
|
||||
hiddenEvents: List<Long> = emptyList()
|
||||
): List<CalendarEvent> {
|
||||
val results = mutableListOf<CalendarEvent>()
|
||||
if (!query.isEmpty() && query.length < 3) return results
|
||||
if (!LauncherPreferences.instance.searchCalendars) return listOf()
|
||||
if (!PermissionsManager.checkPermission(context, PermissionsManager.CALENDAR)) {
|
||||
return emptyList()
|
||||
}
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, intervalStart)
|
||||
ContentUris.appendId(builder, intervalEnd)
|
||||
val uri = builder.build()
|
||||
val projection = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.DISPLAY_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.DESCRIPTION
|
||||
)
|
||||
val selection = mutableListOf<String>()
|
||||
if (query.isNotEmpty()) selection.add("${CalendarContract.Instances.TITLE} LIKE ?")
|
||||
if (hiddenEvents.isNotEmpty()) selection.add("${CalendarContract.Instances.EVENT_ID} NOT IN (${hiddenEvents.joinToString()})")
|
||||
if (unselectedCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${unselectedCalendars.joinToString()})")
|
||||
if (hideAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0")
|
||||
val selArgs = if (query.isBlank()) null else arrayOf("%$query%")
|
||||
val sort = "${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else ""
|
||||
val cursor = context.contentResolver.query(uri, projection, selection.joinToString(separator = " AND "), selArgs, sort)
|
||||
?: return mutableListOf()
|
||||
val proj = arrayOf(
|
||||
CalendarContract.Attendees.EVENT_ID,
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL
|
||||
)
|
||||
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
|
||||
while (cursor.moveToNext()) {
|
||||
val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}"
|
||||
val cur = context.contentResolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
proj, sel, null, s
|
||||
) ?: return mutableListOf()
|
||||
val attendees = mutableListOf<String>()
|
||||
while (cur.moveToNext()) {
|
||||
attendees.add(cur.getString(1).takeUnless { it.isNullOrBlank() }
|
||||
?: cur.getString(2))
|
||||
}
|
||||
cur.close()
|
||||
val allday = cursor.getInt(4) > 0
|
||||
val begin = cursor.getLong(2)
|
||||
|
||||
val tzOffset = if (allday) {
|
||||
Calendar.getInstance().timeZone.getOffset(begin)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val event = CalendarEvent(
|
||||
label = cursor.getString(1) ?: "",
|
||||
id = cursor.getLong(0),
|
||||
color = cursor.getInt(5),
|
||||
startTime = begin - tzOffset,
|
||||
endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0,
|
||||
allDay = allday,
|
||||
location = cursor.getString(6) ?: "",
|
||||
attendees = attendees,
|
||||
description = cursor.getStringOrNull(8)
|
||||
?: "",
|
||||
calendar = cursor.getLong(7)
|
||||
)
|
||||
results.add(event)
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
fun deserialize(context: Context, serialized: String): CalendarEvent? {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) return null
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getLong("id")
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, System.currentTimeMillis())
|
||||
ContentUris.appendId(builder, System.currentTimeMillis() + 63072000000L)
|
||||
val uri = builder.build()
|
||||
val projection = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.DISPLAY_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.DESCRIPTION
|
||||
)
|
||||
val selection = CalendarContract.Instances.EVENT_ID + " = ?"
|
||||
val selArgs = arrayOf(id.toString())
|
||||
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, null)
|
||||
?: return null
|
||||
if (cursor.moveToNext()) {
|
||||
val title = cursor.getString(1)
|
||||
val begin = cursor.getLong(2)
|
||||
val end = cursor.getLong(3)
|
||||
val allday = cursor.getInt(4) != 0
|
||||
val color = cursor.getInt(5)
|
||||
val location = cursor.getString(6)
|
||||
val calendar = cursor.getLong(7)
|
||||
val description = cursor.getStringOrNull(8)
|
||||
?: ""
|
||||
cursor.close()
|
||||
val proj = arrayOf(
|
||||
CalendarContract.Attendees.EVENT_ID,
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL
|
||||
)
|
||||
val sel = "${CalendarContract.Attendees.EVENT_ID} = $id"
|
||||
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
|
||||
val cur = context.contentResolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
proj, sel, null, s
|
||||
) ?: return null
|
||||
val attendees = mutableListOf<String>()
|
||||
while (cur.moveToNext()) {
|
||||
attendees.add(cur.getString(1).takeUnless { it.isNullOrBlank() }
|
||||
?: cur.getString(2))
|
||||
}
|
||||
cur.close()
|
||||
val tzOffset = if (allday) {
|
||||
Calendar.getInstance().timeZone.getOffset(begin)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return CalendarEvent(
|
||||
label = title,
|
||||
id = id,
|
||||
color = color,
|
||||
startTime = begin - tzOffset,
|
||||
endTime = end - tzOffset - if (allday) 1 else 0,
|
||||
allDay = allday,
|
||||
location = location ?: "",
|
||||
attendees = attendees,
|
||||
description = description,
|
||||
calendar = calendar
|
||||
)
|
||||
}
|
||||
cursor.close()
|
||||
return null
|
||||
}
|
||||
|
||||
fun getCalendars(context: Context): List<UserCalendar> {
|
||||
val calendars = mutableListOf<UserCalendar>()
|
||||
val uri = CalendarContract.Calendars.CONTENT_URI
|
||||
val proj = arrayOf(
|
||||
CalendarContract.Calendars._ID,
|
||||
CalendarContract.Calendars.NAME,
|
||||
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||
CalendarContract.Calendars.VISIBLE,
|
||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
if (!context.checkPermission(Manifest.permission.READ_CALENDAR)) return calendars
|
||||
val cursor = context.contentResolver.query(uri, proj, null, null, null)
|
||||
?: return emptyList()
|
||||
while (cursor.moveToNext()) {
|
||||
try {
|
||||
calendars.add(UserCalendar(
|
||||
id = cursor.getLong(0),
|
||||
name = cursor.getString(5) ?: cursor.getString(1) ?: "",
|
||||
owner = cursor.getString(2),
|
||||
color = cursor.getInt(3)
|
||||
))
|
||||
} catch (e: NullPointerException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
calendars.sortBy { it.owner }
|
||||
return calendars
|
||||
}
|
||||
|
||||
fun getDisplayColor(context: Context, color: Int): Int {
|
||||
val hsl = FloatArray(3).let {
|
||||
ColorUtils.RGBToHSL(color.red, color.green, color.blue, it)
|
||||
it
|
||||
}
|
||||
return if (context.resources.getBoolean(R.bool.is_dark_theme)) {
|
||||
if (ColorUtils.calculateContrast(ContextCompat.getColor(context, R.color.calendar_foreground_color), color) < 2.5 || true) {
|
||||
if (color.red == color.green && color.red == color.blue) {
|
||||
val level = 0xFF - ((0xFF - color.red) * 0.7f).toInt()
|
||||
Color.rgb(level, level, level)
|
||||
} else {
|
||||
hsl[2] = hsl[2] + (1 - hsl[2]) * 0.2f
|
||||
hsl[1] = 1 - (1 - hsl[1]) * 0.9f
|
||||
ColorUtils.HSLToColor(hsl)
|
||||
}
|
||||
} else return color
|
||||
} else {
|
||||
if (ColorUtils.calculateContrast(ContextCompat.getColor(context, R.color.calendar_foreground_color), color) < 1.8) {
|
||||
if (color.red == color.green && color.red == color.blue) {
|
||||
val level = (color.red * 0.7f).toInt()
|
||||
Color.rgb(level, level, level)
|
||||
} else {
|
||||
hsl[2] = (0.5f - hsl[2]) * 0.8f + hsl[2]
|
||||
hsl[1] = 1 - (1 - hsl[1]) * 0.8f
|
||||
ColorUtils.HSLToColor(hsl)
|
||||
}
|
||||
} else return color
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
data class UserCalendar(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val owner: String,
|
||||
val color: Int
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
Binary file not shown.
|
After Width: | Height: | Size: 474 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Reference in New Issue
Block a user