Add new limit options

This commit is contained in:
Jonas Lochmann 2020-05-25 02:00:00 +02:00
parent 2945932c2b
commit 5a21314495
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
58 changed files with 3099 additions and 387 deletions

View file

@ -158,6 +158,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "com.google.android.material:material:1.1.0"
implementation "android.arch.navigation:navigation-fragment-ktx:$nav_version"

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,132 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.sync.actions
import io.timelimit.android.data.model.AppRecommendation
import io.timelimit.android.integration.platform.NewPermissionStatus
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.RuntimePermissionStatus
import io.timelimit.android.sync.network.ParentPassword
import org.json.JSONObject
import org.junit.Test
class Actions {
private val appLogicActions: List<AppLogicAction> = listOf(
AddUsedTimeActionVersion2(
trustedTimestamp = 13,
dayOfEpoch = 674,
items = listOf(
AddUsedTimeActionItem(
categoryId = "abcdef",
sessionDurationLimits = setOf(
AddUsedTimeActionItemSessionDurationLimitSlot(
startMinuteOfDay = 10,
endMinuteOfDay = 23,
sessionPauseDuration = 1000,
maxSessionDuration = 2000
),
AddUsedTimeActionItemSessionDurationLimitSlot(
startMinuteOfDay = 14,
endMinuteOfDay = 23,
sessionPauseDuration = 1000,
maxSessionDuration = 2000
)
),
additionalCountingSlots = setOf(
AddUsedTimeActionItemAdditionalCountingSlot(21, 31),
AddUsedTimeActionItemAdditionalCountingSlot(30, 55)
),
extraTimeToSubtract = 100,
timeToAdd = 1255
)
)
),
AddInstalledAppsAction(
apps = listOf(
InstalledApp(
packageName = "com.demo.app",
isLaunchable = true,
title = "Demo",
recommendation = AppRecommendation.Blacklist
)
)
),
RemoveInstalledAppsAction(
packageNames = listOf("com.something.test")
),
UpdateAppActivitiesAction(
removedActivities = listOf("com.demo" to "com.demo.MainActivity", "com.demo" to "com.demo.DemoActivity"),
updatedOrAddedActivities = listOf(
AppActivityItem(
packageName = "com.demo.two",
title = "Test",
className = "com.demo.TwoActivity"
)
)
),
SignOutAtDeviceAction,
UpdateDeviceStatusAction(
newProtectionLevel = ProtectionLevel.PasswordDeviceAdmin,
didReboot = true,
isQOrLaterNow = true,
newAccessibilityServiceEnabled = true,
newAppVersion = 10,
newNotificationAccessPermission = NewPermissionStatus.Granted,
newOverlayPermission = RuntimePermissionStatus.NotRequired,
newUsageStatsPermissionStatus = RuntimePermissionStatus.NotGranted
),
TriedDisablingDeviceAdminAction
)
private val parentActions: List<ParentAction> = listOf(
AddCategoryAppsAction(
categoryId = "abedge",
packageNames = listOf("com.demo.one", "com.demo.two")
)
// this list does not contain all actions
)
private val childActions: List<ChildAction> = listOf(
ChildSignInAction,
ChildChangePasswordAction(
password = ParentPassword.createSync("test")
)
)
@Test
fun decrementCategoryExtraTimeShouldBeSerializedAndParsedCorrectly() {
val originalAction = DecrementCategoryExtraTimeAction(categoryId = "abcdef", extraTimeToSubtract = 1000 * 30)
fun testActionSerializationAndDeserializationWorks() {
appLogicActions.forEach { originalAction ->
val serializedAction = SerializationUtil.serializeAction(originalAction)
val parsedAction = ActionParser.parseAppLogicAction(JSONObject(serializedAction))
val serializedAction = SerializationUtil.serializeAction(originalAction)
val parsedAction = ActionParser.parseAppLogicAction(JSONObject(serializedAction))
assert(parsedAction == originalAction)
}
}
assert(parsedAction == originalAction)
@Test
fun testCanSerializeParentActions() {
parentActions.forEach {
SerializationUtil.serializeAction(it)
}
}
@Test
fun testCanSerializeChildActions() {
childActions.forEach {
SerializationUtil.serializeAction(it)
}
}
}

View file

@ -33,6 +33,7 @@ interface Database {
fun notification(): NotificationDao
fun allowedContact(): AllowedContactDao
fun userKey(): UserKeyDao
fun sessionDuration(): SessionDurationDao
fun beginTransaction()
fun setTransactionSuccessful()

View file

@ -17,6 +17,8 @@ package io.timelimit.android.data
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.extensions.MinuteOfDay
object DatabaseMigrations {
val MIGRATE_TO_V2 = object: Migration(1, 2) {
@ -199,4 +201,21 @@ object DatabaseMigrations {
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_user_key_key` ON `user_key` (`key`)")
}
}
val MIGRATE_TO_V29 = object: Migration(28, 29) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `time_limit_rule` ADD COLUMN `start_minute_of_day` INTEGER NOT NULL DEFAULT ${TimeLimitRule.MIN_START_MINUTE}")
database.execSQL("ALTER TABLE `time_limit_rule` ADD COLUMN `end_minute_of_day` INTEGER NOT NULL DEFAULT ${TimeLimitRule.MAX_END_MINUTE}")
database.execSQL("ALTER TABLE `time_limit_rule` ADD COLUMN `session_duration_milliseconds` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `time_limit_rule` ADD COLUMN `session_pause_milliseconds` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `used_time` RENAME TO `used_time_old`")
database.execSQL("CREATE TABLE IF NOT EXISTS `used_time` (`day_of_epoch` INTEGER NOT NULL, `used_time` INTEGER NOT NULL, `category_id` TEXT NOT NULL, `start_time_of_day` INTEGER NOT NULL, `end_time_of_day` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`, `start_time_of_day`, `end_time_of_day`))")
database.execSQL("INSERT INTO `used_time` SELECT `day_of_epoch`, `used_time`, `category_id`, ${MinuteOfDay.MIN} AS `start_time_of_day`, ${MinuteOfDay.MAX} AS `end_time_of_day` FROM `used_time_old`")
database.execSQL("DROP TABLE `used_time_old`")
database.execSQL("CREATE TABLE IF NOT EXISTS `session_duration` (`category_id` TEXT NOT NULL, `max_session_duration` INTEGER NOT NULL, `session_pause_duration` INTEGER NOT NULL, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `last_usage` INTEGER NOT NULL, `last_session_duration` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `max_session_duration`, `session_pause_duration`, `start_minute_of_day`, `end_minute_of_day`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS `session_duration_index_category_id` ON `session_duration` (`category_id`)")
}
}
}

View file

@ -35,8 +35,9 @@ import io.timelimit.android.data.model.*
AppActivity::class,
Notification::class,
AllowedContact::class,
UserKey::class
], version = 28)
UserKey::class,
SessionDuration::class
], version = 29)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object {
private val lock = Object()
@ -98,7 +99,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database
DatabaseMigrations.MIGRATE_TO_V25,
DatabaseMigrations.MIGRATE_TO_V26,
DatabaseMigrations.MIGRATE_TO_V27,
DatabaseMigrations.MIGRATE_TO_V28
DatabaseMigrations.MIGRATE_TO_V28,
DatabaseMigrations.MIGRATE_TO_V29
)
.build()
}

View file

@ -36,12 +36,13 @@ object DatabaseBackupLowlevel {
private const val DEVICE = "device"
private const val PENDING_SYNC_ACTION = "pendingSyncAction"
private const val TIME_LIMIT_RULE = "timelimitRule"
private const val USED_TIME_ITEM = "usedTime"
private const val USED_TIME_ITEM = "usedTimeV2"
private const val USER = "user"
private const val APP_ACTIVITY = "appActivity"
private const val NOTIFICATION = "notification"
private const val ALLOWED_CONTACT = "allowedContact"
private const val USER_KEY = "userKey"
private const val SESSION_DURATION = "sessionDuration"
fun outputAsBackupJson(database: Database, outputStream: OutputStream) {
val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8))
@ -87,6 +88,7 @@ object DatabaseBackupLowlevel {
handleCollection(NOTIFICATION) { offset, pageSize -> database.notification().getNotificationPageSync(offset, pageSize) }
handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) }
handleCollection(USER_KEY) { offset, pageSize -> database.userKey().getUserKeyPageSync(offset, pageSize) }
handleCollection(SESSION_DURATION) { offset, pageSize -> database.sessionDuration().getSessionDurationPageSync(offset, pageSize) }
writer.endObject().flush()
}
@ -226,6 +228,15 @@ object DatabaseBackupLowlevel {
reader.endArray()
}
SESSION_DURATION -> {
reader.beginArray()
while (reader.hasNext()) {
database.sessionDuration().addSessionDurationIgnoreErrorsSync(SessionDuration.parse(reader))
}
reader.endArray()
}
else -> reader.skipValue()
}
}

View file

@ -0,0 +1,53 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import io.timelimit.android.data.model.SessionDuration
@Dao
interface SessionDurationDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addSessionDurationIgnoreErrorsSync(item: SessionDuration)
@Query("SELECT * FROM session_duration LIMIT :pageSize OFFSET :offset")
fun getSessionDurationPageSync(offset: Int, pageSize: Int): List<SessionDuration>
@Query("SELECT * FROM session_duration WHERE category_id = :categoryId AND max_session_duration = :maxSessionDuration AND session_pause_duration = :sessionPauseDuration AND start_minute_of_day = :startMinuteOfDay AND end_minute_of_day = :endMinuteOfDay")
fun getSessionDurationItemSync(
categoryId: String, maxSessionDuration: Int, sessionPauseDuration: Int, startMinuteOfDay: Int, endMinuteOfDay: Int
): SessionDuration?
@Query("SELECT * FROM session_duration WHERE category_id = :categoryId")
fun getSessionDurationItemsByCategoryId(categoryId: String): LiveData<List<SessionDuration>>
@Insert
fun insertSessionDurationItemSync(item: SessionDuration)
@Insert
fun insertSessionDurationItemsSync(item: List<SessionDuration>)
@Update
fun updateSessionDurationItemSync(item: SessionDuration)
@Query("DELETE FROM session_duration WHERE last_usage + MIN(session_pause_duration + 1000 * 60 * 60, 1000 * 60 * 60 * 24) < :trustedTimestamp")
fun deleteOldSessionDurationItemsSync(trustedTimestamp: Long)
@Query("DELETE FROM session_duration WHERE category_id = :categoryId")
fun deleteByCategoryId(categoryId: String)
}

View file

