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.appcompat:appcompat:1.1.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.cardview:cardview: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 "com.google.android.material:material:1.1.0"
implementation "android.arch.navigation:navigation-fragment-ktx:$nav_version" 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 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.json.JSONObject
import org.junit.Test import org.junit.Test
class Actions { 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 @Test
fun decrementCategoryExtraTimeShouldBeSerializedAndParsedCorrectly() { fun testActionSerializationAndDeserializationWorks() {
val originalAction = DecrementCategoryExtraTimeAction(categoryId = "abcdef", extraTimeToSubtract = 1000 * 30) appLogicActions.forEach { originalAction ->
val serializedAction = SerializationUtil.serializeAction(originalAction)
val parsedAction = ActionParser.parseAppLogicAction(JSONObject(serializedAction))
val serializedAction = SerializationUtil.serializeAction(originalAction) assert(parsedAction == originalAction)
val parsedAction = ActionParser.parseAppLogicAction(JSONObject(serializedAction)) }
}
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 notification(): NotificationDao
fun allowedContact(): AllowedContactDao fun allowedContact(): AllowedContactDao
fun userKey(): UserKeyDao fun userKey(): UserKeyDao
fun sessionDuration(): SessionDurationDao
fun beginTransaction() fun beginTransaction()
fun setTransactionSuccessful() fun setTransactionSuccessful()

View file

@ -17,6 +17,8 @@ package io.timelimit.android.data
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.extensions.MinuteOfDay
object DatabaseMigrations { object DatabaseMigrations {
val MIGRATE_TO_V2 = object: Migration(1, 2) { 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`)") 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, AppActivity::class,
Notification::class, Notification::class,
AllowedContact::class, AllowedContact::class,
UserKey::class UserKey::class,
], version = 28) SessionDuration::class
], version = 29)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object { companion object {
private val lock = 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_V25,
DatabaseMigrations.MIGRATE_TO_V26, DatabaseMigrations.MIGRATE_TO_V26,
DatabaseMigrations.MIGRATE_TO_V27, DatabaseMigrations.MIGRATE_TO_V27,
DatabaseMigrations.MIGRATE_TO_V28 DatabaseMigrations.MIGRATE_TO_V28,
DatabaseMigrations.MIGRATE_TO_V29
) )
.build() .build()
} }

View file

@ -36,12 +36,13 @@ object DatabaseBackupLowlevel {
private const val DEVICE = "device" private const val DEVICE = "device"
private const val PENDING_SYNC_ACTION = "pendingSyncAction" private const val PENDING_SYNC_ACTION = "pendingSyncAction"
private const val TIME_LIMIT_RULE = "timelimitRule" 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 USER = "user"
private const val APP_ACTIVITY = "appActivity" private const val APP_ACTIVITY = "appActivity"
private const val NOTIFICATION = "notification" private const val NOTIFICATION = "notification"
private const val ALLOWED_CONTACT = "allowedContact" private const val ALLOWED_CONTACT = "allowedContact"
private const val USER_KEY = "userKey" private const val USER_KEY = "userKey"
private const val SESSION_DURATION = "sessionDuration"
fun outputAsBackupJson(database: Database, outputStream: OutputStream) { fun outputAsBackupJson(database: Database, outputStream: OutputStream) {
val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8))
@ -87,6 +88,7 @@ object DatabaseBackupLowlevel {
handleCollection(NOTIFICATION) { offset, pageSize -> database.notification().getNotificationPageSync(offset, pageSize) } handleCollection(NOTIFICATION) { offset, pageSize -> database.notification().getNotificationPageSync(offset, pageSize) }
handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) } handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) }
handleCollection(USER_KEY) { offset, pageSize -> database.userKey().getUserKeyPageSync(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() writer.endObject().flush()
} }
@ -226,6 +228,15 @@ object DatabaseBackupLowlevel {
reader.endArray() reader.endArray()
} }
SESSION_DURATION -> {
reader.beginArray()
while (reader.hasNext()) {
database.sessionDuration().addSessionDurationIgnoreErrorsSync(SessionDuration.parse(reader))
}
reader.endArray()
}
else -> reader.skipValue() 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 package io.timelimit.android.data.dao
import android.util.SparseArray
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.paging.DataSource import androidx.paging.DataSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import io.timelimit.android.data.model.UsedTimeItem import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.data.model.UsedTimeListItem
import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.ignoreUnchanged
@Dao @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") @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>> protected abstract fun getUsedTimesOfWeekInternal(categoryId: String, startingDayOfEpoch: Int, endDayOfEpoch: Int): LiveData<List<UsedTimeItem>>
fun getUsedTimesOfWeek(categoryId: String, firstDayOfWeekAsEpochDay: Int): LiveData<SparseArray<UsedTimeItem>> { fun getUsedTimesOfWeek(categoryId: String, firstDayOfWeekAsEpochDay: Int): LiveData<List<UsedTimeItem>> {
return Transformations.map(getUsedTimesOfWeekInternal(categoryId, firstDayOfWeekAsEpochDay, firstDayOfWeekAsEpochDay + 6).ignoreUnchanged()) { return getUsedTimesOfWeekInternal(categoryId, firstDayOfWeekAsEpochDay, firstDayOfWeekAsEpochDay + 6).ignoreUnchanged()
val result = SparseArray<UsedTimeItem>()
it.forEach {
result.put(it.dayOfEpoch - firstDayOfWeekAsEpochDay, it)
}
result
}
} }
@Insert @Insert
@ -48,14 +39,11 @@ abstract class UsedTimeDao {
@Insert @Insert
abstract fun insertUsedTimes(item: List<UsedTimeItem>) abstract fun insertUsedTimes(item: List<UsedTimeItem>)
@Query("UPDATE used_time SET used_time = :newUsedTime WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch") @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 updateUsedTime(categoryId: String, dayOfEpoch: Int, newUsedTime: Long) 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") @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 addUsedTime(categoryId: String, dayOfEpoch: Int, timeToAdd: Int): Int abstract fun getUsedTimeItemSync(categoryId: String, dayOfEpoch: Int, start: Int, end: Int): UsedTimeItem?
@Query("SELECT * FROM used_time WHERE category_id = :categoryId AND day_of_epoch = :dayOfEpoch")
abstract fun getUsedTimeItem(categoryId: String, dayOfEpoch: Int): LiveData<UsedTimeItem?>
@Query("DELETE FROM used_time WHERE category_id = :categoryId") @Query("DELETE FROM used_time WHERE category_id = :categoryId")
abstract fun deleteUsedTimeItems(categoryId: String) abstract fun deleteUsedTimeItems(categoryId: String)
@ -66,12 +54,13 @@ abstract class UsedTimeDao {
@Query("SELECT * FROM used_time LIMIT :pageSize OFFSET :offset") @Query("SELECT * FROM used_time LIMIT :pageSize OFFSET :offset")
abstract fun getUsedTimePageSync(offset: Int, pageSize: Int): List<UsedTimeItem> 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") @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>> abstract fun getUsedTimesByDayAndCategoryIds(categoryIds: List<String>, startingDayOfEpoch: Int, endDayOfEpoch: Int): LiveData<List<UsedTimeItem>>
@Query("SELECT * FROM used_time") @Query("SELECT * FROM used_time")
abstract fun getAllUsedTimeItemsSync(): List<UsedTimeItem> 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 * 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 * 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.os.Parcelable
import android.util.JsonReader import android.util.JsonReader
import android.util.JsonWriter import android.util.JsonWriter
import androidx.lifecycle.LiveData
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@ -25,6 +26,9 @@ import androidx.room.TypeConverters
import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.data.customtypes.ImmutableBitmaskAdapter 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 import kotlinx.android.parcel.Parcelize
@Entity(tableName = "time_limit_rule") @Entity(tableName = "time_limit_rule")
@ -41,7 +45,15 @@ data class TimeLimitRule(
@ColumnInfo(name = "day_mask") @ColumnInfo(name = "day_mask")
val dayMask: Byte, val dayMask: Byte,
@ColumnInfo(name = "max_time") @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 { ): Parcelable, JsonSerializable {
companion object { companion object {
private const val RULE_ID = "ruleId" private const val RULE_ID = "ruleId"
@ -49,6 +61,13 @@ data class TimeLimitRule(
private const val MAX_TIME_IN_MILLIS = "time" private const val MAX_TIME_IN_MILLIS = "time"
private const val DAY_MASK = "days" private const val DAY_MASK = "days"
private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime" 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 { fun parse(reader: JsonReader): TimeLimitRule {
var id: String? = null var id: String? = null
@ -56,6 +75,10 @@ data class TimeLimitRule(
var applyToExtraTimeUsage: Boolean? = null var applyToExtraTimeUsage: Boolean? = null
var dayMask: Byte? = null var dayMask: Byte? = null
var maximumTimeInMillis: Int? = null var maximumTimeInMillis: Int? = null
var startMinuteOfDay = MIN_START_MINUTE
var endMinuteOfDay = MAX_END_MINUTE
var sessionDurationMilliseconds = 0
var sessionPauseMilliseconds = 0
reader.beginObject() reader.beginObject()
@ -66,6 +89,10 @@ data class TimeLimitRule(
MAX_TIME_IN_MILLIS -> maximumTimeInMillis = reader.nextInt() MAX_TIME_IN_MILLIS -> maximumTimeInMillis = reader.nextInt()
DAY_MASK -> dayMask = reader.nextInt().toByte() DAY_MASK -> dayMask = reader.nextInt().toByte()
APPLY_TO_EXTRA_TIME_USAGE -> applyToExtraTimeUsage = reader.nextBoolean() 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() else -> reader.skipValue()
} }
} }
@ -77,7 +104,11 @@ data class TimeLimitRule(
categoryId = categoryId!!, categoryId = categoryId!!,
applyToExtraTimeUsage = applyToExtraTimeUsage!!, applyToExtraTimeUsage = applyToExtraTimeUsage!!,
dayMask = dayMask!!, 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)) { if (dayMask < 0 || dayMask > (1 or 2 or 4 or 8 or 16 or 32 or 64)) {
throw IllegalArgumentException() 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) { override fun serialize(writer: JsonWriter) {
writer.beginObject() writer.beginObject()
@ -103,7 +148,28 @@ data class TimeLimitRule(
writer.name(MAX_TIME_IN_MILLIS).value(maximumTimeInMillis) writer.name(MAX_TIME_IN_MILLIS).value(maximumTimeInMillis)
writer.name(DAY_MASK).value(dayMask) writer.name(DAY_MASK).value(dayMask)
writer.name(APPLY_TO_EXTRA_TIME_USAGE).value(applyToExtraTimeUsage) 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() 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 * 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 * 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 androidx.room.Entity
import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable 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( data class UsedTimeItem(
@ColumnInfo(name = "day_of_epoch") @ColumnInfo(name = "day_of_epoch")
val dayOfEpoch: Int, val dayOfEpoch: Int,
@ColumnInfo(name = "used_time") @ColumnInfo(name = "used_time")
val usedMillis: Long, val usedMillis: Long,
@ColumnInfo(name = "category_id") @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 { ): JsonSerializable {
companion object { companion object {
private const val DAY_OF_EPOCH = "day" private const val DAY_OF_EPOCH = "day"
private const val USED_TIME_MILLIS = "time" private const val USED_TIME_MILLIS = "time"
private const val CATEGORY_ID = "category" 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 { fun parse(reader: JsonReader): UsedTimeItem {
reader.beginObject() reader.beginObject()
@ -42,12 +49,16 @@ data class UsedTimeItem(
var dayOfEpoch: Int? = null var dayOfEpoch: Int? = null
var usedMillis: Long? = null var usedMillis: Long? = null
var categoryId: String? = null var categoryId: String? = null
var startTimeOfDay = MinuteOfDay.MIN
var endTimeOfDay = MinuteOfDay.MAX
while (reader.hasNext()) { while (reader.hasNext()) {
when (reader.nextName()) { when (reader.nextName()) {
DAY_OF_EPOCH -> dayOfEpoch = reader.nextInt() DAY_OF_EPOCH -> dayOfEpoch = reader.nextInt()
USED_TIME_MILLIS -> usedMillis = reader.nextLong() USED_TIME_MILLIS -> usedMillis = reader.nextLong()
CATEGORY_ID -> categoryId = reader.nextString() CATEGORY_ID -> categoryId = reader.nextString()
START_TIME_OF_DAY -> startTimeOfDay = reader.nextInt()
END_TIME_OF_DAY -> endTimeOfDay = reader.nextInt()
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -57,7 +68,9 @@ data class UsedTimeItem(
return UsedTimeItem( return UsedTimeItem(
dayOfEpoch = dayOfEpoch!!, dayOfEpoch = dayOfEpoch!!,
usedMillis = usedMillis!!, usedMillis = usedMillis!!,
categoryId = categoryId!! categoryId = categoryId!!,
startTimeOfDay = startTimeOfDay,
endTimeOfDay = endTimeOfDay
) )
} }
} }
@ -72,6 +85,10 @@ data class UsedTimeItem(
if (usedMillis < 0) { if (usedMillis < 0) {
throw IllegalArgumentException() throw IllegalArgumentException()
} }
if (startTimeOfDay < MinuteOfDay.MIN || endTimeOfDay > MinuteOfDay.MAX || startTimeOfDay > endTimeOfDay) {
throw IllegalArgumentException()
}
} }
override fun serialize(writer: JsonWriter) { override fun serialize(writer: JsonWriter) {
@ -80,6 +97,8 @@ data class UsedTimeItem(
writer.name(DAY_OF_EPOCH).value(dayOfEpoch) writer.name(DAY_OF_EPOCH).value(dayOfEpoch)
writer.name(USED_TIME_MILLIS).value(usedMillis) writer.name(USED_TIME_MILLIS).value(usedMillis)
writer.name(CATEGORY_ID).value(categoryId) writer.name(CATEGORY_ID).value(categoryId)
writer.name(START_TIME_OF_DAY).value(startTimeOfDay)
writer.name(END_TIME_OF_DAY).value(endTimeOfDay)
writer.endObject() 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.RequiresCurrentDevice -> getString(R.string.lock_reason_short_requires_current_device)
BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking) BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking)
BlockingReason.BatteryLimit -> getString(R.string.lock_reason_short_battery_limit) BlockingReason.BatteryLimit -> getString(R.string.lock_reason_short_battery_limit)
BlockingReason.SessionDurationLimit -> getString(R.string.lock_reason_short_session_duration)
BlockingReason.None -> throw IllegalStateException() BlockingReason.None -> throw IllegalStateException()
} }
) )

View file

@ -16,8 +16,6 @@
package io.timelimit.android.logic package io.timelimit.android.logic
import android.util.Log import android.util.Log
import android.util.SparseArray
import android.util.SparseLongArray
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.R 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.data.model.*
import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek 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.AppStatusMessage
import io.timelimit.android.integration.platform.ForegroundAppSpec import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.android.AccessibilityService import io.timelimit.android.integration.platform.android.AccessibilityService
import io.timelimit.android.livedata.* 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.UpdateDeviceStatusAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.ui.IsAppInForeground import io.timelimit.android.ui.IsAppInForeground
@ -235,7 +236,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems( fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems(
database = appLogic.database, database = appLogic.database,
date = nowDate date = nowDate,
timestamp = nowTimestamp
) )
if (realTime.isNetworkTime) { if (realTime.isNetworkTime) {
@ -315,17 +317,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val usedTimeUpdateHelper = usedTimeUpdateHelper!! val usedTimeUpdateHelper = usedTimeUpdateHelper!!
// check times // check times
fun buildUsedTimesSparseArray(items: SparseArray<UsedTimeItem>, categoryId: String): SparseLongArray { fun buildDummyUsedTimeItems(categoryId: String): List<UsedTimeItem> {
val result = SparseLongArray() if (!usedTimeUpdateHelper.timeToAdd.containsKey(categoryId)) {
return emptyList()
for (i in 0..6) {
val usedTimesItem = items[i]?.usedMillis ?: 0
val timeToAddButNotCommited = usedTimeUpdateHelper.timeToAdd[categoryId] ?: 0
result.put(i, usedTimesItem + timeToAddButNotCommited)
} }
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? { suspend fun getRemainingTime(categoryId: String?): RemainingTime? {
@ -338,31 +351,66 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
return null 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( return RemainingTime.getRemainingTime(
nowDate.dayOfWeek, nowDate.dayOfWeek,
buildUsedTimesSparseArray(usedTimes, categoryId), minuteOfWeek % MinuteOfDay.LENGTH,
usedTimes + buildDummyUsedTimeItems(categoryId),
rules, 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 // 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 remainingTimeForegroundAppChild = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(foregroundAppHandling.categoryId) else null
val remainingTimeForegroundAppParent = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(foregroundAppHandling.parentCategoryId) else null val remainingTimeForegroundAppParent = if (foregroundAppHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(foregroundAppHandling.parentCategoryId) else null
val remainingTimeForegroundApp = RemainingTime.min(remainingTimeForegroundAppChild, remainingTimeForegroundAppParent) 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 remainingTimeBackgroundAppChild = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(audioPlaybackHandling.categoryId) else null
val remainingTimeBackgroundAppParent = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(audioPlaybackHandling.parentCategoryId) else null val remainingTimeBackgroundAppParent = if (audioPlaybackHandling.status == BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime) getRemainingTime(audioPlaybackHandling.parentCategoryId) else null
val remainingTimeBackgroundApp = RemainingTime.min(remainingTimeBackgroundAppChild, remainingTimeBackgroundAppParent) 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 // eventually block
if (remainingTimeForegroundApp?.hasRemainingTime == false) { if (remainingTimeForegroundApp?.hasRemainingTime == false || sessionDurationLimitReachedForegroundApp) {
foregroundAppHandling.status = BackgroundTaskLogicAppStatus.ShouldBlock foregroundAppHandling.status = BackgroundTaskLogicAppStatus.ShouldBlock
} }
if (remainingTimeBackgroundApp?.hasRemainingTime == false) { if (remainingTimeBackgroundApp?.hasRemainingTime == false || sessionDurationLimitReachedBackgroundApp) {
audioPlaybackHandling.status = BackgroundTaskLogicAppStatus.ShouldBlock audioPlaybackHandling.status = BackgroundTaskLogicAppStatus.ShouldBlock
} }
@ -375,6 +423,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val categoriesToCount = mutableSetOf<String>() val categoriesToCount = mutableSetOf<String>()
val categoriesToCountExtraTime = mutableSetOf<String>() val categoriesToCountExtraTime = mutableSetOf<String>()
val categoriesToCountSessionDurations = mutableSetOf<String>()
if (shouldCountForegroundApp) { if (shouldCountForegroundApp) {
remainingTimeForegroundAppChild?.let { remainingTime -> remainingTimeForegroundAppChild?.let { remainingTime ->
@ -384,6 +433,10 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) { if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(categoryId) categoriesToCountExtraTime.add(categoryId)
} }
if (!sessionDurationLimitReachedForegroundApp) {
categoriesToCountSessionDurations.add(categoryId)
}
} }
} }
@ -394,6 +447,10 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) { if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(it) categoriesToCountExtraTime.add(it)
} }
if (!sessionDurationLimitReachedForegroundApp) {
categoriesToCountSessionDurations.add(it)
}
} }
} }
} }
@ -406,6 +463,10 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) { if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(it) categoriesToCountExtraTime.add(it)
} }
if (!sessionDurationLimitReachedBackgroundApp) {
categoriesToCountSessionDurations.add(it)
}
} }
} }
@ -416,16 +477,61 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (remainingTime.usingExtraTime) { if (remainingTime.usingExtraTime) {
categoriesToCountExtraTime.add(it) categoriesToCountExtraTime.add(it)
} }
if (!sessionDurationLimitReachedBackgroundApp) {
categoriesToCountSessionDurations.add(it)
}
} }
} }
} }
if (categoriesToCount.isNotEmpty()) { if (categoriesToCount.isNotEmpty()) {
categoriesToCount.forEach { categoryId -> 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( usedTimeUpdateHelper.add(
categoryId = categoryId, categoryId = categoryId,
time = timeToSubtract, 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( fun buildStatusMessage(
handling: BackgroundTaskRestrictionLogicResult, handling: BackgroundTaskRestrictionLogicResult,
remainingTime: RemainingTime?, remainingTime: RemainingTime?,
remainingSessionDuration: Long?,
suffix: String, suffix: String,
appPackageName: String?, appPackageName: String?,
appActivityToShow: String? appActivityToShow: String?
@ -518,27 +625,18 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
appPackageName = appPackageName, appPackageName = appPackageName,
appActivityToShow = appActivityToShow appActivityToShow = appActivityToShow
) )
BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime -> ( BackgroundTaskLogicAppStatus.AllowedCountAndCheckTime -> buildStatusMessageWithCurrentAppTitle(
if (remainingTime?.usingExtraTime == true) { text = if (remainingTime?.usingExtraTime == true)
// using extra time appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remainingTime.includingExtraTime.toInt(), appLogic.context))
buildStatusMessageWithCurrentAppTitle( else if (remainingTime != null && remainingSessionDuration != null && remainingSessionDuration < remainingTime.default)
text = appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remainingTime.includingExtraTime.toInt(), appLogic.context)), TimeTextUtil.pauseIn(remainingSessionDuration.toInt(), appLogic.context)
titlePrefix = getCategoryTitle(handling.categoryId) + " - ", else
titleSuffix = suffix, TimeTextUtil.remaining(remainingTime?.default?.toInt() ?: 0, appLogic.context),
appPackageName = appPackageName, titlePrefix = getCategoryTitle(handling.categoryId) + " - ",
appActivityToShow = appActivityToShow titleSuffix = suffix,
) appPackageName = appPackageName,
} else { appActivityToShow = appActivityToShow
// using normal contingent )
buildStatusMessageWithCurrentAppTitle(
text = TimeTextUtil.remaining(remainingTime?.default?.toInt() ?: 0, appLogic.context),
titlePrefix = getCategoryTitle(handling.categoryId) + " - ",
titleSuffix = suffix,
appPackageName = appPackageName,
appActivityToShow = appActivityToShow
)
}
)
BackgroundTaskLogicAppStatus.Idle -> AppStatusMessage( BackgroundTaskLogicAppStatus.Idle -> AppStatusMessage(
appLogic.context.getString(R.string.background_logic_idle_title) + suffix, appLogic.context.getString(R.string.background_logic_idle_title) + suffix,
appLogic.context.getString(R.string.background_logic_idle_text) appLogic.context.getString(R.string.background_logic_idle_text)
@ -557,7 +655,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
remainingTime = remainingTimeBackgroundApp, remainingTime = remainingTimeBackgroundApp,
suffix = " (2/2)", suffix = " (2/2)",
appPackageName = audioPlaybackPackageName, appPackageName = audioPlaybackPackageName,
appActivityToShow = null appActivityToShow = null,
remainingSessionDuration = remainingSessionDurationBackgroundApp
) )
) )
} else { } else {
@ -568,7 +667,8 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
remainingTime = remainingTimeForegroundApp, remainingTime = remainingTimeForegroundApp,
suffix = if (showBackgroundStatus) " (1/2)" else "", suffix = if (showBackgroundStatus) " (1/2)" else "",
appPackageName = foregroundAppPackageName, 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 package io.timelimit.android.logic
import android.util.SparseArray
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import io.timelimit.android.data.model.Category import io.timelimit.android.data.model.*
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.livedata.* import io.timelimit.android.livedata.*
import java.util.* import java.util.*
@ -49,11 +45,16 @@ class BackgroundTaskLogicCache (private val appLogic: AppLogic) {
return appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(key) return appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(key)
} }
} }
val usedTimesOfCategoryAndWeekByFirstDayOfWeek = object: MultiKeyLiveDataCache<SparseArray<UsedTimeItem>, Pair<String, Int>>() { val usedTimesOfCategoryAndWeekByFirstDayOfWeek = object: MultiKeyLiveDataCache<List<UsedTimeItem>, Pair<String, Int>>() {
override fun createValue(key: Pair<String, Int>): LiveData<SparseArray<UsedTimeItem>> { override fun createValue(key: Pair<String, Int>): LiveData<List<UsedTimeItem>> {
return appLogic.database.usedTimes().getUsedTimesOfWeek(key.first, key.second) 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 shouldDoAutomaticSignOut = SingleItemLiveDataCacheWithRequery { -> appLogic.defaultUserLogic.hasAutomaticSignOut()}
val liveDataCaches = LiveDataCaches(arrayOf( val liveDataCaches = LiveDataCaches(arrayOf(

View file

@ -16,15 +16,14 @@
package io.timelimit.android.logic package io.timelimit.android.logic
import android.util.Log import android.util.Log
import android.util.SparseLongArray
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek 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.platform.android.AndroidIntegrationApps
import io.timelimit.android.integration.time.TimeApi
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.logic.extension.isCategoryAllowed import io.timelimit.android.logic.extension.isCategoryAllowed
import java.util.* import java.util.*
@ -39,7 +38,8 @@ enum class BlockingReason {
MissingNetworkTime, MissingNetworkTime,
RequiresCurrentDevice, RequiresCurrentDevice,
NotificationsAreBlocked, NotificationsAreBlocked,
BatteryLimit BatteryLimit,
SessionDurationLimit
} }
enum class BlockingLevel { enum class BlockingLevel {
@ -319,18 +319,18 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
trustedMinuteOfWeek -> trustedMinuteOfWeek ->
if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) { if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) {
getBlockingReasonStep6(category, timeZone) getBlockingReasonStep6(category, timeZone, trustedMinuteOfWeek)
} else if (trustedMinuteOfWeek == null) { } else if (trustedMinuteOfWeek == null) {
liveDataFromValue(BlockingReason.MissingNetworkTime) liveDataFromValue(BlockingReason.MissingNetworkTime)
} else if (category.blockedMinutesInWeek.read(trustedMinuteOfWeek)) { } else if (category.blockedMinutesInWeek.read(trustedMinuteOfWeek)) {
liveDataFromValue(BlockingReason.BlockedAtThisTime) liveDataFromValue(BlockingReason.BlockedAtThisTime)
} else { } 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) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 6") Log.d(LOG_TAG, "step 6")
} }
@ -343,54 +343,67 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (rules.isEmpty()) { if (rules.isEmpty()) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(BlockingReason.None)
} else if (nowTrustedDate == null) { } else if (nowTrustedDate == null || trustedMinuteOfWeek == null) {
liveDataFromValue(BlockingReason.MissingNetworkTime) liveDataFromValue(BlockingReason.MissingNetworkTime)
} else { } 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) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 6 - 2") Log.d(LOG_TAG, "step 6 - 2")
} }
return appLogic.currentDeviceLogic.isThisDeviceTheCurrentDevice.switchMap { isCurrentDevice -> return appLogic.currentDeviceLogic.isThisDeviceTheCurrentDevice.switchMap { isCurrentDevice ->
if (isCurrentDevice) { if (isCurrentDevice) {
getBlockingReasonStep7(category, nowTrustedDate, rules) getBlockingReasonStep7(category, nowTrustedDate, trustedMinuteOfWeek, rules)
} else { } else {
liveDataFromValue(BlockingReason.RequiresCurrentDevice) 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) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 7") Log.d(LOG_TAG, "step 7")
} }
val extraTime = category.getExtraTime(dayOfEpoch = nowTrustedDate.dayOfEpoch) 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 { return appLogic.database.usedTimes().getUsedTimesOfWeek(category.id, firstDayOfWeekAsEpochDay = firstDayOfWeekAsEpochDay).switchMap { usedTimes ->
usedTimes -> val remaining = RemainingTime.getRemainingTime(nowTrustedDate.dayOfWeek, trustedMinuteOfWeek % MinuteOfDay.LENGTH, usedTimes, rules, extraTime, firstDayOfWeekAsEpochDay)
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)
if (remaining == null || remaining.includingExtraTime > 0) { 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 { } else {
if (extraTime > 0) { if (extraTime > 0) {
BlockingReason.TimeOverExtraTimeCanBeUsedLater liveDataFromValue(BlockingReason.TimeOverExtraTimeCanBeUsedLater)
} else { } else {
BlockingReason.TimeOver liveDataFromValue(BlockingReason.TimeOver)
} }
} }
} }

View file

@ -16,12 +16,12 @@
package io.timelimit.android.logic package io.timelimit.android.logic
import android.util.Log import android.util.Log
import android.util.SparseLongArray
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.logic.extension.isCategoryAllowed import io.timelimit.android.logic.extension.isCategoryAllowed
import java.util.* import java.util.*
@ -146,7 +146,8 @@ class CategoriesBlockingReasonUtil(private val appLogic: AppLogic) {
checkCategoryTimeLimitRules( checkCategoryTimeLimitRules(
temporarilyTrustedDate = temporarilyTrustedDate, temporarilyTrustedDate = temporarilyTrustedDate,
category = category, 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( private fun checkCategoryTimeLimitRules(
temporarilyTrustedDate: LiveData<DateInTimezone?>, temporarilyTrustedDate: LiveData<DateInTimezone?>,
temporarilyTrustedMinuteOfWeek: LiveData<Int?>,
rules: LiveData<List<TimeLimitRule>>, rules: LiveData<List<TimeLimitRule>>,
category: Category category: Category
): LiveData<BlockingReason> = rules.switchMap { rules -> ): LiveData<BlockingReason> = rules.switchMap { rules ->
@ -218,43 +220,60 @@ class CategoriesBlockingReasonUtil(private val appLogic: AppLogic) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(BlockingReason.None)
} else { } else {
temporarilyTrustedDate.switchMap { temporarilyTrustedDate -> temporarilyTrustedDate.switchMap { temporarilyTrustedDate ->
if (temporarilyTrustedDate == null) { temporarilyTrustedMinuteOfWeek.switchMap { temporarilyTrustedMinuteOfWeek ->
liveDataFromValue(BlockingReason.MissingNetworkTime) if (temporarilyTrustedDate == null || temporarilyTrustedMinuteOfWeek == null) {
} else { liveDataFromValue(BlockingReason.MissingNetworkTime)
getBlockingReasonStep7( } else {
category = category, getBlockingReasonStep7(
nowTrustedDate = temporarilyTrustedDate, category = category,
rules = rules 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) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 7") Log.d(LOG_TAG, "step 7")
} }
val extraTime = category.getExtraTime(dayOfEpoch = nowTrustedDate.dayOfEpoch) 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 -> return appLogic.database.usedTimes().getUsedTimesOfWeek(category.id, firstDayOfWeekAsEpochDay).switchMap { usedTimes ->
val usedTimesSparseArray = SparseLongArray() val remaining = RemainingTime.getRemainingTime(nowTrustedDate.dayOfWeek, trustedMinuteOfWeek % MinuteOfDay.LENGTH, usedTimes, rules, extraTime, firstDayOfWeekAsEpochDay)
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)
if (remaining == null || remaining.includingExtraTime > 0) { 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 { } else {
if (extraTime > 0) { if (extraTime > 0) {
BlockingReason.TimeOverExtraTimeCanBeUsedLater liveDataFromValue(BlockingReason.TimeOverExtraTimeCanBeUsedLater)
} else { } else {
BlockingReason.TimeOver liveDataFromValue(BlockingReason.TimeOver)
} }
} }
}.ignoreUnchanged() }.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 package io.timelimit.android.logic
import android.util.SparseLongArray
import io.timelimit.android.data.model.TimeLimitRule import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.UsedTimeItem
data class RemainingTime(val includingExtraTime: Long, val default: Long) { data class RemainingTime(val includingExtraTime: Long, val default: Long) {
val hasRemainingTime = includingExtraTime > 0 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> { private fun getRulesRelatedToDay(dayOfWeek: Int, minuteOfDay: Int, rules: List<TimeLimitRule>): List<TimeLimitRule> {
return rules.filter { (it.dayMask.toInt() and (1 shl dayOfWeek)) != 0 } 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) { if (extraTime < 0) {
throw IllegalStateException("extra time < 0") throw IllegalStateException("extra time < 0")
} }
val relatedRules = getRulesRelatedToDay(dayOfWeek, rules) val relatedRules = getRulesRelatedToDay(dayOfWeek, minuteOfDay, rules)
val withoutExtraTime = getRemainingTime(usedTimes, relatedRules, false) val withoutExtraTime = getRemainingTime(usedTimes, relatedRules, false, firstDayOfWeekAsEpochDay)
val withExtraTime = getRemainingTime(usedTimes, relatedRules, true) val withExtraTime = getRemainingTime(usedTimes, relatedRules, true, firstDayOfWeekAsEpochDay)
if (withoutExtraTime == null && withExtraTime == null) { if (withoutExtraTime == null && withExtraTime == null) {
// no rules // 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? { private fun getRemainingTime(usedTimes: List<UsedTimeItem>, relatedRules: List<TimeLimitRule>, assumeMaximalExtraTime: Boolean, firstDayOfWeekAsEpochDay: Int): Long? {
return relatedRules.filter { (!assumeMaximalExtraTime) || it.applyToExtraTimeUsage }.map { return relatedRules.filter { (!assumeMaximalExtraTime) || it.applyToExtraTimeUsage }.map { rule ->
var usedTime = 0L var usedTime = 0L
for (day in 0..6) { usedTimes.forEach { usedTimeItem ->
if ((it.dayMask.toInt() and (1 shl day)) != 0) { if (usedTimeItem.dayOfEpoch >= firstDayOfWeekAsEpochDay && usedTimeItem.dayOfEpoch <= firstDayOfWeekAsEpochDay + 6) {
usedTime += usedTimes[day] 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) val remaining = Math.max(0, maxTime - usedTime)
remaining remaining

View file

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

View file

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

View file

@ -360,7 +360,24 @@ object ApplyServerDataStatus {
UsedTimeItem( UsedTimeItem(
dayOfEpoch = it.dayOfEpoch, dayOfEpoch = it.dayOfEpoch,
usedMillis = it.usedMillis, 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.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.integration.platform.* import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.network.ParentPassword import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.sync.validation.ListValidation import io.timelimit.android.sync.validation.ListValidation
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.* 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 { companion object {
const val TYPE_VALUE = "ADD_USED_TIME_V2" const val TYPE_VALUE = "ADD_USED_TIME_V2"
private const val DAY_OF_EPOCH = "d" private const val DAY_OF_EPOCH = "d"
private const val ITEMS = "i" private const val ITEMS = "i"
private const val TRUSTED_TIMESTAMP = "t"
fun parse(action: JSONObject): AddUsedTimeActionVersion2 = AddUsedTimeActionVersion2( fun parse(action: JSONObject): AddUsedTimeActionVersion2 = AddUsedTimeActionVersion2(
dayOfEpoch = action.getInt(DAY_OF_EPOCH), 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 { init {
if (dayOfEpoch < 0) { if (dayOfEpoch < 0 || trustedTimestamp < 0) {
throw IllegalArgumentException() throw IllegalArgumentException()
} }
@ -178,20 +186,42 @@ data class AddUsedTimeActionVersion2(val dayOfEpoch: Int, val items: List<AddUse
items.forEach { it.serialize(writer) } items.forEach { it.serialize(writer) }
writer.endArray() writer.endArray()
if (trustedTimestamp != 0L) {
writer.name(TRUSTED_TIMESTAMP).value(trustedTimestamp)
}
writer.endObject() 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 { companion object {
private const val CATEGORY_ID = "categoryId" private const val CATEGORY_ID = "categoryId"
private const val TIME_TO_ADD = "tta" private const val TIME_TO_ADD = "tta"
private const val EXTRA_TIME_TO_SUBTRACT = "etts" 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( fun parse(item: JSONObject): AddUsedTimeActionItem = AddUsedTimeActionItem(
categoryId = item.getString(CATEGORY_ID), categoryId = item.getString(CATEGORY_ID),
timeToAdd = item.getInt(TIME_TO_ADD), 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(TIME_TO_ADD).value(timeToAdd)
writer.name(EXTRA_TIME_TO_SUBTRACT).value(extraTimeToSubtract) 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() 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 ClearTemporarilyAllowedAppsAction(val deviceId: String): AppLogicAction(), LocalOnlyAction
data class InstalledApp(val packageName: String, val title: String, val isLaunchable: Boolean, val recommendation: AppRecommendation) { 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 { companion object {
const val TYPE_VALUE = "UPDATE_TIMELIMIT_RULE" const val TYPE_VALUE = "UPDATE_TIMELIMIT_RULE"
private const val RULE_ID = "ruleId" private const val RULE_ID = "ruleId"
private const val MAX_TIME_IN_MILLIS = "time" private const val MAX_TIME_IN_MILLIS = "time"
private const val DAY_MASK = "days" private const val DAY_MASK = "days"
private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime" 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 { 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)) { if (dayMask < 0 || dayMask > (1 or 2 or 4 or 8 or 16 or 32 or 64)) {
throw IllegalArgumentException() 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) { 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(MAX_TIME_IN_MILLIS).value(maximumTimeInMillis)
writer.name(DAY_MASK).value(dayMask) writer.name(DAY_MASK).value(dayMask)
writer.name(APPLY_TO_EXTRA_TIME_USAGE).value(applyToExtraTimeUsage) 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() writer.endObject()
} }
@ -1369,7 +1502,7 @@ data class AddUserAction(val name: String, val userType: UserType, val password:
fun parse(action: JSONObject): AddUserAction { fun parse(action: JSONObject): AddUserAction {
var password: ParentPassword? = null var password: ParentPassword? = null
val passwordObject = action.getJSONObject(PASSWORD) val passwordObject = action.optJSONObject(PASSWORD)
if (passwordObject != null) { if (passwordObject != null) {
password = ParentPassword.parse(passwordObject) 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 * 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 * 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 import java.io.StringWriter
object SerializationUtil { object SerializationUtil {
fun serializeAction(action: ParentAction): String { fun serializeAction(action: Action): String {
val stringWriter = StringWriter()
val jsonWriter = JsonWriter(stringWriter)
action.serialize(jsonWriter)
return stringWriter.buffer.toString()
}
fun serializeAction(action: AppLogicAction): String {
val stringWriter = StringWriter() val stringWriter = StringWriter()
val jsonWriter = JsonWriter(stringWriter) val jsonWriter = JsonWriter(stringWriter)

View file

@ -117,8 +117,22 @@ object ApplyActionUtil {
if (parsed is AddUsedTimeActionVersion2 && parsed.dayOfEpoch == action.dayOfEpoch) { if (parsed is AddUsedTimeActionVersion2 && parsed.dayOfEpoch == action.dayOfEpoch) {
var updatedAction: AddUsedTimeActionVersion2 = parsed 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 -> action.items.forEach { newItem ->
if (issues) return@forEach
val oldItem = updatedAction.items.find { it.categoryId == newItem.categoryId } val oldItem = updatedAction.items.find { it.categoryId == newItem.categoryId }
if (oldItem == null) { if (oldItem == null) {
@ -126,10 +140,28 @@ object ApplyActionUtil {
items = updatedAction.items + listOf(newItem) items = updatedAction.items + listOf(newItem)
) )
} else { } 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( val mergedItem = AddUsedTimeActionItem(
timeToAdd = oldItem.timeToAdd + newItem.timeToAdd, timeToAdd = oldItem.timeToAdd + newItem.timeToAdd,
extraTimeToSubtract = oldItem.extraTimeToSubtract + newItem.extraTimeToSubtract, extraTimeToSubtract = oldItem.extraTimeToSubtract + newItem.extraTimeToSubtract,
categoryId = newItem.categoryId categoryId = newItem.categoryId,
additionalCountingSlots = oldItem.additionalCountingSlots,
sessionDurationLimits = oldItem.sessionDurationLimits
) )
updatedAction = updatedAction.copy( updatedAction = updatedAction.copy(
@ -138,20 +170,22 @@ object ApplyActionUtil {
} }
} }
// update the previous action if (!issues) {
database.pendingSyncAction().updateEncodedActionSync( // update the previous action
sequenceNumber = previousAction.sequenceNumber, database.pendingSyncAction().updateEncodedActionSync(
action = StringWriter().apply { sequenceNumber = previousAction.sequenceNumber,
JsonWriter(this).apply { action = StringWriter().apply {
updatedAction.serialize(this) JsonWriter(this).apply {
} updatedAction.serialize(this)
}.toString() }
) }.toString()
)
database.setTransactionSuccessful() database.setTransactionSuccessful()
syncUtil.requestVeryUnimportantSync() syncUtil.requestVeryUnimportantSync()
return@executeAndWait return@executeAndWait
}
} }
} }
} }

View file

@ -16,10 +16,8 @@
package io.timelimit.android.sync.actions.dispatch package io.timelimit.android.sync.actions.dispatch
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.data.model.App import io.timelimit.android.data.model.*
import io.timelimit.android.data.model.AppActivity import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.data.model.HadManipulationFlag
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.integration.platform.NewPermissionStatusUtil import io.timelimit.android.integration.platform.NewPermissionStatusUtil
import io.timelimit.android.integration.platform.ProtectionLevelUtil import io.timelimit.android.integration.platform.ProtectionLevelUtil
import io.timelimit.android.integration.platform.RuntimePermissionStatusUtil import io.timelimit.android.integration.platform.RuntimePermissionStatusUtil
@ -46,7 +44,9 @@ object LocalDatabaseAppLogicActionDispatcher {
val updatedRows = database.usedTimes().addUsedTime( val updatedRows = database.usedTimes().addUsedTime(
categoryId = categoryId, categoryId = categoryId,
timeToAdd = action.timeToAdd, timeToAdd = action.timeToAdd,
dayOfEpoch = action.dayOfEpoch dayOfEpoch = action.dayOfEpoch,
start = MinuteOfDay.MIN,
end = MinuteOfDay.MAX
) )
if (updatedRows == 0) { if (updatedRows == 0) {
@ -55,7 +55,9 @@ object LocalDatabaseAppLogicActionDispatcher {
database.usedTimes().insertUsedTime(UsedTimeItem( database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = categoryId, categoryId = categoryId,
dayOfEpoch = action.dayOfEpoch, 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) database.category().getCategoryByIdSync(item.categoryId)
?: throw CategoryNotFoundException() ?: throw CategoryNotFoundException()
val updatedRows = database.usedTimes().addUsedTime( fun handle(start: Int, end: Int) {
categoryId = item.categoryId, val updatedRows = database.usedTimes().addUsedTime(
timeToAdd = item.timeToAdd,
dayOfEpoch = action.dayOfEpoch
)
if (updatedRows == 0) {
// create new entry
database.usedTimes().insertUsedTime(UsedTimeItem(
categoryId = item.categoryId, categoryId = item.categoryId,
timeToAdd = item.timeToAdd,
dayOfEpoch = action.dayOfEpoch, 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) { if (item.extraTimeToSubtract != 0) {
database.category().subtractCategoryExtraTime( database.category().subtractCategoryExtraTime(

View file

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

View file

@ -33,6 +33,8 @@ data class ClientDataStatus(
private const val APPS = "apps" private const val APPS = "apps"
private const val CATEGORIES = "categories" private const val CATEGORIES = "categories"
private const val USERS = "users" private const val USERS = "users"
private const val CLIENT_LEVEL = "clientLevel"
private const val CLIENT_LEVEL_VALUE = 2
val empty = ClientDataStatus( val empty = ClientDataStatus(
deviceListVersion = "", deviceListVersion = "",
@ -75,6 +77,7 @@ data class ClientDataStatus(
fun serialize(writer: JsonWriter) { fun serialize(writer: JsonWriter) {
writer.beginObject() writer.beginObject()
writer.name(CLIENT_LEVEL).value(CLIENT_LEVEL_VALUE)
writer.name(DEVICES).value(deviceListVersion) writer.name(DEVICES).value(deviceListVersion)
writer.name(USERS).value(userListVersion) 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.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.parseList import io.timelimit.android.extensions.parseList
import io.timelimit.android.integration.platform.* import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.actions.AppActivityItem import io.timelimit.android.sync.actions.AppActivityItem
@ -476,16 +477,19 @@ data class ServerUpdatedCategoryAssignedApps(
data class ServerUpdatedCategoryUsedTimes( data class ServerUpdatedCategoryUsedTimes(
val categoryId: String, val categoryId: String,
val usedTimeItems: List<ServerUsedTimeItem>, val usedTimeItems: List<ServerUsedTimeItem>,
val sessionDurations: List<ServerSessionDuration>,
val version: String val version: String
) { ) {
companion object { companion object {
private const val CATEGORY_ID = "categoryId" private const val CATEGORY_ID = "categoryId"
private const val USED_TIMES_ITEMS = "times" private const val USED_TIMES_ITEMS = "times"
private const val SESSION_DURATIONS = "sessionDurations"
private const val VERSION = "version" private const val VERSION = "version"
fun parse(reader: JsonReader): ServerUpdatedCategoryUsedTimes { fun parse(reader: JsonReader): ServerUpdatedCategoryUsedTimes {
var categoryId: String? = null var categoryId: String? = null
var usedTimeItems: List<ServerUsedTimeItem>? = null var usedTimeItems: List<ServerUsedTimeItem>? = null
var sessionDurations = emptyList<ServerSessionDuration>()
var version: String? = null var version: String? = null
reader.beginObject() reader.beginObject()
@ -493,6 +497,7 @@ data class ServerUpdatedCategoryUsedTimes(
when (reader.nextName()) { when (reader.nextName()) {
CATEGORY_ID -> categoryId = reader.nextString() CATEGORY_ID -> categoryId = reader.nextString()
USED_TIMES_ITEMS -> usedTimeItems = ServerUsedTimeItem.parseList(reader) USED_TIMES_ITEMS -> usedTimeItems = ServerUsedTimeItem.parseList(reader)
SESSION_DURATIONS -> sessionDurations = ServerSessionDuration.parseList(reader)
VERSION -> version = reader.nextString() VERSION -> version = reader.nextString()
else -> reader.skipValue() else -> reader.skipValue()
} }
@ -502,6 +507,7 @@ data class ServerUpdatedCategoryUsedTimes(
return ServerUpdatedCategoryUsedTimes( return ServerUpdatedCategoryUsedTimes(
categoryId = categoryId!!, categoryId = categoryId!!,
usedTimeItems = usedTimeItems!!, usedTimeItems = usedTimeItems!!,
sessionDurations = sessionDurations,
version = version!! version = version!!
) )
} }
@ -512,21 +518,29 @@ data class ServerUpdatedCategoryUsedTimes(
data class ServerUsedTimeItem( data class ServerUsedTimeItem(
val dayOfEpoch: Int, val dayOfEpoch: Int,
val usedMillis: Long val usedMillis: Long,
val startTimeOfDay: Int,
val endTimeOfDay: Int
) { ) {
companion object { companion object {
private const val DAY_OF_EPOCH = "day" private const val DAY_OF_EPOCH = "day"
private const val USED_MILLIS = "time" 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 { fun parse(reader: JsonReader): ServerUsedTimeItem {
var dayOfEpoch: Int? = null var dayOfEpoch: Int? = null
var usedMillis: Long? = null var usedMillis: Long? = null
var startTimeOfDay: Int = MinuteOfDay.MIN
var endTimeOfDay: Int = MinuteOfDay.MAX
reader.beginObject() reader.beginObject()
while (reader.hasNext()) { while (reader.hasNext()) {
when (reader.nextName()) { when (reader.nextName()) {
DAY_OF_EPOCH -> dayOfEpoch = reader.nextInt() DAY_OF_EPOCH -> dayOfEpoch = reader.nextInt()
USED_MILLIS -> usedMillis = reader.nextLong() USED_MILLIS -> usedMillis = reader.nextLong()
START_TIME_OF_DAY -> startTimeOfDay = reader.nextInt()
END_TIME_OF_DAY -> endTimeOfDay = reader.nextInt()
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -534,7 +548,9 @@ data class ServerUsedTimeItem(
return ServerUsedTimeItem( return ServerUsedTimeItem(
dayOfEpoch = dayOfEpoch!!, 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( data class ServerUpdatedTimeLimitRules(
val categoryId: String, val categoryId: String,
val version: String, val version: String,
@ -593,19 +671,31 @@ data class ServerTimeLimitRule(
val id: String, val id: String,
val applyToExtraTimeUsage: Boolean, val applyToExtraTimeUsage: Boolean,
val dayMask: Byte, val dayMask: Byte,
val maximumTimeInMillis: Int val maximumTimeInMillis: Int,
val startMinuteOfDay: Int,
val endMinuteOfDay: Int,
val sessionDurationMilliseconds: Int,
val sessionPauseMilliseconds: Int
) { ) {
companion object { companion object {
private const val ID = "id" private const val ID = "id"
private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime" private const val APPLY_TO_EXTRA_TIME_USAGE = "extraTime"
private const val DAY_MASK = "dayMask" private const val DAY_MASK = "dayMask"
private const val MAXIMUM_TIME_IN_MILLIS = "maxTime" 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 { fun parse(reader: JsonReader): ServerTimeLimitRule {
var id: String? = null var id: String? = null
var applyToExtraTimeUsage: Boolean? = null var applyToExtraTimeUsage: Boolean? = null
var dayMask: Byte? = null var dayMask: Byte? = null
var maximumTimeInMillis: Int? = 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() reader.beginObject()
while (reader.hasNext()) { while (reader.hasNext()) {
@ -614,6 +704,10 @@ data class ServerTimeLimitRule(
APPLY_TO_EXTRA_TIME_USAGE -> applyToExtraTimeUsage = reader.nextBoolean() APPLY_TO_EXTRA_TIME_USAGE -> applyToExtraTimeUsage = reader.nextBoolean()
DAY_MASK -> dayMask = reader.nextInt().toByte() DAY_MASK -> dayMask = reader.nextInt().toByte()
MAXIMUM_TIME_IN_MILLIS -> maximumTimeInMillis = reader.nextInt() 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() else -> reader.skipValue()
} }
} }
@ -623,7 +717,11 @@ data class ServerTimeLimitRule(
id = id!!, id = id!!,
applyToExtraTimeUsage = applyToExtraTimeUsage!!, applyToExtraTimeUsage = applyToExtraTimeUsage!!,
dayMask = dayMask!!, dayMask = dayMask!!,
maximumTimeInMillis = maximumTimeInMillis!! maximumTimeInMillis = maximumTimeInMillis!!,
startMinuteOfDay = startMinuteOfDay,
endMinuteOfDay = endMinuteOfDay,
sessionDurationMilliseconds = sessionDurationMilliseconds,
sessionPauseMilliseconds = sessionPauseMilliseconds
) )
} }
@ -645,7 +743,11 @@ data class ServerTimeLimitRule(
applyToExtraTimeUsage = applyToExtraTimeUsage, applyToExtraTimeUsage = applyToExtraTimeUsage,
dayMask = dayMask, dayMask = dayMask,
maximumTimeInMillis = maximumTimeInMillis, 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 * 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 * 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 androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.data.model.TimeLimitRule 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.AddItemViewBinding
import io.timelimit.android.databinding.FragmentCategoryTimeLimitRuleItemBinding import io.timelimit.android.databinding.FragmentCategoryTimeLimitRuleItemBinding
import io.timelimit.android.databinding.TimeLimitRuleIntroductionBinding import io.timelimit.android.databinding.TimeLimitRuleIntroductionBinding
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.util.JoinUtil import io.timelimit.android.util.JoinUtil
import io.timelimit.android.util.TimeTextUtil import io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates import kotlin.properties.Delegates
@ -36,7 +38,8 @@ class Adapter: RecyclerView.Adapter<ViewHolder>() {
} }
var data: List<TimeLimitRuleItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() } 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 var handlers: Handlers? = null
init { init {
@ -110,26 +113,42 @@ class Adapter: RecyclerView.Adapter<ViewHolder>() {
is TimeLimitRuleRuleItem -> { is TimeLimitRuleRuleItem -> {
val rule = item.rule val rule = item.rule
val binding = (holder as ItemViewHolder).view val binding = (holder as ItemViewHolder).view
val context = binding.root.context
val dayNames = binding.root.resources.getStringArray(R.array.days_of_week_array) val dayNames = binding.root.resources.getStringArray(R.array.days_of_week_array)
val usedTime = usedTimes?.mapIndexed { index, value -> val usedTime = usedTimes.filter { usedTime ->
if (rule.dayMask.toInt() and (1 shl index) != 0) { val dayOfWeek = usedTime.dayOfEpoch - epochDayOfStartOfWeek
value usedTime.startTimeOfDay == rule.startMinuteOfDay && usedTime.endTimeOfDay == rule.endMinuteOfDay &&
} else { (rule.dayMask.toInt() and (1 shl dayOfWeek) != 0)
0 }.map { it.usedMillis }.sum().toInt()
}
}?.sum()?.toInt() ?: 0
binding.maxTimeString = TimeTextUtil.time(rule.maximumTimeInMillis, binding.root.context) binding.maxTimeString = TimeTextUtil.time(rule.maximumTimeInMillis, context)
binding.usageAsText = TimeTextUtil.used(usedTime, binding.root.context) binding.usageAsText = TimeTextUtil.used(usedTime, context)
binding.usageProgressInPercent = if (rule.maximumTimeInMillis > 0) binding.usageProgressInPercent = if (rule.maximumTimeInMillis > 0)
(usedTime * 100 / rule.maximumTimeInMillis) (usedTime * 100 / rule.maximumTimeInMillis)
else else
100 100
binding.daysString = JoinUtil.join( binding.daysString = JoinUtil.join(
dayNames.filterIndexed { index, _ -> (rule.dayMask.toInt() and (1 shl index)) != 0 }, 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.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.card.setOnClickListener { handlers?.onTimeLimitRuleClicked(rule) }
binding.executePendingBindings() binding.executePendingBindings()

View file

@ -74,14 +74,19 @@ class CategoryTimeLimitRulesFragment : Fragment(), EditTimeLimitRuleDialogFragme
val userDate = database.user().getUserByIdLive(params.childId).getDateLive(logic.realTimeLogic) val userDate = database.user().getUserByIdLive(params.childId).getDateLive(logic.realTimeLogic)
val usedTimeItems = userDate.switchMap { userDate.switchMap { date ->
date -> val firstDayOfWeekAsEpochDay = date.dayOfEpoch - date.dayOfWeek
database.usedTimes().getUsedTimesOfWeek( database.usedTimes().getUsedTimesOfWeek(
categoryId = params.categoryId, 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) val hasHiddenIntro = database.config().wereHintsShown(HintsToShow.TIME_LIMIT_RULE_INTRODUCTION)
@ -97,16 +102,10 @@ class CategoryTimeLimitRulesFragment : Fragment(), EditTimeLimitRuleDialogFragme
listOf(TimeLimitRuleIntroductionItem) + baseList listOf(TimeLimitRuleIntroductionItem) + baseList
} }
} }
}.observe(this, Observer { }.observe(viewLifecycleOwner, Observer {
adapter.data = it adapter.data = it
}) })
usedTimeItems.observe(this, Observer {
usedTimes ->
adapter.usedTimes = (0..6).map { usedTimes[it]?.usedMillis ?: 0 } }
)
adapter.handlers = object: Handlers { adapter.handlers = object: Handlers {
override fun onTimeLimitRuleClicked(rule: TimeLimitRule) { override fun onTimeLimitRuleClicked(rule: TimeLimitRule) {
if (auth.requestAuthenticationOrReturnTrue()) { if (auth.requestAuthenticationOrReturnTrue()) {
@ -171,7 +170,11 @@ class CategoryTimeLimitRulesFragment : Fragment(), EditTimeLimitRuleDialogFragme
ruleId = oldRule.id, ruleId = oldRule.id,
applyToExtraTimeUsage = oldRule.applyToExtraTimeUsage, applyToExtraTimeUsage = oldRule.applyToExtraTimeUsage,
maximumTimeInMillis = oldRule.maximumTimeInMillis, 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 * 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 * 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.google.android.material.R
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.timelimit.android.R
import io.timelimit.android.async.Threads import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.runAsync import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.IdGenerator 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.TimeLimitRule
import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentEditTimeLimitRuleDialogBinding import io.timelimit.android.databinding.FragmentEditTimeLimitRuleDialogBinding
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.showSafe import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.waitForNonNullValue import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.logic.DefaultAppLogic 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.mustread.MustReadFragment
import io.timelimit.android.ui.view.SelectDayViewHandlers import io.timelimit.android.ui.view.SelectDayViewHandlers
import io.timelimit.android.ui.view.SelectTimeSpanViewListener import io.timelimit.android.ui.view.SelectTimeSpanViewListener
import io.timelimit.android.util.TimeTextUtil
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.* import java.util.*
class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() { class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment(), DurationPickerDialogFragmentListener {
companion object { companion object {
private const val PARAM_EXISTING_RULE = "a" private const val PARAM_EXISTING_RULE = "a"
private const val PARAM_CATEGORY_ID = "b" private const val PARAM_CATEGORY_ID = "b"
@ -76,6 +79,8 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
var existingRule: TimeLimitRule? = null var existingRule: TimeLimitRule? = null
var savedNewRule: TimeLimitRule? = null var savedNewRule: TimeLimitRule? = null
lateinit var newRule: TimeLimitRule
lateinit var view: FragmentEditTimeLimitRuleDialogBinding
private val categoryId: String by lazy { private val categoryId: String by lazy {
if (existingRule != null) { if (existingRule != null) {
@ -109,13 +114,37 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
?: arguments?.getParcelable<TimeLimitRule?>(PARAM_EXISTING_RULE) ?: 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? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = FragmentEditTimeLimitRuleDialogBinding.inflate(layoutInflater, container, false)
val listener = targetFragment as EditTimeLimitRuleDialogFragmentListener val listener = targetFragment as EditTimeLimitRuleDialogFragmentListener
var newRule: TimeLimitRule
val database = DefaultAppLogic.with(context!!).database 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) { if (it == null || it.second.type != UserType.Parent) {
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
@ -129,7 +158,11 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
categoryId = categoryId, categoryId = categoryId,
applyToExtraTimeUsage = false, applyToExtraTimeUsage = false,
dayMask = 0, 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 { } else {
view.isNewRule = false 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() bindRule()
view.daySelection.handlers = object: SelectDayViewHandlers { view.daySelection.handlers = object: SelectDayViewHandlers {
override fun updateDayChecked(day: Int) { override fun updateDayChecked(day: Int) {
@ -181,6 +198,72 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
bindRule() 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() { override fun onSaveRule() {
view.timeSpan.clearNumberPickerFocus() view.timeSpan.clearNumberPickerFocus()
@ -191,7 +274,11 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
ruleId = newRule.id, ruleId = newRule.id,
maximumTimeInMillis = newRule.maximumTimeInMillis, maximumTimeInMillis = newRule.maximumTimeInMillis,
dayMask = newRule.dayMask, dayMask = newRule.dayMask,
applyToExtraTimeUsage = newRule.applyToExtraTimeUsage applyToExtraTimeUsage = newRule.applyToExtraTimeUsage,
start = newRule.startMinuteOfDay,
end = newRule.endMinuteOfDay,
sessionDurationMilliseconds = newRule.sessionDurationMilliseconds,
sessionPauseMilliseconds = newRule.sessionPauseMilliseconds
) )
)) { )) {
return return
@ -245,13 +332,13 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
} }
} }
database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer { database.config().getEnableAlternativeDurationSelectionAsync().observe(viewLifecycleOwner, Observer {
view.timeSpan.enablePickerMode(it) view.timeSpan.enablePickerMode(it)
}) })
if (existingRule != null) { if (existingRule != null) {
database.timeLimitRules() database.timeLimitRules()
.getTimeLimitRuleByIdLive(existingRule!!.id).observe(this, Observer { .getTimeLimitRuleByIdLive(existingRule!!.id).observe(viewLifecycleOwner, Observer {
if (it == null) { if (it == null) {
// rule was deleted // rule was deleted
dismissAllowingStateLoss() 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 { interface Handlers {
fun updateApplyToExtraTime(apply: Boolean) fun updateApplyToExtraTime(apply: Boolean)
fun updateApplyToWholeDay(apply: Boolean)
fun updateStartTime()
fun updateEndTime()
fun updateSessionDurationLimit(enable: Boolean)
fun updateSessionLength()
fun updateSessionBreak()
fun onSaveRule() fun onSaveRule()
fun onDeleteRule() 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 * 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 * 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 package io.timelimit.android.ui.manage.category.usagehistory
import android.text.format.DateFormat import android.text.format.DateFormat
import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView 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.databinding.FragmentUsageHistoryItemBinding
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.util.TimeTextUtil import io.timelimit.android.util.TimeTextUtil
import org.threeten.bp.LocalDate import org.threeten.bp.LocalDate
import org.threeten.bp.ZoneOffset import org.threeten.bp.ZoneOffset
import java.util.* import java.util.*
class UsageHistoryAdapter: PagedListAdapter<UsedTimeItem, UsageHistoryViewHolder>(diffCallback) { class UsageHistoryAdapter: PagedListAdapter<UsedTimeListItem, UsageHistoryViewHolder>(diffCallback) {
companion object { companion object {
private val diffCallback = object: DiffUtil.ItemCallback<UsedTimeItem>() { private val diffCallback = object: DiffUtil.ItemCallback<UsedTimeListItem>() {
override fun areContentsTheSame(oldItem: UsedTimeItem, newItem: UsedTimeItem) = oldItem == newItem override fun areContentsTheSame(oldItem: UsedTimeListItem, newItem: UsedTimeListItem) = oldItem == newItem
override fun areItemsTheSame(oldItem: UsedTimeItem, newItem: UsedTimeItem) = override fun areItemsTheSame(oldItem: UsedTimeListItem, newItem: UsedTimeListItem) =
(oldItem.dayOfEpoch == newItem.dayOfEpoch) && (oldItem.categoryId == newItem.categoryId) (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 binding = holder.binding
val context = binding.root.context 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.date = ""
binding.usedTime = "" 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 * 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 * it under the terms of the GNU General Public License as published by
@ -42,11 +42,11 @@ class UsageHistoryFragment : Fragment() {
val adapter = UsageHistoryAdapter() val adapter = UsageHistoryAdapter()
LivePagedListBuilder( LivePagedListBuilder(
database.usedTimes().getUsedTimesByCategoryId(params.categoryId), database.usedTimes().getUsedTimeListItemsByCategoryId(params.categoryId),
10 10
) )
.build() .build()
.observe(this, Observer { .observe(viewLifecycleOwner, Observer {
binding.isEmpty = it.isEmpty() binding.isEmpty = it.isEmpty()
adapter.submitList(it) adapter.submitList(it)
}) })

View file

@ -16,7 +16,6 @@
package io.timelimit.android.ui.manage.child.category package io.timelimit.android.ui.manage.child.category
import android.app.Application import android.app.Application
import android.util.SparseLongArray
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import io.timelimit.android.data.extensions.mapToTimezone 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.data.model.HintsToShow
import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromFunction import io.timelimit.android.livedata.liveDataFromFunction
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
@ -100,19 +100,16 @@ class ManageChildCategoriesModel(application: Application): AndroidViewModel(app
isBlockedTimeNow = category.blockedMinutesInWeek.read(childMinuteOfWeek), isBlockedTimeNow = category.blockedMinutesInWeek.read(childMinuteOfWeek),
remainingTimeToday = RemainingTime.getRemainingTime( remainingTimeToday = RemainingTime.getRemainingTime(
dayOfWeek = childDate.dayOfWeek, dayOfWeek = childDate.dayOfWeek,
usedTimes = SparseLongArray().apply { usedTimes = usedTimeItemsForCategory,
usedTimeItemsForCategory.forEach { usedTimeItem ->
val dayOfWeek = usedTimeItem.dayOfEpoch - firstDayOfWeek
put(dayOfWeek, usedTimeItem.usedMillis)
}
},
rules = rules, rules = rules,
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch) extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch),
minuteOfDay = childMinuteOfWeek % MinuteOfDay.LENGTH,
firstDayOfWeekAsEpochDay = firstDayOfWeek
)?.includingExtraTime, )?.includingExtraTime,
usedTimeToday = usedTimeItemsForCategory.find { item -> item.dayOfEpoch == childDate.dayOfEpoch }?.usedMillis usedTimeToday = usedTimeItemsForCategory.find { item ->
?: 0, item.dayOfEpoch == childDate.dayOfEpoch && item.startTimeOfDay == MinuteOfDay.MIN &&
item.endTimeOfDay == MinuteOfDay.MAX
}?.usedMillis ?: 0,
usedForNotAssignedApps = categoryForUnassignedApps == category.id, usedForNotAssignedApps = categoryForUnassignedApps == category.id,
parentCategoryTitle = parentCategory?.title 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 * 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 * 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, categoryId = categoryId,
applyToExtraTimeUsage = false, applyToExtraTimeUsage = false,
dayMask = (1 shl day).toByte(), 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, categoryId = categoryId,
applyToExtraTimeUsage = false, applyToExtraTimeUsage = false,
dayMask = (1 shl day).toByte(), 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, categoryId = categoryId,
applyToExtraTimeUsage = false, applyToExtraTimeUsage = false,
dayMask = 1 + 2 + 4 + 8 + 16 + 32 + 64, 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 io.timelimit.android.util.TimeTextUtil
import kotlin.properties.Delegates 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) private val binding = ViewSelectTimeSpanBinding.inflate(LayoutInflater.from(context), this, false)
init { init {

View file

@ -18,7 +18,11 @@ package io.timelimit.android.ui.widget
import android.util.SparseLongArray import android.util.SparseLongArray
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import io.timelimit.android.data.extensions.mapToTimezone 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.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromFunction import io.timelimit.android.livedata.liveDataFromFunction
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
@ -34,6 +38,11 @@ object TimesWidgetItems {
val userDate = userTimezone.switchMap { timeZone -> val userDate = userTimezone.switchMap { timeZone ->
liveDataFromFunction { DateInTimezone.newInstance(logic.realTimeLogic.getCurrentTimeInMillis(), timeZone) } liveDataFromFunction { DateInTimezone.newInstance(logic.realTimeLogic.getCurrentTimeInMillis(), timeZone) }
}.ignoreUnchanged() }.ignoreUnchanged()
val userMinuteOfWeek = userTimezone.switchMap { timeZone ->
liveDataFromFunction {
getMinuteOfWeek(logic.realTimeLogic.getCurrentTimeInMillis(), timeZone)
}
}.ignoreUnchanged()
val categories = userId.switchMap { logic.database.category().getCategoriesByChildId(it) } val categories = userId.switchMap { logic.database.category().getCategoriesByChildId(it) }
val usedTimeItemsForWeek = userDate.switchMap { date -> val usedTimeItemsForWeek = userDate.switchMap { date ->
categories.switchMap { categories -> categories.switchMap { categories ->
@ -49,36 +58,37 @@ object TimesWidgetItems {
categories.map { category -> category.id } 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 -> val categoryItems = categories.switchMap { categories ->
timeLimitRules.switchMap { timeLimitRules -> timeLimitRules.switchMap { timeLimitRules ->
userDate.switchMap { childDate -> timeLimitSlot.switchMap { timeLimitSlot ->
usedTimeItemsForWeek.map { usedTimeItemsForWeek -> userDate.switchMap { childDate ->
val rulesByCategoryId = timeLimitRules.groupBy { rule -> rule.categoryId } usedTimeItemsForWeek.map { usedTimeItemsForWeek ->
val usedTimesByCategory = usedTimeItemsForWeek.groupBy { item -> item.categoryId } val rulesByCategoryId = timeLimitRules.groupBy { rule -> rule.categoryId }
val firstDayOfWeek = childDate.dayOfEpoch - childDate.dayOfWeek val usedTimesByCategory = usedTimeItemsForWeek.groupBy { item -> item.categoryId }
val firstDayOfWeek = childDate.dayOfEpoch - childDate.dayOfWeek
categories.map { category -> categories.map { category ->
val rules = rulesByCategoryId[category.id] ?: emptyList() val rules = rulesByCategoryId[category.id] ?: emptyList()
val usedTimeItemsForCategory = usedTimesByCategory[category.id] val usedTimeItemsForCategory = usedTimesByCategory[category.id]
?: emptyList() ?: emptyList()
val parentCategory = categories.find { it.id == category.parentCategoryId }
TimesWidgetItem( TimesWidgetItem(
title = category.title, title = category.title,
remainingTimeToday = RemainingTime.getRemainingTime( remainingTimeToday = RemainingTime.getRemainingTime(
dayOfWeek = childDate.dayOfWeek, dayOfWeek = childDate.dayOfWeek,
usedTimes = SparseLongArray().apply { usedTimes = usedTimeItemsForCategory,
usedTimeItemsForCategory.forEach { usedTimeItem -> rules = rules,
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch),
val dayOfWeek = usedTimeItem.dayOfEpoch - firstDayOfWeek minuteOfDay = timeLimitSlot,
firstDayOfWeekAsEpochDay = firstDayOfWeek
put(dayOfWeek, usedTimeItem.usedMillis) )?.includingExtraTime
} )
}, }
rules = rules,
extraTime = category.getExtraTime(dayOfEpoch = childDate.dayOfEpoch)
)?.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 * 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 * 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 { fun used(time: Int, context: Context): String {
return if (time <= 0) { return if (time <= 0) {
context.resources.getString(R.string.util_time_unused) 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"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -34,6 +34,14 @@
name="daysString" name="daysString"
type="String" /> type="String" />
<variable
name="timeAreaString"
type="String" />
<variable
name="sessionLimitString"
type="String" />
<variable <variable
name="appliesToExtraTime" name="appliesToExtraTime"
type="Boolean" /> type="Boolean" />
@ -72,6 +80,22 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> 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 <TextView
android:visibility="@{safeUnbox(appliesToExtraTime) ? View.VISIBLE : View.GONE}" android:visibility="@{safeUnbox(appliesToExtraTime) ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceSmall" android:textAppearance="?android:textAppearanceSmall"

View file

@ -14,6 +14,7 @@
--> -->
<layout xmlns:android="http://schemas.android.com/apk/res/android" <layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" 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"> tools:context="io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragment">
<data> <data>
@ -25,6 +26,10 @@
name="applyToExtraTime" name="applyToExtraTime"
type="Boolean" /> type="Boolean" />
<variable
name="applyToWholeDay"
type="Boolean" />
<variable <variable
name="handlers" name="handlers"
type="io.timelimit.android.ui.manage.category.timelimit_rules.edit.Handlers" /> type="io.timelimit.android.ui.manage.category.timelimit_rules.edit.Handlers" />
@ -33,74 +38,223 @@
name="affectsMultipleDays" name="affectsMultipleDays"
type="boolean" /> 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" /> <import type="android.view.View" />
</data> </data>
<LinearLayout <androidx.core.widget.NestedScrollView
android:padding="8dp"
android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:id="@+id/scroll">
<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" />
<LinearLayout <LinearLayout
android:orientation="horizontal" android:padding="8dp"
android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<View <TextView
android:layout_weight="1" android:textAppearance="?android:textAppearanceLarge"
android:layout_width="0dp" tools:text="@string/category_time_limit_rule_dialog_new"
android:layout_height="0dp" /> android:text="@{safeUnbox(isNewRule) ? @string/category_time_limit_rule_dialog_new : @string/category_time_limit_rule_dialog_edit}"
android:layout_width="match_parent"
<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" /> android:layout_height="wrap_content" />
<Button <include layout="@layout/view_select_days" android:id="@+id/day_selection" />
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp" <io.timelimit.android.ui.view.SelectTimeSpanView
android:onClick="@{() -> handlers.onSaveRule()}" android:id="@+id/time_span"
tools:text="@string/generic_create" android:layout_width="match_parent"
android:text="@{safeUnbox(isNewRule) ? @string/generic_create : @string/generic_save}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" /> 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>
</LinearLayout>
</layout> </layout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -20,9 +20,16 @@
name="date" name="date"
type="String" /> type="String" />
<variable
name="timeArea"
type="String" />
<variable <variable
name="usedTime" name="usedTime"
type="String" /> type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" />
</data> </data>
<LinearLayout <LinearLayout
@ -38,6 +45,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> 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 <TextView
android:textAppearance="?android:textAppearanceMedium" android:textAppearance="?android:textAppearanceMedium"
android:text="@{usedTime}" android:text="@{usedTime}"

View file

@ -275,6 +275,20 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> 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 <ProgressBar
android:visibility="@{reason == null ? View.VISIBLE : View.GONE}" android:visibility="@{reason == null ? View.VISIBLE : View.GONE}"
android:padding="8dp" android:padding="8dp"
@ -513,7 +527,7 @@
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
<io.timelimit.android.ui.view.ManageDisableTimelimitsView <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:id="@+id/manage_disable_time_limits"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />

View file

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

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. 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. Regeln, bei denen nur ein Tag gewählt wurde, begrenzen nur diesen Tag.
</string> </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_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_new">Regel erstellen</string>
<string name="category_time_limit_rule_dialog_edit">Regel bearbeiten</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_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_created">Regel wurde erstellt</string>
<string name="category_time_limit_rules_snackbar_updated">Regel wurde geändert</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. 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. Wenn Sei die Begrenzung je Tag wollen, dann erstellen Sie eine Regel je Tag.
</string> </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> </resources>

View file

@ -80,6 +80,10 @@
und dieses Akkulimit wurde erreicht. und dieses Akkulimit wurde erreicht.
Zum Wiederfreigeben muss der Akku aufgeladen werden. Zum Wiederfreigeben muss der Akku aufgeladen werden.
</string> </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_no_category">keine Kategorie</string>
<string name="lock_reason_short_temporarily_blocked">vorübergehend gesperrt</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_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_notification_blocking">alle Benachrichtigungen werden blockiert</string>
<string name="lock_reason_short_battery_limit">Akkulimit unterschritten</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"> <string name="lock_overlay_warning">
Öffnen des Sperrbildschirms fehlgeschlagen. Öffnen des Sperrbildschirms fehlgeschlagen.

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -20,4 +20,7 @@
Die Nutzungszeiten werden nur erfasst, Die Nutzungszeiten werden nur erfasst,
wenn Zeitbegrenzungsregeln existieren und die zeitbegrenzten Apps genutzt werden wenn Zeitbegrenzungsregeln existieren und die zeitbegrenzten Apps genutzt werden
</string> </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> </resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. 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_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_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_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_unused">Unbenutzt</string>
<string name="util_time_used"><xliff:g example="4 minutes" id="used time">%1$s</xliff:g> benutzt</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"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. 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. Rules at which only one day is checked only limit the selected day.
</string> </string>
<string name="category_time_limit_rules_applied_to_extra_time">is applied to extra time, too</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_new">Create rule</string>
<string name="category_time_limit_rule_dialog_edit">Edit 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_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_created">Rule was created</string>
<string name="category_time_limit_rules_snackbar_updated">Rule was modified</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. will limit the total usage duration during one week at the selected days.
If you want it per day, create one rule per day. If you want it per day, create one rule per day.
</string> </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> </resources>

View file

@ -84,6 +84,11 @@
battery limit was reached. battery limit was reached.
To unlock it, charge the battery. To unlock it, charge the battery.
</string> </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_no_category">no category</string>
<string name="lock_reason_short_temporarily_blocked">temporarily blocked</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_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_notification_blocking">all notifications are blocked</string>
<string name="lock_reason_short_battery_limit">battery limit reached</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"> <string name="lock_overlay_warning">
Failed to open the lock screen. Failed to open the lock screen.

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -21,4 +21,7 @@
when there are time limits and when there are time limits and
the limited Apps are used the limited Apps are used
</string> </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> </resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. 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_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_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_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_unused">Unused</string>
<string name="util_time_used">Used <xliff:g example="4 minutes" id="used time">%1$s</xliff:g></string> <string name="util_time_used">Used <xliff:g example="4 minutes" id="used time">%1$s</xliff:g></string>