@ -15,14 +15,13 @@
*/
package io.timelimit.android.data.dao
import android.util.SparseArray
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.data.model.UsedTimeListItem
import io.timelimit.android.livedata.ignoreUnchanged
@Dao
@ -30,16 +29,8 @@ abstract class UsedTimeDao {
@Query("SELECT * FROM used_time WHERE category_id = :categoryId AND day_of_epoch >= :startingDayOfEpoch AND day_of_epoch <= :endDayOfEpoch")
protected abstract fun getUsedTimesOfWeekInternal(categoryId: String, startingDayOfEpoch: Int, endDayOfEpoch: Int): LiveData<List<UsedTimeItem>>
fun getUsedTimesOfWeek(categoryId: String, firstDayOfWeekAsEpochDay: Int): LiveData<SparseArray<UsedTimeItem>> {
return Transformations.map(getUsedTimesOfWeekInternal(categoryId, firstDayOfWeekAsEpochDay, firstDayOfWeekAsEpochDay + 6).ignoreUnchanged()) {
val result = SparseArray<UsedTimeItem>()
it.forEach {
result.put(it.dayOfEpoch - firstDayOfWeekAsEpochDay, it)
}
result
}
fun getUsedTimesOfWeek(categoryId: String, firstDayOfWeekAsEpochDay: Int): LiveData<List<UsedTimeItem>> {
return getUsedTimesOfWeekInternal(categoryId, firstDayOfWeekAsEpochDay, firstDayOfWeekAsEpochDay + 6).ignoreUnchanged()
}
@Insert
@ -48,14 +39,11 @@ abstract class UsedTimeDao {
@Insert
abstract fun insertUsedTimes(item: List<UsedTimeItem>)
@Query("UPDATE used_time SET used_time = :newUsedTime WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch")
abstract fun updateUsedTime(categoryId: String, dayOfEpoch: Int, newUsedTime: Long)
@Query("UPDATE used_time SET used_time = used_time + :timeToAdd WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch AND start_time_of_day = :start AND end_time_of_day = :end")
abstract fun addUsedTime(categoryId: String, dayOfEpoch: Int, timeToAdd: Int, start: Int, end: Int): Int
@Query("UPDATE used_time SET used_time = used_time + :timeToAdd WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch")
abstract fun addUsedTime(categoryId: String, dayOfEpoch: Int, timeToAdd: Int): Int
@Query("SELECT * FROM used_time WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch")
abstract fun getUsedTimeItem(categoryId: String, dayOfEpoch: Int): LiveData<UsedTimeItem?>
@Query("SELECT * FROM used_time WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch AND start_time_of_day = :start AND end_time_of_day = :end")
abstract fun getUsedTimeItemSync(categoryId: String, dayOfEpoch: Int, start: Int, end: Int): UsedTimeItem?
@Query("DELETE FROM used_time WHERE category_id = :categoryId")
abstract fun deleteUsedTimeItems(categoryId: String)
@ -66,12 +54,13 @@ abstract class UsedTimeDao {
@Query("SELECT * FROM used_time LIMIT :pageSize OFFSET :offset")
abstract fun getUsedTimePageSync(offset: Int, pageSize: Int): List<UsedTimeItem>
@Query("SELECT * FROM used_time WHERE category_id = :categoryId ORDER BY day_of_epoch DESC")
abstract fun getUsedTimesByCategoryId(categoryId: String): DataSource.Factory<Int, UsedTimeItem>
@Query("SELECT * FROM used_time WHERE category_id IN (:categoryIds) AND day_of_epoch >= :startingDayOfEpoch AND day_of_epoch <= :endDayOfEpoch")
abstract fun getUsedTimesByDayAndCategoryIds(categoryIds: List<String>, startingDayOfEpoch: Int, endDayOfEpoch: Int): LiveData<List<UsedTimeItem>>
@Query("SELECT * FROM used_time")
abstract fun getAllUsedTimeItemsSync(): List<UsedTimeItem>
// breaking it into multiple lines causes issues during compilation ...
@Query("SELECT 2 AS type, start_time_of_day AS startMinuteOfDay, end_time_of_day AS endMinuteOfDay, used_time AS duration, day_of_epoch AS day, NULL AS lastUsage, NULL AS maxSessionDuration, NULL AS pauseDuration FROM used_time WHERE category_id = :categoryId UNION ALL SELECT 1 AS type, start_minute_of_day AS startMinuteOfDay, end_minute_of_day AS endMinuteOfDay, last_session_duration AS duration, NULL AS day, last_usage AS lastUsage, max_session_duration AS maxSessionDuration, session_pause_duration AS pauseDuration FROM session_duration WHERE category_id = :categoryId ORDER BY type, day DESC, lastUsage DESC, startMinuteOfDay, endMinuteOfDay")
abstract fun getUsedTimeListItemsByCategoryId(categoryId: String): DataSource.Factory<Int, UsedTimeListItem>
}

View file

@ -0,0 +1,145 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import android.util.JsonReader
import android.util.JsonWriter
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.extensions.MinuteOfDay
@Entity(
tableName = "session_duration",
primaryKeys = [
"category_id",
"max_session_duration",
"session_pause_duration",
"start_minute_of_day",
"end_minute_of_day"
],
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["category_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
],
indices = [
Index(
name = "session_duration_index_category_id",
value = ["category_id"],
unique = false
)
]
)
data class SessionDuration(
@ColumnInfo(name = "category_id")
val categoryId: String,
@ColumnInfo(name = "max_session_duration")
val maxSessionDuration: Int,
@ColumnInfo(name = "session_pause_duration")
val sessionPauseDuration: Int,
@ColumnInfo(name = "start_minute_of_day")
val startMinuteOfDay: Int,
@ColumnInfo(name = "end_minute_of_day")
val endMinuteOfDay: Int,
@ColumnInfo(name = "last_usage")
val lastUsage: Long,
@ColumnInfo(name = "last_session_duration")
val lastSessionDuration: Long
): JsonSerializable {
companion object {
private const val CATEGORY_ID = "c"
private const val MAX_SESSION_DURATION = "md"
private const val SESSION_PAUSE_DURATION = "spd"
private const val START_MINUTE_OF_DAY = "sm"
private const val END_MINUTE_OF_DAY = "em"
private const val LAST_USAGE = "l"
private const val LAST_SESSION_DURATION = "d"
fun parse(reader: JsonReader): SessionDuration {
var categoryId: String? = null
var maxSessionDuration: Int? = null
var sessionPauseDuration: Int? = null
var startMinuteOfDay: Int? = null
var endMinuteOfDay: Int? = null
var lastUsage: Long? = null
var lastSessionDuration: Long? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
CATEGORY_ID -> categoryId = reader.nextString()
MAX_SESSION_DURATION -> maxSessionDuration = reader.nextInt()
SESSION_PAUSE_DURATION -> sessionPauseDuration = reader.nextInt()
START_MINUTE_OF_DAY -> startMinuteOfDay = reader.nextInt()
END_MINUTE_OF_DAY -> endMinuteOfDay = reader.nextInt()
LAST_USAGE -> lastUsage = reader.nextLong()
LAST_SESSION_DURATION -> lastSessionDuration = reader.nextLong()
else -> reader.skipValue()
}
}
reader.endObject()
return SessionDuration(
categoryId = categoryId!!,
maxSessionDuration = maxSessionDuration!!,
sessionPauseDuration = sessionPauseDuration!!,
startMinuteOfDay = startMinuteOfDay!!,
endMinuteOfDay = endMinuteOfDay!!,
lastUsage = lastUsage!!,
lastSessionDuration = lastSessionDuration!!
)
}
}
init {
IdGenerator.assertIdValid(categoryId)
if (maxSessionDuration <= 0 || sessionPauseDuration <= 0) {
throw IllegalArgumentException()
}
if (!(MinuteOfDay.isValid(startMinuteOfDay) && MinuteOfDay.isValid(endMinuteOfDay))) {
throw IllegalArgumentException()
}
if (lastUsage < 0 || lastSessionDuration < 0) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(CATEGORY_ID).value(categoryId)
writer.name(MAX_SESSION_DURATION).value(maxSessionDuration)
writer.name(SESSION_PAUSE_DURATION).value(sessionPauseDuration)
writer.name(START_MINUTE_OF_DAY).value(startMinuteOfDay)
writer.name(END_MINUTE_OF_DAY).value(endMinuteOfDay)
writer.name(LAST_USAGE).value(lastUsage)
writer.name(LAST_SESSION_DURATION).value(lastSessionDuration)
writer.endObject()
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,6 +18,7 @@ package io.timelimit.android.data.model
import android.os.Parcelable
import android.util.JsonReader
import android.util.JsonWriter
import androidx.lifecycle.LiveData
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@ -25,6 +26,9 @@ import androidx.room.TypeConverters
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.data.customtypes.ImmutableBitmaskAdapter
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.map
import kotlinx.android.parcel.Parcelize
@Entity(tableName = "time_limit_rule")
@ -41,7 +45,15 @@ data class TimeLimitRule(
@ColumnInfo(name = "day_mask")
val dayMask: Byte,
@ColumnInfo(name = "max_time")
val maximumTimeInMillis: Int
val maximumTimeInMillis: Int,
@ColumnInfo(name = "start_minute_of_day")
val startMinuteOfDay: Int,
@ColumnInfo(name = "end_minute_of_day")
val endMinuteOfDay: Int,
@ColumnInfo(name = "session_duration_milliseconds")
val sessionDurationMilliseconds: Int,
@ColumnInfo(name = "session_pause_milliseconds")
val sessionPauseMilliseconds: Int
): Parcelable, JsonSerializable {
companion object {
private const val RULE_ID = "ruleId"
@ -49,6 +61,13 @@ data class TimeLimitRule(
private const val MAX_TIME_IN_MILLIS = "time"
private const val DAY_MASK = "days"
private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime"
private const val START_MINUTE_OF_DAY = "start"
private const val END_MINUTE_OF_DAY = "end"
private const val SESSION_DURATION_MILLISECONDS = "dur"
private const val SESSION_PAUSE_MILLISECONDS = "pause"
const val MIN_START_MINUTE = MinuteOfDay.MIN
const val MAX_END_MINUTE = MinuteOfDay.MAX
fun parse(reader: JsonReader): TimeLimitRule {
var id: String? = null
@ -56,6 +75,10 @@ data class TimeLimitRule(
var applyToExtraTimeUsage: Boolean? = null
var dayMask: Byte? = null
var maximumTimeInMillis: Int? = null
var startMinuteOfDay = MIN_START_MINUTE
var endMinuteOfDay = MAX_END_MINUTE
var sessionDurationMilliseconds = 0
var sessionPauseMilliseconds = 0
reader.beginObject()
@ -66,6 +89,10 @@ data class TimeLimitRule(
MAX_TIME_IN_MILLIS -> maximumTimeInMillis = reader.nextInt()
DAY_MASK -> dayMask = reader.nextInt().toByte()
APPLY_TO_EXTRA_TIME_USAGE -> applyToExtraTimeUsage = reader.nextBoolean()
START_MINUTE_OF_DAY -> startMinuteOfDay = reader.nextInt()
END_MINUTE_OF_DAY -> endMinuteOfDay = reader.nextInt()
SESSION_DURATION_MILLISECONDS -> sessionDurationMilliseconds = reader.nextInt()
SESSION_PAUSE_MILLISECONDS -> sessionPauseMilliseconds = reader.nextInt()
else -> reader.skipValue()
}
}
@ -77,7 +104,11 @@ data class TimeLimitRule(
categoryId = categoryId!!,
applyToExtraTimeUsage = applyToExtraTimeUsage!!,
dayMask = dayMask!!,
maximumTimeInMillis = maximumTimeInMillis!!
maximumTimeInMillis = maximumTimeInMillis!!,
startMinuteOfDay = startMinuteOfDay,
endMinuteOfDay = endMinuteOfDay,
sessionDurationMilliseconds = sessionDurationMilliseconds,
sessionPauseMilliseconds = sessionPauseMilliseconds
)
}
}
@ -93,8 +124,22 @@ data class TimeLimitRule(
if (dayMask < 0 || dayMask > (1 or 2 or 4 or 8 or 16 or 32 or 64)) {
throw IllegalArgumentException()
}
if (startMinuteOfDay < MIN_START_MINUTE || endMinuteOfDay > MAX_END_MINUTE || startMinuteOfDay > endMinuteOfDay) {
throw IllegalArgumentException()
}
if (sessionDurationMilliseconds < 0 || sessionPauseMilliseconds < 0) {
throw IllegalArgumentException()
}
}
val appliesToWholeDay: Boolean
get() = startMinuteOfDay == MIN_START_MINUTE && endMinuteOfDay == MAX_END_MINUTE
val sessionDurationLimitEnabled: Boolean
get() = sessionPauseMilliseconds > 0 && sessionDurationMilliseconds > 0
override fun serialize(writer: JsonWriter) {
writer.beginObject()
@ -103,7 +148,28 @@ data class TimeLimitRule(
writer.name(MAX_TIME_IN_MILLIS).value(maximumTimeInMillis)
writer.name(DAY_MASK).value(dayMask)
writer.name(APPLY_TO_EXTRA_TIME_USAGE).value(applyToExtraTimeUsage)
writer.name(START_MINUTE_OF_DAY).value(startMinuteOfDay)
writer.name(END_MINUTE_OF_DAY).value(endMinuteOfDay)
if (sessionDurationMilliseconds != 0 || sessionPauseMilliseconds != 0) {
writer.name(SESSION_DURATION_MILLISECONDS).value(sessionDurationMilliseconds)
writer.name(SESSION_PAUSE_MILLISECONDS).value(sessionPauseMilliseconds)
}
writer.endObject()
}
}
fun List<TimeLimitRule>.getSlotSwitchMinutes(): Set<Int> {
val result = mutableSetOf<Int>()
result.add(MinuteOfDay.MIN)
forEach { rule -> result.add(rule.startMinuteOfDay); result.add(rule.endMinuteOfDay) }
return result
}
fun getCurrentTimeSlotStartMinute(slots: Set<Int>, minuteOfDay: LiveData<Int>): LiveData<Int> = minuteOfDay.map { minuteOfDay ->
slots.find { it >= minuteOfDay } ?: 0
}.ignoreUnchanged()

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -21,20 +21,27 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.extensions.MinuteOfDay
@Entity(primaryKeys = ["category_id", "day_of_epoch"], tableName = "used_time")
@Entity(primaryKeys = ["category_id", "day_of_epoch", "start_time_of_day", "end_time_of_day"], tableName = "used_time")
data class UsedTimeItem(
@ColumnInfo(name = "day_of_epoch")
val dayOfEpoch: Int,
@ColumnInfo(name = "used_time")
val usedMillis: Long,
@ColumnInfo(name = "category_id")
val categoryId: String
val categoryId: String,
@ColumnInfo(name = "start_time_of_day")
val startTimeOfDay: Int,
@ColumnInfo(name = "end_time_of_day")
val endTimeOfDay: Int
): JsonSerializable {
companion object {
private const val DAY_OF_EPOCH = "day"
private const val USED_TIME_MILLIS = "time"
private const val CATEGORY_ID = "category"
private const val START_TIME_OF_DAY = "start"
private const val END_TIME_OF_DAY = "end"
fun parse(reader: JsonReader): UsedTimeItem {
reader.beginObject()
@ -42,12 +49,16 @@ data class UsedTimeItem(
var dayOfEpoch: Int? = null
var usedMillis: Long? = null
var categoryId: String? = null
var startTimeOfDay = MinuteOfDay.MIN
var endTimeOfDay = MinuteOfDay.MAX
while (reader.hasNext()) {
when (reader.nextName()) {
DAY_OF_EPOCH -> dayOfEpoch = reader.nextInt()
USED_TIME_MILLIS -> usedMillis = reader.nextLong()
CATEGORY_ID -> categoryId = reader.nextString()
START_TIME_OF_DAY -> startTimeOfDay = reader.nextInt()
END_TIME_OF_DAY -> endTimeOfDay = reader.nextInt()
else -> reader.skipValue()
}
}
@ -57,7 +68,9 @@ data class UsedTimeItem(
return UsedTimeItem(
dayOfEpoch = dayOfEpoch!!,
usedMillis = usedMillis!!,
categoryId = categoryId!!
categoryId = categoryId!!,
startTimeOfDay = startTimeOfDay,
endTimeOfDay = endTimeOfDay
)
}
}
@ -72,6 +85,10 @@ data class UsedTimeItem(
if (usedMillis < 0) {
throw IllegalArgumentException()
}
if (startTimeOfDay < MinuteOfDay.MIN || endTimeOfDay > MinuteOfDay.MAX || startTimeOfDay > endTimeOfDay) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
@ -80,6 +97,8 @@ data class UsedTimeItem(
writer.name(DAY_OF_EPOCH).value(dayOfEpoch)
writer.name(USED_TIME_MILLIS).value(usedMillis)
writer.name(CATEGORY_ID).value(categoryId)
writer.name(START_TIME_OF_DAY).value(startTimeOfDay)
writer.name(END_TIME_OF_DAY).value(endTimeOfDay)
writer.endObject()
}

View file

@ -0,0 +1,29 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
data class UsedTimeListItem(
val startMinuteOfDay: Int,
val endMinuteOfDay: Int,
val duration: Long,
// used time item
val day: Long?,
// session duration
val lastUsage: Long?,
val maxSessionDuration: Long?,
val pauseDuration: Long?
)

View file

@ -0,0 +1,39 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.extensions
object MinuteOfDay {
const val MIN = 0
const val MAX = 24 * 60 - 1
const val LENGTH = 24 * 60
fun isValid(value: Int) = value >= MIN && value <= MAX
fun format(minuteOfDay: Int): String {
if (minuteOfDay < MIN || minuteOfDay > MAX) {
return "???"
} else {
val hour = minuteOfDay / 60
val minute = minuteOfDay % 60
val hourString = hour.toString()
val minuteString = minute.toString().padStart(2, '0')
return "$hourString:$minuteString"
}
}
}

View file

@ -113,6 +113,7 @@ class NotificationListener: NotificationListenerService() {
BlockingReason.RequiresCurrentDevice -> getString(R.string.lock_reason_short_requires_current_device)
BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking)
BlockingReason.BatteryLimit -> getString(R.string.lock_reason_short_battery_limit)
BlockingReason.SessionDurationLimit -> getString(R.string.lock_reason_short_session_duration)
BlockingReason.None -> throw IllegalStateException()
}
)

View file

@ -16,8 +16,6 @@
package io.timelimit.android.logic
import android.util.Log
import android.util.SparseArray
import android.util.SparseLongArray
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
@ -29,11 +27,14 @@ import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.integration.platform.AppStatusMessage
import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.android.AccessibilityService
import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.AddUsedTimeActionItemAdditionalCountingSlot
import io.timelimit.android.sync.actions.AddUsedTimeActionItemSessionDurationLimitSlot
import io.timelimit.android.sync.actions.UpdateDeviceStatusAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.ui.IsAppInForeground
@ -235,7 +236,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems(
database = appLogic.database,
date = nowDate
date = nowDate,
timestamp = nowTimestamp
)
if (realTime.isNetworkTime) {
@ -315,17 +317,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val usedTimeUpdateHelper = usedTimeUpdateHelper!!
// check times
fun buildUsedTimesSparseArray(items: SparseArray<UsedTimeItem>, categoryId: String): SparseLongArray {
val result = SparseLongArray()
for (i in 0..6) {
val usedTimesItem = items[i]?.usedMillis ?: 0
val timeToAddButNotCommited = usedTimeUpdateHelper.timeToAdd[categoryId] ?: 0
result.put(i, usedTimesItem + timeToAddButNotCommited)
fun buildDummyUsedTimeItems(categoryId: String): List<UsedTimeItem> {
if (!usedTimeUpdateHelper.timeToAdd.containsKey(categoryId)) {
return emptyList()
}
return result
return (usedTimeUpdateHelper.additionalSlots[categoryId] ?: emptySet()).map {
UsedTimeItem(
categoryId = categoryId,
startTimeOfDay = it.start,
endTimeOfDay = it.end,
dayOfEpoch = usedTimeUpdateHelper.date.dayOfEpoch,
usedMillis = (usedTimeUpdateHelper.timeToAdd[categoryId] ?: 0).toLong()
)
} + listOf(
UsedTimeItem(
categoryId = categoryId,
startTimeOfDay = MinuteOfDay.MIN,
endTimeOfDay = MinuteOfDay.MAX,
dayOfEpoch = usedTimeUpdateHelper.date.dayOfEpoch,
usedMillis = (usedTimeUpdateHelper.timeToAdd[categoryId] ?: 0).toLong()
)
)
}
suspend fun getRemainingTime(categoryId: String?): RemainingTime? {
@ -338,31 +351,66 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
return null
}
val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue()
val firstDayOfWeekAsEpochDay = nowDate.dayOfEpoch - nowDate.dayOfWeek
val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, firstDayOfWeekAsEpochDay)).waitForNonNullValue()
return RemainingTime.getRemainingTime(
nowDate.dayOfWeek,
buildUsedTimesSparseArray(usedTimes, categoryId),
minuteOfWeek % MinuteOfDay.LENGTH,
usedTimes + buildDummyUsedTimeItems(categoryId),
rules,
Math.max(0, category.getExtraTime(dayOfEpoch = nowDate.dayOfEpoch) - (usedTimeUpdateHelper.extraTimeToSubtract.get(categoryId) ?: 0))
Math.max(0, category.getExtraTime(dayOfEpoch = nowDate.dayOfEpoch) - (usedTimeUpdateHelper.extraTimeToSubtract.get(categoryId) ?: 0)),
firstDayOfWeekAsEpochDay
)
}
suspend fun getRemainingSessionDuration(categoryId: String?): Long? {
categoryId ?: return null
val category = categories.find { it.id == categoryId } ?: return null
val rules = timeLimitRules.get(category.id).waitForNonNullValue()
val durations = cache.usedSessionDurationsByCategoryId.get(categoryId).waitForNonNullValue()
val timeToAdd = usedTimeUpdateHelper.timeToAdd[categoryId] ?: 0
val result = RemainingSessionDuration.getRemainingSessionDuration(
rules = rules,
durationsOfCategory = durations,
timestamp = nowTimestamp,
dayOfWeek = nowDate.dayOfWeek,
minuteOfDay = minuteOfWeek % MinuteOfDay.LENGTH
)
if (result == null) {
return null
} else {
return (result - timeToAdd).coerceAtLeast(0)
}
}
// note: remainingTime != null implicates that there are limits and they are currently not ignored
val remainingTimeForegroundAppChild = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(foregroundAppHandling.categoryId) else null
val remainingTimeForegroundAppParent = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(foregroundAppHandling.parentCategoryId) else null
val remainingTimeForegroundApp = RemainingTime.min(remainingTimeForegroundAppChild, remainingTimeForegroundAppParent)
val remainingSessionDurationForegroundAppChild = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingSessionDuration(foregroundAppHandling.categoryId) else null
val remainingSessionDurationForegroundAppParent = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingSessionDuration(foregroundAppHandling.parentCategoryId) else null
val remainingSessionDurationForegroundApp = RemainingSessionDuration.min(remainingSessionDurationForegroundAppChild, remainingSessionDurationForegroundAppParent)
val remainingTimeBackgroundAppChild = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(audioPlaybackHandling.categoryId) else null
val remainingTimeBackgroundAppParent = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(audioPlaybackHandling.parentCategoryId) else null
val remainingTimeBackgroundApp = RemainingTime.min(remainingTimeBackgroundAppChild, remainingTimeBackgroundAppParent)
val remainingSessionDurationBackgroundAppChild = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingSessionDuration(audioPlaybackHandling.categoryId) else null
val remainingSessionDurationBackgroundAppParent = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingSessionDuration(audioPlaybackHandling.parentCategoryId) else null
val remainingSessionDurationBackgroundApp = RemainingSessionDuration.min(remainingSessionDurationBackgroundAppChild, remainingSessionDurationBackgroundAppParent)
val sessionDurationLimitReachedForegroundApp = (remainingSessionDurationForegroundApp != null && remainingSessionDurationForegroundApp == 0L)
val sessionDurationLimitReachedBackgroundApp = (remainingSessionDurationBackgroundApp != null && remainingSessionDurationBackgroundApp == 0L)
// eventually block
if (remainingTimeForegroundApp?.hasRemainingTime == false) {
if (remainingTimeForegroundApp?.hasRemainingTime == false || sessionDurationLimitReachedForegroundApp) {
foregroundAppHandling.status = BackgroundTaskLogicAppStatus.ShouldBlock
}
if (remainingTimeBackgroundApp?.hasRemainingTime == false) {
if (remainingTimeBackgroundApp?.hasRemainingTime == false || sessionDurationLimitReachedBackgroundApp) {
audioPlaybackHandling.status = BackgroundTaskLogicAppStatus.ShouldBlock
}
@ -375,6 +423,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val categoriesToCount = mutableSetOf<String>()
val categoriesToCountExtraTime = mutableSetOf<String>()
val categoriesToCountSessionDurations = mutableSetOf<String>()
if (shouldCountForegroundApp) {
remainingTimeForegroundAppChild?.let { remainingTime ->
@ -384,6 +433,10 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(categoryId)
}
if (!sessionDurationLimitReachedForegroundApp) {
categoriesToCountSessionDurations.add(categoryId)
}
}
}
@ -394,6 +447,10 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(it)
}
if (!sessionDurationLimitReachedForegroundApp) {
categoriesToCountSessionDurations.add(it)
}
}
}
}
@ -406,6 +463,10 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(it)
}
if (!sessionDurationLimitReachedBackgroundApp) {
categoriesToCountSessionDurations.add(it)
}
}
}
@ -416,16 +477,61 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(it)
}
if (!sessionDurationLimitReachedBackgroundApp) {
categoriesToCountSessionDurations.add(it)
}
}
}
}
if (categoriesToCount.isNotEmpty()) {
categoriesToCount.forEach { categoryId ->
// only handle rules which are related to today
val rules = timeLimitRules.get(categoryId).waitForNonNullValue().filter {
(it.dayMask.toInt() and (1 shl nowDate.dayOfWeek)) != 0
}
usedTimeUpdateHelper.add(
categoryId = categoryId,
time = timeToSubtract,
includingExtraTime = categoriesToCountExtraTime.contains(categoryId)
includingExtraTime = categoriesToCountExtraTime.contains(categoryId),
slots = run {
val slots = mutableSetOf<AddUsedTimeActionItemAdditionalCountingSlot>()
rules.forEach { rule ->
if (!rule.appliesToWholeDay) {
slots.add(
AddUsedTimeActionItemAdditionalCountingSlot(
rule.startMinuteOfDay, rule.endMinuteOfDay
)
)
}
}
slots
},
trustedTimestamp = if (realTime.shouldTrustTimePermanently) realTime.timeInMillis else 0,
sessionDurationLimits = run {
val slots = mutableSetOf<AddUsedTimeActionItemSessionDurationLimitSlot>()
if (categoriesToCountSessionDurations.contains(categoryId)) {
rules.forEach { rule ->
if (rule.sessionDurationLimitEnabled) {
slots.add(
AddUsedTimeActionItemSessionDurationLimitSlot(
startMinuteOfDay = rule.startMinuteOfDay,
endMinuteOfDay = rule.endMinuteOfDay,
sessionPauseDuration = rule.sessionPauseMilliseconds,
maxSessionDuration = rule.sessionDurationMilliseconds
)
)
}
}
}
slots
}
)
}
}
@ -479,6 +585,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
fun buildStatusMessage(
handling: BackgroundTaskRestrictionLogicResult,
remainingTime: RemainingTime?,
remainingSessionDuration: Long?,
suffix: String,
appPackageName: String?,
appActivityToShow: String?
@ -518,27 +625,18 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
appPackageName = appPackageName,
appActivityToShow = appActivityToShow
)
BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime -> (
if (remainingTime?.usingExtraTime == true) {
// using extra time
buildStatusMessageWithCurrentAppTitle(
text = appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remainingTime.includingExtraTime.toInt(), appLogic.context)),
titlePrefix = getCategoryTitle(handling.categoryId) + " - ",
titleSuffix = suffix,
appPackageName = appPackageName,
appActivityToShow = appActivityToShow
)
} else {
// using normal contingent
buildStatusMessageWithCurrentAppTitle(
text = TimeTextUtil.remaining(remainingTime?.default?.toInt() ?: 0, appLogic.context),
titlePrefix = getCategoryTitle(handling.categoryId) + " - ",
titleSuffix = suffix,
appPackageName = appPackageName,
appActivityToShow = appActivityToShow
)
}
)
BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime -> buildStatusMessageWithCurrentAppTitle(
text = if (remainingTime?.usingExtraTime == true)
appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remainingTime.includingExtraTime.toInt(), appLogic.context))
else if (remainingTime != null && remainingSessionDuration != null && remainingSessionDuration < remainingTime.default)
TimeTextUtil.pauseIn(remainingSessionDuration.toInt(), appLogic.context)
else
TimeTextUtil.remaining(remainingTime?.default?.toInt() ?: 0, appLogic.context),
titlePrefix = getCategoryTitle(handling.categoryId) + " - ",
titleSuffix = suffix,
appPackageName = appPackageName,
appActivityToShow = appActivityToShow
)
BackgroundTaskLogicAppStatus.Idle -> AppStatusMessage(
appLogic.context.getString(R.string.background_logic_idle_title) + suffix,
appLogic.context.getString(R.string.background_logic_idle_text)
@ -557,7 +655,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
remainingTime = remainingTimeBackgroundApp,
suffix = " (2/2)",
appPackageName = audioPlaybackPackageName,
appActivityToShow = null
appActivityToShow = null,
remainingSessionDuration = remainingSessionDurationBackgroundApp
)
)
} else {
@ -568,7 +667,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
remainingTime = remainingTimeForegroundApp,
suffix = if (showBackgroundStatus) " (1/2)" else "",
appPackageName = foregroundAppPackageName,
appActivityToShow = if (activityLevelBlocking) foregroundAppActivityName else null
appActivityToShow = if (activityLevelBlocking) foregroundAppActivityName else null,
remainingSessionDuration = remainingSessionDurationForegroundApp
)
)
}

View file

@ -15,12 +15,8 @@
*/
package io.timelimit.android.logic
import android.util.SparseArray
import androidx.lifecycle.LiveData
import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.CategoryApp
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.data.model.*
import io.timelimit.android.livedata.*
import java.util.*
@ -49,11 +45,16 @@ class BackgroundTaskLogicCache (private val appLogic: AppLogic) {
return appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(key)
}
}
val usedTimesOfCategoryAndWeekByFirstDayOfWeek = object: MultiKeyLiveDataCache<SparseArray<UsedTimeItem>, Pair<String, Int>>() {
override fun createValue(key: Pair<String, Int>): LiveData<SparseArray<UsedTimeItem>> {
val usedTimesOfCategoryAndWeekByFirstDayOfWeek = object: MultiKeyLiveDataCache<List<UsedTimeItem>, Pair<String, Int>>() {
override fun createValue(key: Pair<String, Int>): LiveData<List<UsedTimeItem>> {
return appLogic.database.usedTimes().getUsedTimesOfWeek(key.first, key.second)
}
}
val usedSessionDurationsByCategoryId = object: MultiKeyLiveDataCache<List<SessionDuration>, String>() {
override fun createValue(key: String): LiveData<List<SessionDuration>> {
return appLogic.database.sessionDuration().getSessionDurationItemsByCategoryId(key)
}
}
val shouldDoAutomaticSignOut = SingleItemLiveDataCacheWithRequery { -> appLogic.defaultUserLogic.hasAutomaticSignOut()}
val liveDataCaches = LiveDataCaches(arrayOf(

View file

@ -16,15 +16,14 @@
package io.timelimit.android.logic
import android.util.Log
import android.util.SparseLongArray
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import io.timelimit.android.BuildConfig
import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.integration.platform.android.AndroidIntegrationApps
import io.timelimit.android.integration.time.TimeApi
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.extension.isCategoryAllowed
import java.util.*
@ -39,7 +38,8 @@ enum class BlockingReason {
MissingNetworkTime,
RequiresCurrentDevice,
NotificationsAreBlocked,
BatteryLimit
BatteryLimit,
SessionDurationLimit
}
enum class BlockingLevel {
@ -319,18 +319,18 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
trustedMinuteOfWeek ->
if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) {
getBlockingReasonStep6(category, timeZone)
getBlockingReasonStep6(category, timeZone, trustedMinuteOfWeek)
} else if (trustedMinuteOfWeek == null) {
liveDataFromValue(BlockingReason.MissingNetworkTime)
} else if (category.blockedMinutesInWeek.read(trustedMinuteOfWeek)) {
liveDataFromValue(BlockingReason.BlockedAtThisTime)
} else {
getBlockingReasonStep6(category, timeZone)
getBlockingReasonStep6(category, timeZone, trustedMinuteOfWeek)
}
}
}
private fun getBlockingReasonStep6(category: Category, timeZone: TimeZone): LiveData<BlockingReason> {
private fun getBlockingReasonStep6(category: Category, timeZone: TimeZone, trustedMinuteOfWeek: Int?): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 6")
}
@ -343,54 +343,67 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (rules.isEmpty()) {
liveDataFromValue(BlockingReason.None)
} else if (nowTrustedDate == null) {
} else if (nowTrustedDate == null || trustedMinuteOfWeek == null) {
liveDataFromValue(BlockingReason.MissingNetworkTime)
} else {
getBlockingReasonStep6(category, nowTrustedDate, rules)
getBlockingReasonStep6(category, nowTrustedDate, trustedMinuteOfWeek, rules)
}
}
}
}
private fun getBlockingReasonStep6(category: Category, nowTrustedDate: DateInTimezone, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
private fun getBlockingReasonStep6(category: Category, nowTrustedDate: DateInTimezone, trustedMinuteOfWeek: Int, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 6 - 2")
}
return appLogic.currentDeviceLogic.isThisDeviceTheCurrentDevice.switchMap { isCurrentDevice ->
if (isCurrentDevice) {
getBlockingReasonStep7(category, nowTrustedDate, rules)
getBlockingReasonStep7(category, nowTrustedDate, trustedMinuteOfWeek, rules)
} else {
liveDataFromValue(BlockingReason.RequiresCurrentDevice)
}
}
}
private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, trustedMinuteOfWeek: Int, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 7")
}
val extraTime = category.getExtraTime(dayOfEpoch = nowTrustedDate.dayOfEpoch)
val firstDayOfWeekAsEpochDay = nowTrustedDate.dayOfEpoch - nowTrustedDate.dayOfWeek
return appLogic.database.usedTimes().getUsedTimesOfWeek(category.id, nowTrustedDate.dayOfEpoch - nowTrustedDate.dayOfWeek).map {
usedTimes ->
val usedTimesSparseArray = SparseLongArray()
for (i in 0..6) {
val usedTimesItem = usedTimes[i]?.usedMillis
usedTimesSparseArray.put(i, (if (usedTimesItem != null) usedTimesItem else 0))
}
val remaining = RemainingTime.getRemainingTime(nowTrustedDate.dayOfWeek, usedTimesSparseArray, rules, extraTime)
return appLogic.database.usedTimes().getUsedTimesOfWeek(category.id, firstDayOfWeekAsEpochDay = firstDayOfWeekAsEpochDay).switchMap { usedTimes ->
val remaining = RemainingTime.getRemainingTime(nowTrustedDate.dayOfWeek, trustedMinuteOfWeek % MinuteOfDay.LENGTH, usedTimes, rules, extraTime, firstDayOfWeekAsEpochDay)
if (remaining == null || remaining.includingExtraTime > 0) {
BlockingReason.None
appLogic.database.sessionDuration().getSessionDurationItemsByCategoryId(category.id).switchMap { durations ->
getTemporarilyTrustedTimeInMillis().map { timeInMillis ->
if (timeInMillis == null) {
BlockingReason.MissingNetworkTime
} else {
val remainingDuration = RemainingSessionDuration.getRemainingSessionDuration(
rules = rules,
dayOfWeek = nowTrustedDate.dayOfWeek,
durationsOfCategory = durations,
minuteOfDay = trustedMinuteOfWeek % MinuteOfDay.LENGTH,
timestamp = timeInMillis
)
if (remainingDuration == null || remainingDuration > 0) {
BlockingReason.None
} else {
BlockingReason.SessionDurationLimit
}
}
}
}
} else {
if (extraTime > 0) {
BlockingReason.TimeOverExtraTimeCanBeUsedLater
liveDataFromValue(BlockingReason.TimeOverExtraTimeCanBeUsedLater)
} else {
BlockingReason.TimeOver
liveDataFromValue(BlockingReason.TimeOver)
}
}
}

View file

@ -16,12 +16,12 @@
package io.timelimit.android.logic
import android.util.Log
import android.util.SparseLongArray
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.extension.isCategoryAllowed
import java.util.*
@ -146,7 +146,8 @@ class CategoriesBlockingReasonUtil(private val appLogic: AppLogic) {
checkCategoryTimeLimitRules(
temporarilyTrustedDate = temporarilyTrustedDate,
category = category,
rules = appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id)
rules = appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id),
temporarilyTrustedMinuteOfWeek = temporarilyTrustedMinuteOfWeek
)
}
}
@ -211,6 +212,7 @@ class CategoriesBlockingReasonUtil(private val appLogic: AppLogic) {
private fun checkCategoryTimeLimitRules(
temporarilyTrustedDate: LiveData<DateInTimezone?>,
temporarilyTrustedMinuteOfWeek: LiveData<Int?>,
rules: LiveData<List<TimeLimitRule>>,
category: Category
): LiveData<BlockingReason> = rules.switchMap { rules ->
@ -218,43 +220,60 @@ class CategoriesBlockingReasonUtil(private val appLogic: AppLogic) {
liveDataFromValue(BlockingReason.None)
} else {
temporarilyTrustedDate.switchMap { temporarilyTrustedDate ->
if (temporarilyTrustedDate == null) {
liveDataFromValue(BlockingReason.MissingNetworkTime)
} else {
getBlockingReasonStep7(
category = category,
nowTrustedDate = temporarilyTrustedDate,
rules = rules
)
temporarilyTrustedMinuteOfWeek.switchMap { temporarilyTrustedMinuteOfWeek ->
if (temporarilyTrustedDate == null || temporarilyTrustedMinuteOfWeek == null) {
liveDataFromValue(BlockingReason.MissingNetworkTime)
} else {
getBlockingReasonStep7(
category = category,
nowTrustedDate = temporarilyTrustedDate,
rules = rules,
trustedMinuteOfWeek = temporarilyTrustedMinuteOfWeek
)
}
}
}
}
}
private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, trustedMinuteOfWeek: Int, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 7")
}
val extraTime = category.getExtraTime(dayOfEpoch = nowTrustedDate.dayOfEpoch)
val firstDayOfWeekAsEpochDay = nowTrustedDate.dayOfEpoch - nowTrustedDate.dayOfWeek
return appLogic.database.usedTimes().getUsedTimesOfWeek(category.id, nowTrustedDate.dayOfEpoch - nowTrustedDate.dayOfWeek).map { usedTimes ->
val usedTimesSparseArray = SparseLongArray()
for (i in 0..6) {
val usedTimesItem = usedTimes[i]?.usedMillis
usedTimesSparseArray.put(i, (if (usedTimesItem != null) usedTimesItem else 0))
}
val remaining = RemainingTime.getRemainingTime(nowTrustedDate.dayOfWeek, usedTimesSparseArray, rules, extraTime)
return appLogic.database.usedTimes().getUsedTimesOfWeek(category.id, firstDayOfWeekAsEpochDay).switchMap { usedTimes ->
val remaining = RemainingTime.getRemainingTime(nowTrustedDate.dayOfWeek, trustedMinuteOfWeek % MinuteOfDay.LENGTH, usedTimes, rules, extraTime, firstDayOfWeekAsEpochDay)
if (remaining == null || remaining.includingExtraTime > 0) {
BlockingReason.None
appLogic.database.sessionDuration().getSessionDurationItemsByCategoryId(category.id).switchMap { durations ->
blockingReason.getTemporarilyTrustedTimeInMillis().map { timeInMillis ->
if (timeInMillis == null) {
BlockingReason.MissingNetworkTime
} else {
val remainingDuration = RemainingSessionDuration.getRemainingSessionDuration(
rules = rules,
dayOfWeek = nowTrustedDate.dayOfWeek,
durationsOfCategory = durations,
minuteOfDay = trustedMinuteOfWeek % MinuteOfDay.LENGTH,
timestamp = timeInMillis
)
if (remainingDuration == null || remainingDuration > 0) {
BlockingReason.None
} else {
BlockingReason.SessionDurationLimit
}
}
}
}
} else {
if (extraTime > 0) {
BlockingReason.TimeOverExtraTimeCanBeUsedLater
liveDataFromValue(BlockingReason.TimeOverExtraTimeCanBeUsedLater)
} else {
BlockingReason.TimeOver
liveDataFromValue(BlockingReason.TimeOver)
}
}
}.ignoreUnchanged()

View file

@ -0,0 +1,62 @@
/*
* TimeLimit Copyright <C> 2019- 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.logic
import io.timelimit.android.data.model.SessionDuration
import io.timelimit.android.data.model.TimeLimitRule
object RemainingSessionDuration {
fun min(a: Long?, b: Long?): Long? {
if (a == null && b == null) {
return null
} else if (a == null) {
return b
} else if (b == null) {
return a
} else {
return a.coerceAtMost(b)
}
}
fun getRemainingSessionDuration(
rules: List<TimeLimitRule>, durationsOfCategory: List<SessionDuration>,
dayOfWeek: Int, minuteOfDay: Int, timestamp: Long
): Long? {
var result: Long? = null
rules.forEach { rule ->
if (
rule.sessionDurationLimitEnabled &&
rule.dayMask.toInt() and (1 shl dayOfWeek) != 0 &&
rule.startMinuteOfDay <= minuteOfDay && rule.endMinuteOfDay >= minuteOfDay
) {
val remaining = durationsOfCategory.find {
it.startMinuteOfDay == rule.startMinuteOfDay &&
it.endMinuteOfDay == rule.endMinuteOfDay &&
it.maxSessionDuration == rule.sessionDurationMilliseconds &&
it.sessionPauseDuration == rule.sessionPauseMilliseconds &&
it.lastUsage + it.sessionPauseDuration > timestamp
}?.let { durationItem ->
(durationItem.maxSessionDuration - durationItem.lastSessionDuration).coerceAtLeast(0)
} ?: rule.sessionDurationMilliseconds.toLong()
result = min(result, remaining)
}
}
return result
}
}

View file

@ -15,8 +15,8 @@
*/
package io.timelimit.android.logic
import android.util.SparseLongArray
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.UsedTimeItem
data class RemainingTime(val includingExtraTime: Long, val default: Long) {
val hasRemainingTime = includingExtraTime > 0
@ -44,18 +44,21 @@ data class RemainingTime(val includingExtraTime: Long, val default: Long) {
)
}
private fun getRulesRelatedToDay(dayOfWeek: Int, rules: List<TimeLimitRule>): List<TimeLimitRule> {
return rules.filter { (it.dayMask.toInt() and (1 shl dayOfWeek)) != 0 }
private fun getRulesRelatedToDay(dayOfWeek: Int, minuteOfDay: Int, rules: List<TimeLimitRule>): List<TimeLimitRule> {
return rules.filter {
((it.dayMask.toInt() and (1 shl dayOfWeek)) != 0) &&
minuteOfDay >= it.startMinuteOfDay && minuteOfDay <= it.endMinuteOfDay
}
}
fun getRemainingTime(dayOfWeek: Int, usedTimes: SparseLongArray, rules: List<TimeLimitRule>, extraTime: Long): RemainingTime? {
fun getRemainingTime(dayOfWeek: Int, minuteOfDay: Int, usedTimes: List<UsedTimeItem>, rules: List<TimeLimitRule>, extraTime: Long, firstDayOfWeekAsEpochDay: Int): RemainingTime? {
if (extraTime < 0) {
throw IllegalStateException("extra time < 0")
}
val relatedRules = getRulesRelatedToDay(dayOfWeek, rules)
val withoutExtraTime = getRemainingTime(usedTimes, relatedRules, false)
val withExtraTime = getRemainingTime(usedTimes, relatedRules, true)
val relatedRules = getRulesRelatedToDay(dayOfWeek, minuteOfDay, rules)
val withoutExtraTime = getRemainingTime(usedTimes, relatedRules, false, firstDayOfWeekAsEpochDay)
val withExtraTime = getRemainingTime(usedTimes, relatedRules, true, firstDayOfWeekAsEpochDay)
if (withoutExtraTime == null && withExtraTime == null) {
// no rules
@ -83,17 +86,23 @@ data class RemainingTime(val includingExtraTime: Long, val default: Long) {
}
}
private fun getRemainingTime(usedTimes: SparseLongArray, relatedRules: List<TimeLimitRule>, assumeMaximalExtraTime: Boolean): Long? {
return relatedRules.filter { (!assumeMaximalExtraTime) || it.applyToExtraTimeUsage }.map {
private fun getRemainingTime(usedTimes: List<UsedTimeItem>, relatedRules: List<TimeLimitRule>, assumeMaximalExtraTime: Boolean, firstDayOfWeekAsEpochDay: Int): Long? {
return relatedRules.filter { (!assumeMaximalExtraTime) || it.applyToExtraTimeUsage }.map { rule ->
var usedTime = 0L
for (day in 0..6) {
if ((it.dayMask.toInt() and (1 shl day)) != 0) {
usedTime += usedTimes[day]
usedTimes.forEach { usedTimeItem ->
if (usedTimeItem.dayOfEpoch >= firstDayOfWeekAsEpochDay && usedTimeItem.dayOfEpoch <= firstDayOfWeekAsEpochDay + 6) {
val usedTimeItemDayOfWeek = usedTimeItem.dayOfEpoch - firstDayOfWeekAsEpochDay
if ((rule.dayMask.toInt() and (1 shl usedTimeItemDayOfWeek)) != 0) {
if (rule.startMinuteOfDay == usedTimeItem.startTimeOfDay && rule.endMinuteOfDay == usedTimeItem.endTimeOfDay) {
usedTime += usedTimeItem.usedMillis
}
}
}
}
val maxTime = it.maximumTimeInMillis
val maxTime = rule.maximumTimeInMillis
val remaining = Math.max(0, maxTime - usedTime)
remaining

View file

@ -21,7 +21,7 @@ import io.timelimit.android.data.transaction
import io.timelimit.android.date.DateInTimezone
object UsedTimeDeleter {
fun deleteOldUsedTimeItems(database: Database, date: DateInTimezone) {
fun deleteOldUsedTimeItems(database: Database, date: DateInTimezone, timestamp: Long) {
Threads.database.execute {
database.transaction().use {
if (database.config().getDeviceAuthTokenSync().isNotEmpty()) {
@ -38,6 +38,8 @@ object UsedTimeDeleter {
database.usedTimes().deleteOldUsedTimeItems(lastDayToKeep = date.dayOfEpoch - date.dayOfWeek)
database.sessionDuration().deleteOldSessionDurationItemsSync(trustedTimestamp = timestamp)
it.setSuccess()
}
}

View file

@ -19,6 +19,8 @@ import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.sync.actions.AddUsedTimeActionItem
import io.timelimit.android.sync.actions.AddUsedTimeActionItemAdditionalCountingSlot
import io.timelimit.android.sync.actions.AddUsedTimeActionItemSessionDurationLimitSlot
import io.timelimit.android.sync.actions.AddUsedTimeActionVersion2
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.actions.dispatch.CategoryNotFoundException
@ -30,9 +32,16 @@ class UsedTimeUpdateHelper (val date: DateInTimezone) {
val timeToAdd = mutableMapOf<String, Int>()
val extraTimeToSubtract = mutableMapOf<String, Int>()
val sessionDurationLimitSlots = mutableMapOf<String, Set<AddUsedTimeActionItemSessionDurationLimitSlot>>()
var trustedTimestamp: Long = 0
val additionalSlots = mutableMapOf<String, Set<AddUsedTimeActionItemAdditionalCountingSlot>>()
var shouldDoAutoCommit = false
fun add(categoryId: String, time: Int, includingExtraTime: Boolean) {
fun add(
categoryId: String, time: Int, slots: Set<AddUsedTimeActionItemAdditionalCountingSlot>,
includingExtraTime: Boolean, sessionDurationLimits: Set<AddUsedTimeActionItemSessionDurationLimitSlot>,
trustedTimestamp: Long
) {
if (time < 0) {
throw IllegalArgumentException()
}
@ -43,10 +52,24 @@ class UsedTimeUpdateHelper (val date: DateInTimezone) {
timeToAdd[categoryId] = (timeToAdd[categoryId] ?: 0) + time
if (sessionDurationLimits.isNotEmpty()) {
this.sessionDurationLimitSlots[categoryId] = sessionDurationLimits
}
if (sessionDurationLimits.isNotEmpty() && trustedTimestamp != 0L) {
this.trustedTimestamp = trustedTimestamp
}
if (includingExtraTime) {
extraTimeToSubtract[categoryId] = (extraTimeToSubtract[categoryId] ?: 0) + time
}
if (additionalSlots[categoryId] != null && slots != additionalSlots[categoryId]) {
shouldDoAutoCommit = true
} else if (slots.isNotEmpty()) {
additionalSlots[categoryId] = slots
}
if (timeToAdd[categoryId]!! >= 1000 * 10) {
shouldDoAutoCommit = true
}
@ -77,9 +100,12 @@ class UsedTimeUpdateHelper (val date: DateInTimezone) {
AddUsedTimeActionItem(
categoryId = categoryId,
timeToAdd = timeToAdd[categoryId] ?: 0,
extraTimeToSubtract = extraTimeToSubtract[categoryId] ?: 0
extraTimeToSubtract = extraTimeToSubtract[categoryId] ?: 0,
additionalCountingSlots = additionalSlots[categoryId] ?: emptySet(),
sessionDurationLimits = sessionDurationLimitSlots[categoryId] ?: emptySet()
)
}
},
trustedTimestamp = trustedTimestamp
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
@ -96,6 +122,9 @@ class UsedTimeUpdateHelper (val date: DateInTimezone) {
timeToAdd.clear()
extraTimeToSubtract.clear()
sessionDurationLimitSlots.clear()
trustedTimestamp = 0
additionalSlots.clear()
shouldDoAutoCommit = false
}
}

View file

@ -360,7 +360,24 @@ object ApplyServerDataStatus {
UsedTimeItem(
dayOfEpoch = it.dayOfEpoch,
usedMillis = it.usedMillis,
categoryId = categoryId
categoryId = categoryId,
startTimeOfDay = it.startTimeOfDay,
endTimeOfDay = it.endTimeOfDay
)
}
)
database.sessionDuration().deleteByCategoryId(categoryId)
database.sessionDuration().insertSessionDurationItemsSync(
newUsedTime.sessionDurations.map {
SessionDuration(
categoryId = categoryId,
maxSessionDuration = it.maxSessionDuration,
sessionPauseDuration = it.sessionPauseDuration,
startMinuteOfDay = it.startMinuteOfDay,
endMinuteOfDay = it.endMinuteOfDay,
lastUsage = it.lastUsage,
lastSessionDuration = it.lastSessionDuration
)
}
)

View file

@ -22,9 +22,11 @@ import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.sync.validation.ListValidation
import org.json.JSONArray
import org.json.JSONObject
import java.util.*
@ -142,20 +144,26 @@ data class AddUsedTimeAction(val categoryId: String, val dayOfEpoch: Int, val ti
}
}
data class AddUsedTimeActionVersion2(val dayOfEpoch: Int, val items: List<AddUsedTimeActionItem>): AppLogicAction() {
data class AddUsedTimeActionVersion2(
val dayOfEpoch: Int,
val items: List<AddUsedTimeActionItem>,
val trustedTimestamp: Long
): AppLogicAction() {
companion object {
const val TYPE_VALUE = "ADD_USED_TIME_V2"
private const val DAY_OF_EPOCH = "d"
private const val ITEMS = "i"
private const val TRUSTED_TIMESTAMP = "t"
fun parse(action: JSONObject): AddUsedTimeActionVersion2 = AddUsedTimeActionVersion2(
dayOfEpoch = action.getInt(DAY_OF_EPOCH),
items = ParseUtils.readObjectArray(action.getJSONArray(ITEMS)).map { AddUsedTimeActionItem.parse(it) }
items = ParseUtils.readObjectArray(action.getJSONArray(ITEMS)).map { AddUsedTimeActionItem.parse(it) },
trustedTimestamp = if (action.has(TRUSTED_TIMESTAMP)) action.getLong(TRUSTED_TIMESTAMP) else 0L
)
}
init {
if (dayOfEpoch < 0) {
if (dayOfEpoch < 0 || trustedTimestamp < 0) {
throw IllegalArgumentException()
}
@ -178,20 +186,42 @@ data class AddUsedTimeActionVersion2(val dayOfEpoch: Int, val items: List<AddUse
items.forEach { it.serialize(writer) }
writer.endArray()
if (trustedTimestamp != 0L) {
writer.name(TRUSTED_TIMESTAMP).value(trustedTimestamp)
}
writer.endObject()
}
}
data class AddUsedTimeActionItem(val categoryId: String, val timeToAdd: Int, val extraTimeToSubtract: Int) {
data class AddUsedTimeActionItem(
val categoryId: String, val timeToAdd: Int, val extraTimeToSubtract: Int,
val additionalCountingSlots: Set<AddUsedTimeActionItemAdditionalCountingSlot>,
val sessionDurationLimits: Set<AddUsedTimeActionItemSessionDurationLimitSlot>
) {
companion object {
private const val CATEGORY_ID = "categoryId"
private const val TIME_TO_ADD = "tta"
private const val EXTRA_TIME_TO_SUBTRACT = "etts"
private const val ADDITIONAL_COUNTING_SLOTS = "as"
private const val SESSION_DURATION_LIMITS = "sdl"
fun parse(item: JSONObject): AddUsedTimeActionItem = AddUsedTimeActionItem(
categoryId = item.getString(CATEGORY_ID),
timeToAdd = item.getInt(TIME_TO_ADD),
extraTimeToSubtract = item.getInt(EXTRA_TIME_TO_SUBTRACT)
extraTimeToSubtract = item.getInt(EXTRA_TIME_TO_SUBTRACT),
additionalCountingSlots = if (item.has(ADDITIONAL_COUNTING_SLOTS))
item.getJSONArray(ADDITIONAL_COUNTING_SLOTS).let { array ->
(0 until array.length()).map { AddUsedTimeActionItemAdditionalCountingSlot.parse(array.getJSONArray(it)) }
}.toSet()
else
emptySet(),
sessionDurationLimits = if (item.has(SESSION_DURATION_LIMITS))
item.getJSONArray(SESSION_DURATION_LIMITS).let { array ->
(0 until array.length()).map { AddUsedTimeActionItemSessionDurationLimitSlot.parse(array.getJSONArray(it)) }
}.toSet()
else
emptySet()
)
}
@ -214,10 +244,91 @@ data class AddUsedTimeActionItem(val categoryId: String, val timeToAdd: Int, val
writer.name(TIME_TO_ADD).value(timeToAdd)
writer.name(EXTRA_TIME_TO_SUBTRACT).value(extraTimeToSubtract)
if (additionalCountingSlots.isNotEmpty()) {
writer.name(ADDITIONAL_COUNTING_SLOTS).beginArray()
additionalCountingSlots.forEach { it.serialize(writer) }
writer.endArray()
}
if (sessionDurationLimits.isNotEmpty()) {
writer.name(SESSION_DURATION_LIMITS).beginArray()
sessionDurationLimits.forEach { it.serialize(writer) }
writer.endArray()
}
writer.endObject()
}
}
data class AddUsedTimeActionItemAdditionalCountingSlot(val start: Int, val end: Int) {
companion object {
fun parse(array: JSONArray): AddUsedTimeActionItemAdditionalCountingSlot {
val length = array.length()
if (length != 2) {
throw IllegalArgumentException()
}
return AddUsedTimeActionItemAdditionalCountingSlot(
start = array.getInt(0),
end = array.getInt(1)
)
}
}
init {
if (start < MinuteOfDay.MIN || end > MinuteOfDay.MAX || start > end) {
throw IllegalArgumentException()
}
if (start == MinuteOfDay.MIN && end == MinuteOfDay.MAX) {
throw IllegalArgumentException()
}
}
fun serialize(writer: JsonWriter) {
writer.beginArray()
.value(start).value(end)
.endArray()
}
}
data class AddUsedTimeActionItemSessionDurationLimitSlot(
val startMinuteOfDay: Int, val endMinuteOfDay: Int,
val maxSessionDuration: Int, val sessionPauseDuration: Int
) {
companion object {
fun parse(array: JSONArray): AddUsedTimeActionItemSessionDurationLimitSlot {
if (array.length() != 4) {
throw IllegalArgumentException()
}
return AddUsedTimeActionItemSessionDurationLimitSlot(
array.getInt(0), array.getInt(1), array.getInt(2), array.getInt(3)
)
}
}
init {
if (startMinuteOfDay < MinuteOfDay.MIN || endMinuteOfDay > MinuteOfDay.MAX || startMinuteOfDay > endMinuteOfDay) {
throw IllegalArgumentException()
}
if (maxSessionDuration <= 0 || sessionPauseDuration <= 0) {
throw IllegalArgumentException()
}
}
fun serialize(writer: JsonWriter) {
writer.beginArray()
.value(startMinuteOfDay)
.value(endMinuteOfDay)
.value(maxSessionDuration)
.value(sessionPauseDuration)
.endArray()
}
}
// data class ClearTemporarilyAllowedAppsAction(val deviceId: String): AppLogicAction(), LocalOnlyAction
data class InstalledApp(val packageName: String, val title: String, val isLaunchable: Boolean, val recommendation: AppRecommendation) {
@ -1299,13 +1410,20 @@ data class CreateTimeLimitRuleAction(val rule: TimeLimitRule): ParentAction() {
}
}
data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val maximumTimeInMillis: Int, val applyToExtraTimeUsage: Boolean): ParentAction() {
data class UpdateTimeLimitRuleAction(
val ruleId: String, val dayMask: Byte, val maximumTimeInMillis: Int, val applyToExtraTimeUsage: Boolean,
val start: Int, val end: Int, val sessionDurationMilliseconds: Int, val sessionPauseMilliseconds: Int
): ParentAction() {
companion object {
const val TYPE_VALUE = "UPDATE_TIMELIMIT_RULE"
private const val RULE_ID = "ruleId"
private const val MAX_TIME_IN_MILLIS = "time"
private const val DAY_MASK = "days"
private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime"
private const val START = "start"
private const val END = "end"
private const val SESSION_DURATION_MILLISECONDS = "dur"
private const val SESSION_PAUSE_MILLISECONDS = "pause"
}
init {
@ -1318,6 +1436,14 @@ data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val
if (dayMask < 0 || dayMask > (1 or 2 or 4 or 8 or 16 or 32 or 64)) {
throw IllegalArgumentException()
}
if (start < MinuteOfDay.MIN || end > MinuteOfDay.MAX || start > end) {
throw IllegalArgumentException()
}
if (sessionDurationMilliseconds < 0 || sessionPauseMilliseconds < 0) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
@ -1328,6 +1454,13 @@ data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val
writer.name(MAX_TIME_IN_MILLIS).value(maximumTimeInMillis)
writer.name(DAY_MASK).value(dayMask)
writer.name(APPLY_TO_EXTRA_TIME_USAGE).value(applyToExtraTimeUsage)
writer.name(START).value(start)
writer.name(END).value(end)
if (sessionPauseMilliseconds > 0 || sessionDurationMilliseconds > 0) {
writer.name(SESSION_DURATION_MILLISECONDS).value(sessionDurationMilliseconds)
writer.name(SESSION_PAUSE_MILLISECONDS).value(sessionPauseMilliseconds)
}
writer.endObject()
}
@ -1369,7 +1502,7 @@ data class AddUserAction(val name: String, val userType: UserType, val password:
fun parse(action: JSONObject): AddUserAction {
var password: ParentPassword? = null
val passwordObject = action.getJSONObject(PASSWORD)
val passwordObject = action.optJSONObject(PASSWORD)
if (passwordObject != null) {
password = ParentPassword.parse(passwordObject)

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -19,16 +19,7 @@ import android.util.JsonWriter
import java.io.StringWriter
object SerializationUtil {
fun serializeAction(action: ParentAction): String {
val stringWriter = StringWriter()
val jsonWriter = JsonWriter(stringWriter)
action.serialize(jsonWriter)
return stringWriter.buffer.toString()
}
fun serializeAction(action: AppLogicAction): String {
fun serializeAction(action: Action): String {
val stringWriter = StringWriter()
val jsonWriter = JsonWriter(stringWriter)

View file

@ -117,8 +117,22 @@ object ApplyActionUtil {
if (parsed is AddUsedTimeActionVersion2 && parsed.dayOfEpoch == action.dayOfEpoch) {
var updatedAction: AddUsedTimeActionVersion2 = parsed
var issues = false
if (parsed.trustedTimestamp != 0L && action.trustedTimestamp != 0L) {
issues = action.items.map { it.categoryId } != parsed.items.map { it.categoryId } ||
parsed.trustedTimestamp >= action.trustedTimestamp
updatedAction = updatedAction.copy(trustedTimestamp = action.trustedTimestamp)
// keep timestamp of the old action
} else if (parsed.trustedTimestamp != 0L || action.trustedTimestamp != 0L) {
issues = true
}
action.items.forEach { newItem ->
if (issues) return@forEach
val oldItem = updatedAction.items.find { it.categoryId == newItem.categoryId }
if (oldItem == null) {
@ -126,10 +140,28 @@ object ApplyActionUtil {
items = updatedAction.items + listOf(newItem)
)
} else {
if (
oldItem.additionalCountingSlots != newItem.additionalCountingSlots ||
oldItem.sessionDurationLimits != newItem.sessionDurationLimits
) {
issues = true
}
if (parsed.trustedTimestamp != 0L && action.trustedTimestamp != 0L) {
val timeBeforeCurrentItem = action.trustedTimestamp - newItem.timeToAdd
val diff = Math.abs(timeBeforeCurrentItem - parsed.trustedTimestamp)
if (diff > 2 * 1000) {
issues = true
}
}
val mergedItem = AddUsedTimeActionItem(
timeToAdd = oldItem.timeToAdd + newItem.timeToAdd,
extraTimeToSubtract = oldItem.extraTimeToSubtract + newItem.extraTimeToSubtract,
categoryId = newItem.categoryId
categoryId = newItem.categoryId,
additionalCountingSlots = oldItem.additionalCountingSlots,
sessionDurationLimits = oldItem.sessionDurationLimits
)
updatedAction = updatedAction.copy(
@ -138,20 +170,22 @@ object ApplyActionUtil {
}
}
// update the previous action
database.pendingSyncAction().updateEncodedActionSync(
sequenceNumber = previousAction.sequenceNumber,
action = StringWriter().apply {
JsonWriter(this).apply {
updatedAction.serialize(this)
}
}.toString()
)
if (!issues) {
// update the previous action
database.pendingSyncAction().updateEncodedActionSync(
sequenceNumber = previousAction.sequenceNumber,
action = StringWriter().apply {
JsonWriter(this).apply {
updatedAction.serialize(this)
}
}.toString()
)
database.setTransactionSuccessful()
syncUtil.requestVeryUnimportantSync()
database.setTransactionSuccessful()
syncUtil.requestVeryUnimportantSync()
return@executeAndWait
return@executeAndWait
}
}
}
}

View file

@ -16,10 +16,8 @@
package io.timelimit.android.sync.actions.dispatch
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.HadManipulationFlag
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.integration.platform.NewPermissionStatusUtil
import io.timelimit.android.integration.platform.ProtectionLevelUtil
import io.timelimit.android.integration.platform.RuntimePermissionStatusUtil
@ -46,7 +44,9 @@ object LocalDatabaseAppLogicActionDispatcher {
val updatedRows = database.usedTimes().addUsedTime(
categoryId = categoryId,
timeToAdd = action.timeToAdd,
dayOfEpoch = action.dayOfEpoch
dayOfEpoch = action.dayOfEpoch,
start = MinuteOfDay.MIN,
end = MinuteOfDay.MAX
)
if (updatedRows == 0) {
@ -55,7 +55,9 @@ object LocalDatabaseAppLogicActionDispatcher {
database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = categoryId,
dayOfEpoch = action.dayOfEpoch,
usedMillis = action.timeToAdd.toLong()
usedMillis = action.timeToAdd.toLong(),
startTimeOfDay = MinuteOfDay.MIN,
endTimeOfDay = MinuteOfDay.MAX
))
}
@ -81,22 +83,67 @@ object LocalDatabaseAppLogicActionDispatcher {
database.category().getCategoryByIdSync(item.categoryId)
?: throw CategoryNotFoundException()
val updatedRows = database.usedTimes().addUsedTime(
categoryId = item.categoryId,
timeToAdd = item.timeToAdd,
dayOfEpoch = action.dayOfEpoch
)
if (updatedRows == 0) {
// create new entry
database.usedTimes().insertUsedTime(UsedTimeItem(
fun handle(start: Int, end: Int) {
val updatedRows = database.usedTimes().addUsedTime(
categoryId = item.categoryId,
timeToAdd = item.timeToAdd,
dayOfEpoch = action.dayOfEpoch,
usedMillis = item.timeToAdd.toLong()
))
start = start,
end = end
)
if (updatedRows == 0) {
// create new entry
database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = item.categoryId,
dayOfEpoch = action.dayOfEpoch,
usedMillis = item.timeToAdd.toLong(),
startTimeOfDay = start,
endTimeOfDay = end
))
}
}
handle(MinuteOfDay.MIN, MinuteOfDay.MAX)
item.additionalCountingSlots.forEach { handle(it.start, it.end) }
kotlin.run {
val hasTrustedTimestamp = action.trustedTimestamp != 0L
item.sessionDurationLimits.forEach { limit ->
val oldItem = database.sessionDuration().getSessionDurationItemSync(
categoryId = item.categoryId,
maxSessionDuration = limit.maxSessionDuration,
sessionPauseDuration = limit.sessionPauseDuration,
startMinuteOfDay = limit.startMinuteOfDay,
endMinuteOfDay = limit.endMinuteOfDay
)
val newItem = oldItem?.copy(
lastUsage = if (hasTrustedTimestamp) action.trustedTimestamp else oldItem.lastUsage,
lastSessionDuration = if (hasTrustedTimestamp && action.trustedTimestamp - item.timeToAdd > oldItem.lastUsage + oldItem.sessionPauseDuration)
item.timeToAdd.toLong()
else
oldItem.lastSessionDuration + item.timeToAdd.toLong()
) ?: SessionDuration(
categoryId = item.categoryId,
maxSessionDuration = limit.maxSessionDuration,
sessionPauseDuration = limit.sessionPauseDuration,
startMinuteOfDay = limit.startMinuteOfDay,
endMinuteOfDay = limit.endMinuteOfDay,
lastSessionDuration = item.timeToAdd.toLong(),
// this will cause a small loss of session durations
lastUsage = if (hasTrustedTimestamp) action.trustedTimestamp else 0
)
if (oldItem == null) {
database.sessionDuration().insertSessionDurationItemSync(newItem)
} else {
database.sessionDuration().updateSessionDurationItemSync(newItem)
}
}
}
if (item.extraTimeToSubtract != 0) {
database.category().subtractCategoryExtraTime(

View file

@ -205,7 +205,11 @@ object LocalDatabaseParentActionDispatcher {
database.timeLimitRules().updateTimeLimitRule(oldRule.copy(
maximumTimeInMillis = action.maximumTimeInMillis,
dayMask = action.dayMask,
applyToExtraTimeUsage = action.applyToExtraTimeUsage
applyToExtraTimeUsage = action.applyToExtraTimeUsage,
startMinuteOfDay = action.start,
endMinuteOfDay = action.end,
sessionDurationMilliseconds = action.sessionDurationMilliseconds,
sessionPauseMilliseconds = action.sessionPauseMilliseconds
))
}
is SetDeviceUserAction -> {

View file

@ -33,6 +33,8 @@ data class ClientDataStatus(
private const val APPS = "apps"
private const val CATEGORIES = "categories"
private const val USERS = "users"
private const val CLIENT_LEVEL = "clientLevel"
private const val CLIENT_LEVEL_VALUE = 2
val empty = ClientDataStatus(
deviceListVersion = "",
@ -75,6 +77,7 @@ data class ClientDataStatus(
fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(CLIENT_LEVEL).value(CLIENT_LEVEL_VALUE)
writer.name(DEVICES).value(deviceListVersion)
writer.name(USERS).value(userListVersion)

View file

@ -19,6 +19,7 @@ import android.util.JsonReader
import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.parseList
import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.actions.AppActivityItem
@ -476,16 +477,19 @@ data class ServerUpdatedCategoryAssignedApps(
data class ServerUpdatedCategoryUsedTimes(
val categoryId: String,
val usedTimeItems: List<ServerUsedTimeItem>,
val sessionDurations: List<ServerSessionDuration>,
val version: String
) {
companion object {
private const val CATEGORY_ID = "categoryId"
private const val USED_TIMES_ITEMS = "times"
private const val SESSION_DURATIONS = "sessionDurations"
private const val VERSION = "version"
fun parse(reader: JsonReader): ServerUpdatedCategoryUsedTimes {
var categoryId: String? = null
var usedTimeItems: List<ServerUsedTimeItem>? = null
var sessionDurations = emptyList<ServerSessionDuration>()
var version: String? = null
reader.beginObject()
@ -493,6 +497,7 @@ data class ServerUpdatedCategoryUsedTimes(
when (reader.nextName()) {
CATEGORY_ID -> categoryId = reader.nextString()
USED_TIMES_ITEMS -> usedTimeItems = ServerUsedTimeItem.parseList(reader)
SESSION_DURATIONS -> sessionDurations = ServerSessionDuration.parseList(reader)
VERSION -> version = reader.nextString()
else -> reader.skipValue()
}
@ -502,6 +507,7 @@ data class ServerUpdatedCategoryUsedTimes(
return ServerUpdatedCategoryUsedTimes(
categoryId = categoryId!!,
usedTimeItems = usedTimeItems!!,
sessionDurations = sessionDurations,
version = version!!
)
}
@ -512,21 +518,29 @@ data class ServerUpdatedCategoryUsedTimes(
data class ServerUsedTimeItem(
val dayOfEpoch: Int,
val usedMillis: Long
val usedMillis: Long,
val startTimeOfDay: Int,
val endTimeOfDay: Int
) {
companion object {
private const val DAY_OF_EPOCH = "day"
private const val USED_MILLIS = "time"
private const val START_TIME_OF_DAY = "start"
private const val END_TIME_OF_DAY = "end"
fun parse(reader: JsonReader): ServerUsedTimeItem {
var dayOfEpoch: Int? = null
var usedMillis: Long? = null
var startTimeOfDay: Int = MinuteOfDay.MIN
var endTimeOfDay: Int = MinuteOfDay.MAX
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
DAY_OF_EPOCH -> dayOfEpoch = reader.nextInt()
USED_MILLIS -> usedMillis = reader.nextLong()
START_TIME_OF_DAY -> startTimeOfDay = reader.nextInt()
END_TIME_OF_DAY -> endTimeOfDay = reader.nextInt()
else -> reader.skipValue()
}
}
@ -534,7 +548,9 @@ data class ServerUsedTimeItem(
return ServerUsedTimeItem(
dayOfEpoch = dayOfEpoch!!,
usedMillis = usedMillis!!
usedMillis = usedMillis!!,
startTimeOfDay = startTimeOfDay,
endTimeOfDay = endTimeOfDay
)
}
@ -552,6 +568,68 @@ data class ServerUsedTimeItem(
}
}
data class ServerSessionDuration(
val maxSessionDuration: Int,
val sessionPauseDuration: Int,
val startMinuteOfDay: Int,
val endMinuteOfDay: Int,
val lastUsage: Long,
val lastSessionDuration: Long
) {
companion object {
private const val MAX_SESSION_DURATION = "md"
private const val SESSION_PAUSE_DURATION = "spd"
private const val START_MINUTE_OF_DAY = "sm"
private const val END_MINUTE_OF_DAY = "em"
private const val LAST_USAGE = "l"
private const val LAST_SESSION_DURATION = "d"
fun parse(reader: JsonReader): ServerSessionDuration {
var maxSessionDuration: Int? = null
var sessionPauseDuration: Int? = null
var startMinuteOfDay: Int? = null
var endMinuteOfDay: Int? = null
var lastUsage: Long? = null
var lastSessionDuration: Long? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
MAX_SESSION_DURATION -> maxSessionDuration = reader.nextInt()
SESSION_PAUSE_DURATION -> sessionPauseDuration = reader.nextInt()
START_MINUTE_OF_DAY -> startMinuteOfDay = reader.nextInt()
END_MINUTE_OF_DAY -> endMinuteOfDay = reader.nextInt()
LAST_USAGE -> lastUsage = reader.nextLong()
LAST_SESSION_DURATION -> lastSessionDuration = reader.nextLong()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerSessionDuration(
maxSessionDuration = maxSessionDuration!!,
sessionPauseDuration = sessionPauseDuration!!,
startMinuteOfDay = startMinuteOfDay!!,
endMinuteOfDay = endMinuteOfDay!!,
lastUsage = lastUsage!!,
lastSessionDuration = lastSessionDuration!!
)
}
fun parseList(reader: JsonReader): List<ServerSessionDuration> {
val result = ArrayList<ServerSessionDuration>()
reader.beginArray()
while (reader.hasNext()) {
result.add(parse(reader))
}
reader.endArray()
return Collections.unmodifiableList(result)
}
}
}
data class ServerUpdatedTimeLimitRules(
val categoryId: String,
val version: String,
@ -593,19 +671,31 @@ data class ServerTimeLimitRule(
val id: String,
val applyToExtraTimeUsage: Boolean,
val dayMask: Byte,
val maximumTimeInMillis: Int
val maximumTimeInMillis: Int,
val startMinuteOfDay: Int,
val endMinuteOfDay: Int,
val sessionDurationMilliseconds: Int,
val sessionPauseMilliseconds: Int
) {
companion object {
private const val ID = "id"
private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime"
private const val DAY_MASK = "dayMask"
private const val MAXIMUM_TIME_IN_MILLIS = "maxTime"
private const val START_MINUTE_OF_DAY = "start"
private const val END_MINUTE_OF_DAY = "end"
private const val SESSION_DURATION_MILLISECONDS = "session"
private const val SESSION_PAUSE_MILLISECONDS = "pause"
fun parse(reader: JsonReader): ServerTimeLimitRule {
var id: String? = null
var applyToExtraTimeUsage: Boolean? = null
var dayMask: Byte? = null
var maximumTimeInMillis: Int? = null
var startMinuteOfDay = TimeLimitRule.MIN_START_MINUTE
var endMinuteOfDay = TimeLimitRule.MAX_END_MINUTE
var sessionDurationMilliseconds: Int = 0
var sessionPauseMilliseconds: Int = 0
reader.beginObject()
while (reader.hasNext()) {
@ -614,6 +704,10 @@ data class ServerTimeLimitRule(
APPLY_TO_EXTRA_TIME_USAGE -> applyToExtraTimeUsage = reader.nextBoolean()
DAY_MASK -> dayMask = reader.nextInt().toByte()
MAXIMUM_TIME_IN_MILLIS -> maximumTimeInMillis = reader.nextInt()
START_MINUTE_OF_DAY -> startMinuteOfDay = reader.nextInt()
END_MINUTE_OF_DAY -> endMinuteOfDay = reader.nextInt()
SESSION_DURATION_MILLISECONDS -> sessionDurationMilliseconds = reader.nextInt()
SESSION_PAUSE_MILLISECONDS -> sessionPauseMilliseconds = reader.nextInt()
else -> reader.skipValue()
}
}
@ -623,7 +717,11 @@ data class ServerTimeLimitRule(
id = id!!,
applyToExtraTimeUsage = applyToExtraTimeUsage!!,
dayMask = dayMask!!,
maximumTimeInMillis = maximumTimeInMillis!!
maximumTimeInMillis = maximumTimeInMillis!!,
startMinuteOfDay = startMinuteOfDay,
endMinuteOfDay = endMinuteOfDay,
sessionDurationMilliseconds = sessionDurationMilliseconds,
sessionPauseMilliseconds = sessionPauseMilliseconds
)
}
@ -645,7 +743,11 @@ data class ServerTimeLimitRule(
applyToExtraTimeUsage = applyToExtraTimeUsage,
dayMask = dayMask,
maximumTimeInMillis = maximumTimeInMillis,
categoryId = categoryId
categoryId = categoryId,
startMinuteOfDay = startMinuteOfDay,
endMinuteOfDay = endMinuteOfDay,
sessionDurationMilliseconds = sessionDurationMilliseconds,
sessionPauseMilliseconds = sessionPauseMilliseconds
)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -21,9 +21,11 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.R
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.databinding.AddItemViewBinding
import io.timelimit.android.databinding.FragmentCategoryTimeLimitRuleItemBinding
import io.timelimit.android.databinding.TimeLimitRuleIntroductionBinding
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.util.JoinUtil
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
@ -36,7 +38,8 @@ class Adapter: RecyclerView.Adapter<ViewHolder>() {
}
var data: List<TimeLimitRuleItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
var usedTimes: List<Long>? by Delegates.observable(null as List<Long>?) { _, _, _ -> notifyDataSetChanged() }
var usedTimes: List<UsedTimeItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
var epochDayOfStartOfWeek: Int by Delegates.observable(0) { _, _, _ -> notifyDataSetChanged() }
var handlers: Handlers? = null
init {
@ -110,26 +113,42 @@ class Adapter: RecyclerView.Adapter<ViewHolder>() {
is TimeLimitRuleRuleItem -> {
val rule = item.rule
val binding = (holder as ItemViewHolder).view
val context = binding.root.context
val dayNames = binding.root.resources.getStringArray(R.array.days_of_week_array)
val usedTime = usedTimes?.mapIndexed { index, value ->
if (rule.dayMask.toInt() and (1 shl index) != 0) {
value
} else {
0
}
}?.sum()?.toInt() ?: 0
val usedTime = usedTimes.filter { usedTime ->
val dayOfWeek = usedTime.dayOfEpoch - epochDayOfStartOfWeek
usedTime.startTimeOfDay == rule.startMinuteOfDay && usedTime.endTimeOfDay == rule.endMinuteOfDay &&
(rule.dayMask.toInt() and (1 shl dayOfWeek) != 0)
}.map { it.usedMillis }.sum().toInt()
binding.maxTimeString = TimeTextUtil.time(rule.maximumTimeInMillis, binding.root.context)
binding.usageAsText = TimeTextUtil.used(usedTime, binding.root.context)
binding.maxTimeString = TimeTextUtil.time(rule.maximumTimeInMillis, context)
binding.usageAsText = TimeTextUtil.used(usedTime, context)
binding.usageProgressInPercent = if (rule.maximumTimeInMillis > 0)
(usedTime * 100 / rule.maximumTimeInMillis)
else
100
binding.daysString = JoinUtil.join(
dayNames.filterIndexed { index, _ -> (rule.dayMask.toInt() and (1 shl index)) != 0 },
binding.root.context
context
)
binding.timeAreaString = if (rule.appliesToWholeDay)
null
else
context.getString(
R.string.category_time_limit_rules_time_area,
MinuteOfDay.format(rule.startMinuteOfDay),
MinuteOfDay.format(rule.endMinuteOfDay)
)
binding.appliesToExtraTime = rule.applyToExtraTimeUsage
binding.sessionLimitString = if (rule.sessionDurationLimitEnabled)
context.getString(
R.string.category_time_limit_rules_session_limit,
TimeTextUtil.time(rule.sessionPauseMilliseconds, context),
TimeTextUtil.time(rule.sessionDurationMilliseconds, context)
)
else
null
binding.card.setOnClickListener { handlers?.onTimeLimitRuleClicked(rule) }
binding.executePendingBindings()

View file

@ -74,14 +74,19 @@ class CategoryTimeLimitRulesFragment : Fragment(), EditTimeLimitRuleDialogFragme
val userDate = database.user().getUserByIdLive(params.childId).getDateLive(logic.realTimeLogic)
val usedTimeItems = userDate.switchMap {
date ->
userDate.switchMap { date ->
val firstDayOfWeekAsEpochDay = date.dayOfEpoch - date.dayOfWeek
database.usedTimes().getUsedTimesOfWeek(
categoryId = params.categoryId,
firstDayOfWeekAsEpochDay = date.dayOfEpoch - date.dayOfWeek
)
}
firstDayOfWeekAsEpochDay = firstDayOfWeekAsEpochDay
).map { res ->
firstDayOfWeekAsEpochDay to res
}
}.observe(viewLifecycleOwner, Observer {
adapter.epochDayOfStartOfWeek = it.first
adapter.usedTimes = it.second
})
val hasHiddenIntro = database.config().wereHintsShown(HintsToShow.TIME_LIMIT_RULE_INTRODUCTION)
@ -97,16 +102,10 @@ class CategoryTimeLimitRulesFragment : Fragment(), EditTimeLimitRuleDialogFragme
listOf(TimeLimitRuleIntroductionItem) + baseList
}
}
}.observe(this, Observer {
}.observe(viewLifecycleOwner, Observer {
adapter.data = it
})
usedTimeItems.observe(this, Observer {
usedTimes ->
adapter.usedTimes = (0..6).map { usedTimes[it]?.usedMillis ?: 0 } }
)
adapter.handlers = object: Handlers {
override fun onTimeLimitRuleClicked(rule: TimeLimitRule) {
if (auth.requestAuthenticationOrReturnTrue()) {
@ -171,7 +170,11 @@ class CategoryTimeLimitRulesFragment : Fragment(), EditTimeLimitRuleDialogFragme
ruleId = oldRule.id,
applyToExtraTimeUsage = oldRule.applyToExtraTimeUsage,
maximumTimeInMillis = oldRule.maximumTimeInMillis,
dayMask = oldRule.dayMask
dayMask = oldRule.dayMask,
start = oldRule.startMinuteOfDay,
end = oldRule.endMinuteOfDay,
sessionDurationMilliseconds = oldRule.sessionDurationMilliseconds,
sessionPauseMilliseconds = oldRule.sessionPauseMilliseconds
)
)
}

View file

@ -0,0 +1,93 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.timelimit_rules.edit
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Observer
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.databinding.DurationPickerDialogFragmentBinding
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
class DurationPickerDialogFragment: DialogFragment() {
companion object {
private const val DIALOG_TAG = "DurationPickerDialogFragment"
private const val TITLE_RES = "titleRes"
private const val INDEX = "index"
private const val START_TIME_IN_MILLIS = "startTimeInMillis"
fun newInstance(titleRes: Int, index: Int, target: Fragment, startTimeInMillis: Int) = DurationPickerDialogFragment().apply {
arguments = Bundle().apply {
putInt(TITLE_RES, titleRes)
putInt(INDEX, index)
putInt(START_TIME_IN_MILLIS, startTimeInMillis)
}
setTargetFragment(target, 0)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = DurationPickerDialogFragmentBinding.inflate(LayoutInflater.from(context!!))
val view = binding.duration
val target = targetFragment as DurationPickerDialogFragmentListener
val index = arguments!!.getInt(INDEX)
val titleRes = arguments!!.getInt(TITLE_RES)
val startTimeInMillis = arguments!!.getInt(START_TIME_IN_MILLIS)
val config = DefaultAppLogic.with(context!!).database.config()
if (savedInstanceState == null) {
view.timeInMillis = startTimeInMillis.toLong()
}
config.getEnableAlternativeDurationSelectionAsync().observe(this, Observer {
view.enablePickerMode(it)
})
view.listener = object: SelectTimeSpanViewListener {
override fun onTimeSpanChanged(newTimeInMillis: Long) = Unit
override fun setEnablePickerMode(enable: Boolean) {
Threads.database.execute {
config.setEnableAlternativeDurationSelectionSync(enable)
}
}
}
return AlertDialog.Builder(context!!, theme)
.setTitle(titleRes)
.setView(binding.root)
.setPositiveButton(R.string.generic_ok) { _, _ ->
target.onDurationSelected(view.timeInMillis.toInt(), index)
}
.setNegativeButton(R.string.generic_cancel, null)
.create()
}
fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG)
}
interface DurationPickerDialogFragmentListener {
fun onDurationSelected(durationInMillis: Int, index: Int)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,12 +20,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Observer
import com.google.android.material.R
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.IdGenerator
@ -33,6 +34,7 @@ import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentEditTimeLimitRuleDialogBinding
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.logic.DefaultAppLogic
@ -44,11 +46,12 @@ import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.mustread.MustReadFragment
import io.timelimit.android.ui.view.SelectDayViewHandlers
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
import io.timelimit.android.util.TimeTextUtil
import java.nio.ByteBuffer
import java.util.*
class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment(), DurationPickerDialogFragmentListener {
companion object {
private const val PARAM_EXISTING_RULE = "a"
private const val PARAM_CATEGORY_ID = "b"
@ -76,6 +79,8 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
var existingRule: TimeLimitRule? = null
var savedNewRule: TimeLimitRule? = null
lateinit var newRule: TimeLimitRule
lateinit var view: FragmentEditTimeLimitRuleDialogBinding
private val categoryId: String by lazy {
if (existingRule != null) {
@ -109,13 +114,37 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
?: arguments?.getParcelable<TimeLimitRule?>(PARAM_EXISTING_RULE)
}
fun bindRule() {
savedNewRule = newRule
view.daySelection.selectedDays = BitSet.valueOf(
ByteBuffer.allocate(1).put(newRule.dayMask).apply {
position(0)
}
)
view.applyToExtraTime = newRule.applyToExtraTimeUsage
view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong()
val affectedDays = Math.max(0, (0..6).map { (newRule.dayMask.toInt() shr it) and 1 }.sum())
view.timeSpan.maxDays = Math.max(0, affectedDays - 1) // max prevents crash
view.affectsMultipleDays = affectedDays >= 2
view.applyToWholeDay = newRule.appliesToWholeDay
view.startTime = MinuteOfDay.format(newRule.startMinuteOfDay)
view.endTime = MinuteOfDay.format(newRule.endMinuteOfDay)
view.enableSessionDurationLimit = newRule.sessionDurationLimitEnabled
view.sessionBreakText = TimeTextUtil.minutes(newRule.sessionPauseMilliseconds / (1000 * 60), context!!)
view.sessionLengthText = TimeTextUtil.minutes(newRule.sessionDurationMilliseconds / (1000 * 60), context!!)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = FragmentEditTimeLimitRuleDialogBinding.inflate(layoutInflater, container, false)
val listener = targetFragment as EditTimeLimitRuleDialogFragmentListener
var newRule: TimeLimitRule
val database = DefaultAppLogic.with(context!!).database
auth.authenticatedUser.observe(this, Observer {
view = FragmentEditTimeLimitRuleDialogBinding.inflate(layoutInflater, container, false)
auth.authenticatedUser.observe(viewLifecycleOwner, Observer {
if (it == null || it.second.type != UserType.Parent) {
dismissAllowingStateLoss()
}
@ -129,7 +158,11 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
categoryId = categoryId,
applyToExtraTimeUsage = false,
dayMask = 0,
maximumTimeInMillis = 1000 * 60 * 60 * 5 / 2 // 2,5 (5/2) hours
maximumTimeInMillis = 1000 * 60 * 60 * 5 / 2, // 2,5 (5/2) hours
startMinuteOfDay = TimeLimitRule.MIN_START_MINUTE,
endMinuteOfDay = TimeLimitRule.MAX_END_MINUTE,
sessionPauseMilliseconds = 0,
sessionDurationMilliseconds = 0
)
} else {
view.isNewRule = false
@ -145,22 +178,6 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
}
}
fun bindRule() {
savedNewRule = newRule
view.daySelection.selectedDays = BitSet.valueOf(
ByteBuffer.allocate(1).put(newRule.dayMask).apply {
position(0)
}
)
view.applyToExtraTime = newRule.applyToExtraTimeUsage
view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong()
val affectedDays = Math.max(0, (0..6).map { (newRule.dayMask.toInt() shr it) and 1 }.sum())
view.timeSpan.maxDays = Math.max(0, affectedDays - 1) // max prevents crash
view.affectsMultipleDays = affectedDays >= 2
}
bindRule()
view.daySelection.handlers = object: SelectDayViewHandlers {
override fun updateDayChecked(day: Int) {
@ -181,6 +198,72 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
bindRule()
}
override fun updateApplyToWholeDay(apply: Boolean) {
if (apply) {
newRule = newRule.copy(
startMinuteOfDay = TimeLimitRule.MIN_START_MINUTE,
endMinuteOfDay = TimeLimitRule.MAX_END_MINUTE
)
} else {
newRule = newRule.copy(
startMinuteOfDay = 10 * 60,
endMinuteOfDay = 16 * 60
)
}
bindRule()
}
override fun updateStartTime() {
TimePickerDialogFragment.newInstance(
editTimeLimitRuleDialogFragment = this@EditTimeLimitRuleDialogFragment,
index = 0,
startMinuteOfDay = newRule.startMinuteOfDay
).show(parentFragmentManager)
}
override fun updateEndTime() {
TimePickerDialogFragment.newInstance(
editTimeLimitRuleDialogFragment = this@EditTimeLimitRuleDialogFragment,
index = 1,
startMinuteOfDay = newRule.endMinuteOfDay
).show(parentFragmentManager)
}
override fun updateSessionDurationLimit(enable: Boolean) {
if (enable) {
newRule = newRule.copy(
sessionDurationMilliseconds = 1000 * 60 * 30,
sessionPauseMilliseconds = 1000 * 60 * 10
)
} else {
newRule = newRule.copy(
sessionDurationMilliseconds = 0,
sessionPauseMilliseconds = 0
)
}
bindRule()
}
override fun updateSessionLength() {
DurationPickerDialogFragment.newInstance(
titleRes = R.string.category_time_limit_rules_session_limit_duration,
index = 0,
target = this@EditTimeLimitRuleDialogFragment,
startTimeInMillis = newRule.sessionDurationMilliseconds
).show(parentFragmentManager)
}
override fun updateSessionBreak() {
DurationPickerDialogFragment.newInstance(
titleRes = R.string.category_time_limit_rules_session_limit_pause,
index = 1,
target = this@EditTimeLimitRuleDialogFragment,
startTimeInMillis = newRule.sessionPauseMilliseconds
).show(parentFragmentManager)
}
override fun onSaveRule() {
view.timeSpan.clearNumberPickerFocus()
@ -191,7 +274,11 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
ruleId = newRule.id,
maximumTimeInMillis = newRule.maximumTimeInMillis,
dayMask = newRule.dayMask,
applyToExtraTimeUsage = newRule.applyToExtraTimeUsage
applyToExtraTimeUsage = newRule.applyToExtraTimeUsage,
start = newRule.startMinuteOfDay,
end = newRule.endMinuteOfDay,
sessionDurationMilliseconds = newRule.sessionDurationMilliseconds,
sessionPauseMilliseconds = newRule.sessionPauseMilliseconds
)
)) {
return
@ -245,13 +332,13 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
}
}
database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer {
database.config().getEnableAlternativeDurationSelectionAsync().observe(viewLifecycleOwner, Observer {
view.timeSpan.enablePickerMode(it)
})
if (existingRule != null) {
database.timeLimitRules()
.getTimeLimitRuleByIdLive(existingRule!!.id).observe(this, Observer {
.getTimeLimitRuleByIdLive(existingRule!!.id).observe(viewLifecycleOwner, Observer {
if (it == null) {
// rule was deleted
dismissAllowingStateLoss()
@ -301,10 +388,64 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
}
}
}
fun handleTimePickerResult(index: Int, minuteOfDay: Int) {
if (!MinuteOfDay.isValid(minuteOfDay)) {
Toast.makeText(context, R.string.error_general, Toast.LENGTH_SHORT).show()
return
}
if (index == 0) {
// start minute
if (minuteOfDay > newRule.endMinuteOfDay) {
Toast.makeText(context, R.string.category_time_limit_rules_invalid_range, Toast.LENGTH_SHORT).show()
} else {
newRule = newRule.copy(startMinuteOfDay = minuteOfDay)
bindRule()
}
} else if (index == 1) {
// end minute
if (minuteOfDay < newRule.startMinuteOfDay) {
Toast.makeText(context, R.string.category_time_limit_rules_invalid_range, Toast.LENGTH_SHORT).show()
} else {
newRule = newRule.copy(endMinuteOfDay = minuteOfDay)
bindRule()
}
} else {
Toast.makeText(context, R.string.error_general, Toast.LENGTH_SHORT).show()
}
}
override fun onDurationSelected(durationInMillis: Int, index: Int) {
if (index == 0) {
newRule = newRule.copy(
sessionDurationMilliseconds = durationInMillis
)
bindRule()
} else if (index == 1) {
newRule = newRule.copy(
sessionPauseMilliseconds = durationInMillis
)
bindRule()
} else {
throw IllegalArgumentException()
}
}
}
interface Handlers {
fun updateApplyToExtraTime(apply: Boolean)
fun updateApplyToWholeDay(apply: Boolean)
fun updateStartTime()
fun updateEndTime()
fun updateSessionDurationLimit(enable: Boolean)
fun updateSessionLength()
fun updateSessionBreak()
fun onSaveRule()
fun onDeleteRule()
}

View file

@ -0,0 +1,55 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.timelimit_rules.edit
import android.app.Dialog
import android.app.TimePickerDialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import io.timelimit.android.extensions.showSafe
class TimePickerDialogFragment: DialogFragment() {
companion object {
private const val INDEX = "index"
private const val START_MINUTE_OF_DAY = "startMinuteOfDay"
private const val DIALOG_TAG = "TimePickerDialogFragment"
fun newInstance(
editTimeLimitRuleDialogFragment: EditTimeLimitRuleDialogFragment,
index: Int,
startMinuteOfDay: Int
) = TimePickerDialogFragment().apply {
setTargetFragment(editTimeLimitRuleDialogFragment, 0)
arguments = Bundle().apply {
putInt(INDEX, index)
putInt(START_MINUTE_OF_DAY, startMinuteOfDay)
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val fragment = targetFragment as EditTimeLimitRuleDialogFragment
val index = arguments!!.getInt(INDEX)
val startMinuteOfDay = arguments!!.getInt(START_MINUTE_OF_DAY)
return TimePickerDialog(context, theme, { _, hour, minute ->
fragment.handleTimePickerResult(index, hour * 60 + minute)
}, startMinuteOfDay / 60, startMinuteOfDay % 60, true)
}
fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,24 +16,28 @@
package io.timelimit.android.ui.manage.category.usagehistory
import android.text.format.DateFormat
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.R
import io.timelimit.android.data.model.UsedTimeListItem
import io.timelimit.android.databinding.FragmentUsageHistoryItemBinding
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.util.TimeTextUtil
import org.threeten.bp.LocalDate
import org.threeten.bp.ZoneOffset
import java.util.*
class UsageHistoryAdapter: PagedListAdapter<UsedTimeItem, UsageHistoryViewHolder>(diffCallback) {
class UsageHistoryAdapter: PagedListAdapter<UsedTimeListItem, UsageHistoryViewHolder>(diffCallback) {
companion object {
private val diffCallback = object: DiffUtil.ItemCallback<UsedTimeItem>() {
override fun areContentsTheSame(oldItem: UsedTimeItem, newItem: UsedTimeItem) = oldItem == newItem
override fun areItemsTheSame(oldItem: UsedTimeItem, newItem: UsedTimeItem) =
(oldItem.dayOfEpoch == newItem.dayOfEpoch) && (oldItem.categoryId == newItem.categoryId)
private val diffCallback = object: DiffUtil.ItemCallback<UsedTimeListItem>() {
override fun areContentsTheSame(oldItem: UsedTimeListItem, newItem: UsedTimeListItem) = oldItem == newItem
override fun areItemsTheSame(oldItem: UsedTimeListItem, newItem: UsedTimeListItem) =
(oldItem.day == newItem.day) && (oldItem.startMinuteOfDay == newItem.startMinuteOfDay) &&
(oldItem.endMinuteOfDay == newItem.endMinuteOfDay) && (oldItem.maxSessionDuration == newItem.maxSessionDuration)
}
}
@ -50,17 +54,35 @@ class UsageHistoryAdapter: PagedListAdapter<UsedTimeItem, UsageHistoryViewHolder
val binding = holder.binding
val context = binding.root.context
if (item == null) {
val timeAreaString = if (item == null || item.startMinuteOfDay == MinuteOfDay.MIN && item.endMinuteOfDay == MinuteOfDay.MAX)
null
else
context.getString(R.string.usage_history_time_area, MinuteOfDay.format(item.startMinuteOfDay), MinuteOfDay.format(item.endMinuteOfDay))
if (item?.day != null) {
val dateObject = LocalDate.ofEpochDay(item.day)
val dateString = DateFormat.getDateFormat(context).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(dateObject.atStartOfDay().toEpochSecond(ZoneOffset.UTC) * 1000L))
binding.date = dateString
binding.timeArea = timeAreaString
binding.usedTime = TimeTextUtil.used(item.duration.toInt(), context)
} else if (item?.lastUsage != null && item.maxSessionDuration != null && item.pauseDuration != null) {
binding.date = context.getString(
R.string.usage_history_item_session_duration_limit,
TimeTextUtil.time(item.maxSessionDuration.toInt(), context),
TimeTextUtil.time(item.pauseDuration.toInt(), context)
)
binding.timeArea = timeAreaString
binding.usedTime = TimeTextUtil.used(item.duration.toInt(), context) + "\n" +
context.getString(
R.string.usage_history_item_last_usage,
DateUtils.formatDateTime(context, item.lastUsage, DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE)
)
} else {
binding.date = ""
binding.usedTime = ""
} else {
val date = LocalDate.ofEpochDay(item.dayOfEpoch.toLong())
binding.date = DateFormat.getDateFormat(context).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(date.atStartOfDay().toEpochSecond(ZoneOffset.UTC) * 1000L))
binding.usedTime = TimeTextUtil.used(item.usedMillis.toInt(), context)
}
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -42,11 +42,11 @@ class UsageHistoryFragment : Fragment() {
val adapter = UsageHistoryAdapter()
LivePagedListBuilder(
database.usedTimes().getUsedTimesByCategoryId(params.categoryId),
database.usedTimes().getUsedTimeListItemsByCategoryId(params.categoryId),
10
)
.build()
.observe(this, Observer {
.observe(viewLifecycleOwner, Observer {
binding.isEmpty = it.isEmpty()
adapter.submitList(it)
})

View file

@ -16,7 +16,6 @@
package io.timelimit.android.ui.manage.child.category
import android.app.Application
import android.util.SparseLongArray
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.data.extensions.mapToTimezone
@ -24,6 +23,7 @@ import io.timelimit.android.data.extensions.sorted
import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromFunction
import io.timelimit.android.livedata.map
@ -100,19 +100,16 @@ class ManageChildCategoriesModel(application: Application): AndroidViewModel(app
isBlockedTimeNow = category.blockedMinutesInWeek.read(childMinuteOfWeek),
remainingTimeToday = RemainingTime.getRemainingTime(
dayOfWeek = childDate.dayOfWeek,
usedTimes = SparseLongArray().apply {
usedTimeItemsForCategory.forEach { usedTimeItem ->
val dayOfWeek = usedTimeItem.dayOfEpoch - firstDayOfWeek
put(dayOfWeek, usedTimeItem.usedMillis)
}
},
usedTimes = usedTimeItemsForCategory,
rules = rules,
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch)
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch),
minuteOfDay = childMinuteOfWeek % MinuteOfDay.LENGTH,
firstDayOfWeekAsEpochDay = firstDayOfWeek
)?.includingExtraTime,
usedTimeToday = usedTimeItemsForCategory.find { item -> item.dayOfEpoch == childDate.dayOfEpoch }?.usedMillis
?: 0,
usedTimeToday = usedTimeItemsForCategory.find { item ->
item.dayOfEpoch == childDate.dayOfEpoch && item.startTimeOfDay == MinuteOfDay.MIN &&
item.endTimeOfDay == MinuteOfDay.MAX
}?.usedMillis ?: 0,
usedForNotAssignedApps = categoryForUnassignedApps == category.id,
parentCategoryTitle = parentCategory?.title
)

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -64,7 +64,11 @@ class DefaultCategories private constructor(private val context: Context) {
categoryId = categoryId,
applyToExtraTimeUsage = false,
dayMask = (1 shl day).toByte(),
maximumTimeInMillis = 1000 * 60 * 30 // 30 minutes
maximumTimeInMillis = 1000 * 60 * 30, // 30 minutes
startMinuteOfDay = TimeLimitRule.MIN_START_MINUTE,
endMinuteOfDay = TimeLimitRule.MAX_END_MINUTE,
sessionPauseMilliseconds = 0,
sessionDurationMilliseconds = 0
)
)
}
@ -77,7 +81,11 @@ class DefaultCategories private constructor(private val context: Context) {
categoryId = categoryId,
applyToExtraTimeUsage = false,
dayMask = (1 shl day).toByte(),
maximumTimeInMillis = 1000 * 60 * 60 * 3 // 3 hours
maximumTimeInMillis = 1000 * 60 * 60 * 3, // 3 hours
startMinuteOfDay = TimeLimitRule.MIN_START_MINUTE,
endMinuteOfDay = TimeLimitRule.MAX_END_MINUTE,
sessionPauseMilliseconds = 0,
sessionDurationMilliseconds = 0
)
)
}
@ -93,7 +101,11 @@ class DefaultCategories private constructor(private val context: Context) {
categoryId = categoryId,
applyToExtraTimeUsage = false,
dayMask = 1 + 2 + 4 + 8 + 16 + 32 + 64,
maximumTimeInMillis = 1000 * 60 * 60 * 6 // 6 hours
maximumTimeInMillis = 1000 * 60 * 60 * 6, // 6 hours
startMinuteOfDay = TimeLimitRule.MIN_START_MINUTE,
endMinuteOfDay = TimeLimitRule.MAX_END_MINUTE,
sessionPauseMilliseconds = 0,
sessionDurationMilliseconds = 0
)
)

View file

@ -26,7 +26,7 @@ import io.timelimit.android.databinding.ViewSelectTimeSpanBinding
import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates
class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLayout(context, attributeSet) {
class SelectTimeSpanView(context: Context, attributeSet: AttributeSet? = null): FrameLayout(context, attributeSet) {
private val binding = ViewSelectTimeSpanBinding.inflate(LayoutInflater.from(context), this, false)
init {

View file

@ -18,7 +18,11 @@ package io.timelimit.android.ui.widget
import android.util.SparseLongArray
import androidx.lifecycle.LiveData
import io.timelimit.android.data.extensions.mapToTimezone
import io.timelimit.android.data.model.getCurrentTimeSlotStartMinute
import io.timelimit.android.data.model.getSlotSwitchMinutes
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromFunction
import io.timelimit.android.livedata.map
@ -34,6 +38,11 @@ object TimesWidgetItems {
val userDate = userTimezone.switchMap { timeZone ->
liveDataFromFunction { DateInTimezone.newInstance(logic.realTimeLogic.getCurrentTimeInMillis(), timeZone) }
}.ignoreUnchanged()
val userMinuteOfWeek = userTimezone.switchMap { timeZone ->
liveDataFromFunction {
getMinuteOfWeek(logic.realTimeLogic.getCurrentTimeInMillis(), timeZone)
}
}.ignoreUnchanged()
val categories = userId.switchMap { logic.database.category().getCategoriesByChildId(it) }
val usedTimeItemsForWeek = userDate.switchMap { date ->
categories.switchMap { categories ->
@ -49,36 +58,37 @@ object TimesWidgetItems {
categories.map { category -> category.id }
)
}
val timeLimitSlot = timeLimitRules.map { it.getSlotSwitchMinutes() }.switchMap {
userMinuteOfWeek.switchMap { minuteOfWeek ->
getCurrentTimeSlotStartMinute(it, userMinuteOfWeek.map { it % MinuteOfDay.LENGTH })
}
}
val categoryItems = categories.switchMap { categories ->
timeLimitRules.switchMap { timeLimitRules ->
userDate.switchMap { childDate ->
usedTimeItemsForWeek.map { usedTimeItemsForWeek ->
val rulesByCategoryId = timeLimitRules.groupBy { rule -> rule.categoryId }
val usedTimesByCategory = usedTimeItemsForWeek.groupBy { item -> item.categoryId }
val firstDayOfWeek = childDate.dayOfEpoch - childDate.dayOfWeek
timeLimitSlot.switchMap { timeLimitSlot ->
userDate.switchMap { childDate ->
usedTimeItemsForWeek.map { usedTimeItemsForWeek ->
val rulesByCategoryId = timeLimitRules.groupBy { rule -> rule.categoryId }
val usedTimesByCategory = usedTimeItemsForWeek.groupBy { item -> item.categoryId }
val firstDayOfWeek = childDate.dayOfEpoch - childDate.dayOfWeek
categories.map { category ->
val rules = rulesByCategoryId[category.id] ?: emptyList()
val usedTimeItemsForCategory = usedTimesByCategory[category.id]
?: emptyList()
val parentCategory = categories.find { it.id == category.parentCategoryId }
categories.map { category ->
val rules = rulesByCategoryId[category.id] ?: emptyList()
val usedTimeItemsForCategory = usedTimesByCategory[category.id]
?: emptyList()
TimesWidgetItem(
title = category.title,
remainingTimeToday = RemainingTime.getRemainingTime(
dayOfWeek = childDate.dayOfWeek,
usedTimes = SparseLongArray().apply {
usedTimeItemsForCategory.forEach { usedTimeItem ->
val dayOfWeek = usedTimeItem.dayOfEpoch - firstDayOfWeek
put(dayOfWeek, usedTimeItem.usedMillis)
}
},
rules = rules,
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch)
)?.includingExtraTime
)
TimesWidgetItem(
title = category.title,
remainingTimeToday = RemainingTime.getRemainingTime(
dayOfWeek = childDate.dayOfWeek,
usedTimes = usedTimeItemsForCategory,
rules = rules,
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch),
minuteOfDay = timeLimitSlot,
firstDayOfWeekAsEpochDay = firstDayOfWeek
)?.includingExtraTime
)
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -67,6 +67,14 @@ object TimeTextUtil {
}
}
fun pauseIn(time: Int, context: Context): String {
return if (time <= 1000 * 60) {
context.getString(R.string.util_time_pause_shortly)
} else {
context.getString(R.string.util_time_pause_in, time(time, context))
}
}
fun used(time: Int, context: Context): String {
return if (time <= 0) {
context.resources.getString(R.string.util_time_unused)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M9,16h2L11,8L9,8v8zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM13,16h2L15,8h-2v8z"/>
</vector>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<io.timelimit.android.ui.view.SelectTimeSpanView
android:padding="16dp"
android:id="@+id/duration"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</layout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -34,6 +34,14 @@
name="daysString"
type="String" />
<variable
name="timeAreaString"
type="String" />
<variable
name="sessionLimitString"
type="String" />
<variable
name="appliesToExtraTime"
type="Boolean" />
@ -72,6 +80,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:text="@{timeAreaString}"
tools:text="von 12:00 bis 18:00"
android:visibility="@{TextUtils.isEmpty(timeAreaString) ? View.GONE : View.VISIBLE}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:text="@{sessionLimitString}"
tools:text="10 Minuten Pause nach 5 Minuten Nutzung"
android:visibility="@{TextUtils.isEmpty(sessionLimitString) ? View.GONE : View.VISIBLE}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{safeUnbox(appliesToExtraTime) ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceSmall"

View file

@ -14,6 +14,7 @@
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragment">
<data>
@ -25,6 +26,10 @@
name="applyToExtraTime"
type="Boolean" />
<variable
name="applyToWholeDay"
type="Boolean" />
<variable
name="handlers"
type="io.timelimit.android.ui.manage.category.timelimit_rules.edit.Handlers" />
@ -33,74 +38,223 @@
name="affectsMultipleDays"
type="boolean" />
<variable
name="startTime"
type="String" />
<variable
name="endTime"
type="String" />
<variable
name="enableSessionDurationLimit"
type="boolean" />
<variable
name="sessionLengthText"
type="String" />
<variable
name="sessionBreakText"
type="String" />
<import type="android.view.View" />
</data>
<LinearLayout
android:padding="8dp"
android:orientation="vertical"
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceLarge"
tools:text="@string/category_time_limit_rule_dialog_new"
android:text="@{safeUnbox(isNewRule) ? @string/category_time_limit_rule_dialog_new : @string/category_time_limit_rule_dialog_edit}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<include layout="@layout/view_select_days" android:id="@+id/day_selection" />
<io.timelimit.android.ui.view.SelectTimeSpanView
android:id="@+id/time_span"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:onClick="@{() -> handlers.updateApplyToExtraTime(!safeUnbox(applyToExtraTime))}"
android:checked="@{safeUnbox(applyToExtraTime)}"
android:text="@string/category_time_limit_rules_apply_to_extra_time"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{affectsMultipleDays ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/category_time_limit_rules_warning_multiple_days"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:id="@+id/scroll">
<LinearLayout
android:orientation="horizontal"
android:padding="8dp"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="0dp" />
<Button
android:layout_marginEnd="4dp"
style="?borderlessButtonStyle"
android:onClick="@{() -> handlers.onDeleteRule()}"
android:visibility="@{safeUnbox(isNewRule) ? View.GONE : View.VISIBLE}"
android:textColor="@color/text_red"
android:text="@string/generic_delete"
android:layout_width="wrap_content"
<TextView
android:textAppearance="?android:textAppearanceLarge"
tools:text="@string/category_time_limit_rule_dialog_new"
android:text="@{safeUnbox(isNewRule) ? @string/category_time_limit_rule_dialog_new : @string/category_time_limit_rule_dialog_edit}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:onClick="@{() -> handlers.onSaveRule()}"
tools:text="@string/generic_create"
android:text="@{safeUnbox(isNewRule) ? @string/generic_create : @string/generic_save}"
android:layout_width="wrap_content"
<include layout="@layout/view_select_days" android:id="@+id/day_selection" />
<io.timelimit.android.ui.view.SelectTimeSpanView
android:id="@+id/time_span"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:onClick="@{() -> handlers.updateApplyToExtraTime(!safeUnbox(applyToExtraTime))}"
android:checked="@{safeUnbox(applyToExtraTime)}"
android:text="@string/category_time_limit_rules_apply_to_extra_time"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:onClick="@{() -> handlers.updateApplyToWholeDay(!safeUnbox(applyToWholeDay))}"
android:checked="@{safeUnbox(applyToWholeDay)}"
android:text="@string/category_time_limit_rules_apply_to_whole_day"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.flexbox.FlexboxLayout
app:flexWrap="wrap"
app:alignItems="center"
android:visibility="@{safeUnbox(applyToWholeDay) ? View.GONE : View.VISIBLE}"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:padding="8dp"
android:text="@string/category_time_limit_rules_apply_to_part_day_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
style="?materialButtonOutlinedStyle"
android:onClick="@{() -> handlers.updateStartTime()}"
tools:text="10:00"
android:text="@{startTime}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:padding="8dp"
android:text="@string/category_time_limit_rules_apply_to_part_day_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
style="?materialButtonOutlinedStyle"
android:onClick="@{() -> handlers.updateEndTime()}"
tools:text="16:00"
android:text="@{endTime}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:padding="8dp"
android:text="@string/category_time_limit_rules_apply_to_part_day_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</com.google.android.flexbox.FlexboxLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:onClick="@{() -> handlers.updateSessionDurationLimit(!safeUnbox(enableSessionDurationLimit))}"
android:checked="@{enableSessionDurationLimit}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/category_time_limit_rules_enable_session_limit" />
<androidx.gridlayout.widget.GridLayout
android:visibility="@{enableSessionDurationLimit ? View.VISIBLE : View.GONE}"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
app:layout_columnWeight="1"
app:layout_gravity="fill_vertical"
android:gravity="center_vertical"
android:paddingEnd="8dp"
android:paddingStart="8dp"
app:layout_row="1"
app:layout_column="1"
android:textAppearance="?android:textAppearanceMedium"
android:text="@string/category_time_limit_rules_session_limit_duration"
android:layout_width="0dp"
android:layout_height="wrap_content" />
<Button
android:layout_marginEnd="4dp"
app:layout_columnWeight="0"
android:onClick="@{() -> handlers.updateSessionLength()}"
app:layout_gravity="center_vertical|fill_horizontal"
style="?materialButtonOutlinedStyle"
app:layout_row="1"
app:layout_column="3"
tools:text="199999 Minuten"
android:text="@{sessionLengthText}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
app:layout_columnWeight="1"
app:layout_gravity="fill_vertical"
android:gravity="center_vertical"
android:paddingEnd="8dp"
android:paddingStart="8dp"
android:textAppearance="?android:textAppearanceMedium"
app:layout_row="2"
app:layout_column="1"
android:text="@string/category_time_limit_rules_session_limit_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:layout_marginEnd="4dp"
app:layout_columnWeight="0"
android:onClick="@{() -> handlers.updateSessionBreak()}"
app:layout_gravity="center_vertical|fill_horizontal"
style="?materialButtonOutlinedStyle"
app:layout_row="2"
app:layout_column="3"
tools:text="20 Minuten"
android:text="@{sessionBreakText}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</androidx.gridlayout.widget.GridLayout>
<TextView
android:visibility="@{safeUnbox(applyToWholeDay) ? View.GONE : View.VISIBLE}"
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/category_time_limit_rules_warning_day_part"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{safeUnbox(affectsMultipleDays) ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/category_time_limit_rules_warning_multiple_days"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="0dp" />
<Button
android:layout_marginEnd="4dp"
style="?borderlessButtonStyle"
android:onClick="@{() -> handlers.onDeleteRule()}"
android:visibility="@{safeUnbox(isNewRule) ? View.GONE : View.VISIBLE}"
android:textColor="@color/text_red"
android:text="@string/generic_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:onClick="@{() -> handlers.onSaveRule()}"
tools:text="@string/generic_create"
android:text="@{safeUnbox(isNewRule) ? @string/generic_create : @string/generic_save}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</layout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -20,9 +20,16 @@
name="date"
type="String" />
<variable
name="timeArea"
type="String" />
<variable
name="usedTime"
type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" />
</data>
<LinearLayout
@ -38,6 +45,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(timeArea) ? View.GONE : View.VISIBLE}"
android:textAppearance="?android:textAppearanceMedium"
android:text="@{timeArea}"
tools:text="von 10:00 bis 16:00"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:text="@{usedTime}"

View file

@ -275,6 +275,20 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:paddingStart="8dp"
android:paddingEnd="0dp"
tools:ignore="UnusedAttribute"
android:drawablePadding="16dp"
android:drawableTint="?colorOnSurface"
android:drawableStart="@drawable/ic_pause_circle_outline_black_24dp"
android:visibility="@{reason == BlockingReason.SessionDurationLimit ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceMedium"
android:text="@{@string/lock_reason_session_duration(blockedKindLabel)}"
tools:text="@string/lock_reason_session_duration"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<ProgressBar
android:visibility="@{reason == null ? View.VISIBLE : View.GONE}"
android:padding="8dp"
@ -513,7 +527,7 @@
</androidx.cardview.widget.CardView>
<io.timelimit.android.ui.view.ManageDisableTimelimitsView
android:visibility="@{reason == BlockingReason.BlockedAtThisTime || reason == BlockingReason.TimeOver || reason == BlockingReason.TimeOverExtraTimeCanBeUsedLater ? View.VISIBLE : View.GONE}"
android:visibility="@{reason == BlockingReason.BlockedAtThisTime || reason == BlockingReason.TimeOver || reason == BlockingReason.TimeOverExtraTimeCanBeUsedLater || reason == BlockingReason.SessionDurationLimit ? View.VISIBLE : View.GONE}"
android:id="@+id/manage_disable_time_limits"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

View file

@ -30,7 +30,7 @@
android:layout_height="wrap_content">
<HorizontalScrollView
android:layout_centerHorizontal="true"
android:id="@+id/scroll"
android:id="@+id/select_days_scroll"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.button.MaterialButtonToggleGroup

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -23,9 +23,19 @@
Regeln, bei denen nur ein Tag gewählt wurde, begrenzen nur diesen Tag.
</string>
<string name="category_time_limit_rules_applied_to_extra_time">gilt auch für ggf. vorhandene Extra-Zeit</string>
<string name="category_time_limit_rules_time_area">nur von %s bis %s gültig</string>
<string name="category_time_limit_rules_session_limit">%s Pause nach %s Nutzung</string>
<string name="category_time_limit_rule_dialog_new">Regel erstellen</string>
<string name="category_time_limit_rule_dialog_edit">Regel bearbeiten</string>
<string name="category_time_limit_rules_apply_to_extra_time">Auch auf die Extra-Zeit anwenden</string>
<string name="category_time_limit_rules_apply_to_whole_day">Für den ganzen Tag anwenden</string>
<string name="category_time_limit_rules_apply_to_part_day_1">Von</string>
<string name="category_time_limit_rules_apply_to_part_day_2">bis</string>
<string name="category_time_limit_rules_apply_to_part_day_3">anwenden</string>
<string name="category_time_limit_rules_invalid_range">Die Anfangszeit muss vor der Endzeit liegen</string>
<string name="category_time_limit_rules_enable_session_limit">Sitzungsdauer beschränken</string>
<string name="category_time_limit_rules_session_limit_duration">maximale Nutzungsdauer</string>
<string name="category_time_limit_rules_session_limit_pause">Pausendauer</string>
<string name="category_time_limit_rules_snackbar_created">Regel wurde erstellt</string>
<string name="category_time_limit_rules_snackbar_updated">Regel wurde geändert</string>
@ -35,4 +45,9 @@
wird die Gesamtnutzungsdauer in einer Woche an den gewählten Tagen einschränken.
Wenn Sei die Begrenzung je Tag wollen, dann erstellen Sie eine Regel je Tag.
</string>
<string name="category_time_limit_rules_warning_day_part">Diese Regel begrenzt nur
die Nutzung im angegebenen Intervall. Ohne Regeln für andere Intervalle
oder Sperrzeiten wird es keine Begrenzung außerhalb dieses Intervalls
geben.
</string>
</resources>

View file

@ -80,6 +80,10 @@
und dieses Akkulimit wurde erreicht.
Zum Wiederfreigeben muss der Akku aufgeladen werden.
</string>
<string name="lock_reason_session_duration">
Für diese %s gibt es eine Sitzungsdauerbegrenzung.
Nach dem Ablauf der Pausenzeit wird die Sperre aufgehoben.
</string>
<string name="lock_reason_short_no_category">keine Kategorie</string>
<string name="lock_reason_short_temporarily_blocked">vorübergehend gesperrt</string>
@ -89,6 +93,7 @@
<string name="lock_reason_short_requires_current_device">nicht als aktuelles Gerät gewählt</string>
<string name="lock_reason_short_notification_blocking">alle Benachrichtigungen werden blockiert</string>
<string name="lock_reason_short_battery_limit">Akkulimit unterschritten</string>
<string name="lock_reason_short_session_duration">Sitzungsdauergrenze erreicht</string>
<string name="lock_overlay_warning">
Öffnen des Sperrbildschirms fehlgeschlagen.

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -20,4 +20,7 @@
Die Nutzungszeiten werden nur erfasst,
wenn Zeitbegrenzungsregeln existieren und die zeitbegrenzten Apps genutzt werden
</string>
<string name="usage_history_time_area">von %s bis %s</string>
<string name="usage_history_item_session_duration_limit">Sitzungsdauerbegrenzung von %s mit %s Pause</string>
<string name="usage_history_item_last_usage">Letzte Verwendung: %s</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -39,6 +39,8 @@
<string name="util_limit_hours_and_minutes"><xliff:g id="hours">%1$s</xliff:g> und <xliff:g>%2$s</xliff:g></string>
<string name="util_time_remaining">Noch <xliff:g example="3 minutes" id="time">%1$s</xliff:g></string>
<string name="util_time_done">Zeit verbraucht</string>
<string name="util_time_pause_in">Pause in %s</string>
<string name="util_time_pause_shortly">gleich eine Pause</string>
<string name="util_time_unused">Unbenutzt</string>
<string name="util_time_used"><xliff:g example="4 minutes" id="used time">%1$s</xliff:g> benutzt</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -23,9 +23,19 @@
Rules at which only one day is checked only limit the selected day.
</string>
<string name="category_time_limit_rules_applied_to_extra_time">is applied to extra time, too</string>
<string name="category_time_limit_rules_time_area">only applied from %s to %s</string>
<string name="category_time_limit_rules_session_limit">%s break after %s usage</string>
<string name="category_time_limit_rule_dialog_new">Create rule</string>
<string name="category_time_limit_rule_dialog_edit">Edit rule</string>
<string name="category_time_limit_rules_apply_to_extra_time">Apply to the extra time</string>
<string name="category_time_limit_rules_apply_to_whole_day">Apply to whole day</string>
<string name="category_time_limit_rules_apply_to_part_day_1">Apply from</string>
<string name="category_time_limit_rules_apply_to_part_day_2">to</string>
<string name="category_time_limit_rules_apply_to_part_day_3"></string>
<string name="category_time_limit_rules_invalid_range">The start time must be before the end time</string>
<string name="category_time_limit_rules_enable_session_limit">Limit session duration</string>
<string name="category_time_limit_rules_session_limit_duration">Maximum session duration</string>
<string name="category_time_limit_rules_session_limit_pause">Break duration after session</string>
<string name="category_time_limit_rules_snackbar_created">Rule was created</string>
<string name="category_time_limit_rules_snackbar_updated">Rule was modified</string>
@ -35,4 +45,9 @@
will limit the total usage duration during one week at the selected days.
If you want it per day, create one rule per day.
</string>
<string name="category_time_limit_rules_warning_day_part">This rule
will limit the usage duration only in the specified interval.
Without rules for other times or blocked time areas, there will
be no limit outside of this interval.
</string>
</resources>

View file

@ -84,6 +84,11 @@
battery limit was reached.
To unlock it, charge the battery.
</string>
<string name="lock_reason_session_duration">
This %s is part of a category which has got a session duration limit
and this limit was reached.
It will be unlocked after the break duration.
</string>
<string name="lock_reason_short_no_category">no category</string>
<string name="lock_reason_short_temporarily_blocked">temporarily blocked</string>
@ -93,6 +98,7 @@
<string name="lock_reason_short_requires_current_device">device must be the current device</string>
<string name="lock_reason_short_notification_blocking">all notifications are blocked</string>
<string name="lock_reason_short_battery_limit">battery limit reached</string>
<string name="lock_reason_short_session_duration">session duration limit reached</string>
<string name="lock_overlay_warning">
Failed to open the lock screen.

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -21,4 +21,7 @@
when there are time limits and
the limited Apps are used
</string>
<string name="usage_history_time_area">from %s until %s</string>
<string name="usage_history_item_session_duration_limit">Session duration limit of %s with %s break</string>
<string name="usage_history_item_last_usage">Last usage: %s</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TimeLimit Copyright <C> 2019 Jonas Lochmann
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -39,6 +39,8 @@
<string name="util_limit_hours_and_minutes"><xliff:g id="hours">%1$s</xliff:g> and <xliff:g>%2$s</xliff:g></string>
<string name="util_time_remaining"><xliff:g example="3 minutes" id="time">%1$s</xliff:g> remaining</string>
<string name="util_time_done">Time over</string>
<string name="util_time_pause_in">Break in %s</string>
<string name="util_time_pause_shortly">Break in a few moments</string>
<string name="util_time_unused">Unused</string>
<string name="util_time_used">Used <xliff:g example="4 minutes" id="used time">%1$s</xliff:g></string>