Skip to content

Commit 0c4f4ab

Browse files
committed
#1687 fix: restoring key map groups would sometimes fail due to improper breadth first traversal of the group tree
1 parent 6c09a43 commit 0c4f4ab

4 files changed

Lines changed: 164 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- #1683 key event actions work in Minecraft and other apps again.
1919
- Export log files as .txt instead of .zip files.
2020
- #1684 Removed the redundant and broken refresh devices button when configuring a key event action because they are automatically refreshed anyway.
21+
- #1687 restoring key map groups would sometimes fail.
2122

2223
## [3.0.1](https://github.com/sds100/KeyMapper/releases/tag/v3.0.1)
2324

app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt

Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ import io.github.sds100.keymapper.util.Error
5454
import io.github.sds100.keymapper.util.Result
5555
import io.github.sds100.keymapper.util.State
5656
import io.github.sds100.keymapper.util.Success
57+
import io.github.sds100.keymapper.util.TreeNode
5758
import io.github.sds100.keymapper.util.UuidGenerator
59+
import io.github.sds100.keymapper.util.breadFirstTraversal
5860
import io.github.sds100.keymapper.util.onFailure
5961
import io.github.sds100.keymapper.util.then
6062
import kotlinx.coroutines.CoroutineScope
@@ -473,55 +475,11 @@ class BackupManagerImpl(
473475

474476
// Group parents must be restored first so an SqliteConstraintException
475477
// is not thrown when restoring a child group.
476-
val groupsToRestoreMap = backupContent.groups.associateBy { it.uid }.toMutableMap()
477-
val groupRestoreQueue = LinkedList<GroupEntity>()
478+
val groupRestoreTrees = buildGroupTrees(backupContent.groups)
478479

479-
// Order the groups into a queue such that a parent is always before a child.
480-
for (group in backupContent.groups) {
481-
if (groupsToRestoreMap.containsKey(group.uid)) {
482-
groupRestoreQueue.addFirst(group)
483-
}
484-
485-
var parent = groupsToRestoreMap[group.parentUid]
486-
487-
while (parent != null) {
488-
groupRestoreQueue.addFirst(parent)
489-
groupsToRestoreMap.remove(parent.uid)
490-
parent = groupsToRestoreMap[parent.parentUid]
491-
}
492-
}
493-
494-
for (group in groupRestoreQueue) {
495-
// Set the last opened date to now so that the imported group
496-
// shows as the most recent.
497-
var modifiedGroup = group.copy(lastOpenedDate = currentTime)
498-
499-
// If the group's parent wasn't backed up or doesn't exist
500-
// then set it the parent to the root group
501-
if (!groupUids.contains(group.parentUid)) {
502-
modifiedGroup = modifiedGroup.copy(parentUid = null)
503-
}
504-
505-
val siblings =
506-
groupRepository.getGroupsByParent(modifiedGroup.parentUid).first()
507-
508-
modifiedGroup = RepositoryUtils.saveUniqueName(
509-
modifiedGroup,
510-
saveBlock = { renamedGroup ->
511-
// Do not rename the group with a (1) if it is the same UID. Just overwrite the name.
512-
if (siblings.any { sibling -> sibling.uid != renamedGroup.uid && sibling.name == renamedGroup.name }) {
513-
throw IllegalStateException("Non unique group name")
514-
}
515-
},
516-
renameBlock = { entity, suffix ->
517-
entity.copy(name = "${entity.name} $suffix")
518-
},
519-
)
520-
521-
if (existingGroupUids.contains(modifiedGroup.uid)) {
522-
groupRepository.update(modifiedGroup)
523-
} else {
524-
groupRepository.insert(modifiedGroup)
480+
for (tree in groupRestoreTrees) {
481+
tree.breadFirstTraversal { group ->
482+
restoreGroup(group, currentTime, groupUids, existingGroupUids)
525483
}
526484
}
527485
}
@@ -615,6 +573,91 @@ class BackupManagerImpl(
615573
}
616574
}
617575

576+
private suspend fun restoreGroup(
577+
group: GroupEntity,
578+
currentTime: Long,
579+
groupUids: Set<String>,
580+
existingGroupUids: Set<String>,
581+
) {
582+
// Set the last opened date to now so that the imported group
583+
// shows as the most recent.
584+
var modifiedGroup = group.copy(lastOpenedDate = currentTime)
585+
586+
// If the group's parent wasn't backed up or doesn't exist
587+
// then set it the parent to the root group
588+
if (!groupUids.contains(group.parentUid)) {
589+
modifiedGroup = modifiedGroup.copy(parentUid = null)
590+
}
591+
592+
val siblings =
593+
groupRepository.getGroupsByParent(modifiedGroup.parentUid).first()
594+
595+
modifiedGroup = RepositoryUtils.saveUniqueName(
596+
modifiedGroup,
597+
saveBlock = { renamedGroup ->
598+
// Do not rename the group with a (1) if it is the same UID. Just overwrite the name.
599+
if (siblings.any { sibling -> sibling.uid != renamedGroup.uid && sibling.name == renamedGroup.name }) {
600+
throw IllegalStateException("Non unique group name")
601+
}
602+
},
603+
renameBlock = { entity, suffix ->
604+
entity.copy(name = "${entity.name} $suffix")
605+
},
606+
)
607+
608+
if (existingGroupUids.contains(modifiedGroup.uid)) {
609+
groupRepository.update(modifiedGroup)
610+
} else {
611+
groupRepository.insert(modifiedGroup)
612+
}
613+
}
614+
615+
/**
616+
* Converts the group relationships into trees. This first finds all the root groups which
617+
* have no parent. Then it loops over all the other groups indefinitely until they have been
618+
* added to their parent. If the parent does not exist while looping then it is skipped and
619+
* processed in the next iteration.
620+
*
621+
* @return A list of the root nodes for all the group trees.
622+
*/
623+
private fun buildGroupTrees(groups: List<GroupEntity>): List<TreeNode<GroupEntity>> {
624+
if (groups.isEmpty()) {
625+
return emptyList()
626+
}
627+
628+
val nodeMap = mutableMapOf<String, TreeNode<GroupEntity>>()
629+
val rootNodes = mutableListOf<TreeNode<GroupEntity>>()
630+
631+
val groupQueue = LinkedList<GroupEntity>()
632+
633+
for (group in groups) {
634+
if (group.parentUid == null) {
635+
val node = TreeNode(group)
636+
nodeMap[group.uid] = node
637+
rootNodes.add(node)
638+
} else {
639+
groupQueue.add(group)
640+
}
641+
}
642+
643+
while (groupQueue.isNotEmpty()) {
644+
val groupsToRemove = mutableListOf<GroupEntity>()
645+
646+
for (group in groupQueue) {
647+
if (nodeMap.containsKey(group.parentUid)) {
648+
val node = TreeNode(group)
649+
nodeMap[group.uid] = node
650+
nodeMap[group.parentUid]!!.children.add(node)
651+
groupsToRemove.add(group)
652+
}
653+
}
654+
655+
groupQueue.removeAll(groupsToRemove.toSet())
656+
}
657+
658+
return rootNodes
659+
}
660+
618661
private suspend fun appendKeyMapsInRepository(keyMaps: List<KeyMapEntity>) = withContext(dispatchers.default()) {
619662
val randomUids = keyMaps.map { it.copy(uid = UUID.randomUUID().toString()) }
620663
keyMapRepository.insert(*randomUids.toTypedArray())
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.github.sds100.keymapper.util
2+
3+
data class TreeNode<T>(val value: T, val children: MutableList<TreeNode<T>> = mutableListOf())
4+
5+
inline fun <T> TreeNode<T>.breadFirstTraversal(
6+
action: (T) -> Unit,
7+
) {
8+
val queue = ArrayDeque<TreeNode<T>>()
9+
queue.add(this)
10+
11+
while (queue.isNotEmpty()) {
12+
val currentNode = queue.removeFirst()
13+
action(currentNode.value)
14+
queue.addAll(currentNode.children)
15+
}
16+
}

app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,61 @@ class BackupManagerTest {
136136
Dispatchers.resetMain()
137137
}
138138

139+
/**
140+
* Issue #1655. If the list of groups in the backup has a child before the parent then the
141+
* parent must be restored first. Otherwise the SqliteConstraintException will be thrown.
142+
*/
143+
@Test
144+
fun `restore groups breadth first so parents exist before children are restored with child first in the backup`() = runTest(testDispatcher) {
145+
val parentGroup1 = GroupEntity(
146+
uid = "parent_group_1_uid",
147+
name = "parent_group_1_name",
148+
parentUid = null,
149+
lastOpenedDate = 0L,
150+
)
151+
152+
val parentGroup2 = GroupEntity(
153+
uid = "parent_group_2_uid",
154+
name = "parent_group_2_name",
155+
parentUid = null,
156+
lastOpenedDate = 0L,
157+
)
158+
159+
val childGroup = GroupEntity(
160+
uid = "child_group_uid",
161+
name = "child_group_name",
162+
parentUid = parentGroup1.uid,
163+
lastOpenedDate = 0L,
164+
)
165+
166+
val grandChildGroup = GroupEntity(
167+
uid = "grand_child_group_uid",
168+
name = "grand_child_group_name",
169+
parentUid = childGroup.uid,
170+
lastOpenedDate = 0L,
171+
)
172+
173+
val backupContent = BackupContent(
174+
appVersion = Constants.VERSION_CODE,
175+
dbVersion = AppDatabase.DATABASE_VERSION,
176+
groups = listOf(childGroup, grandChildGroup, parentGroup1),
177+
)
178+
179+
inOrder(mockGroupRepository) {
180+
backupManager.restore(
181+
RestoreType.REPLACE,
182+
backupContent,
183+
emptyList(),
184+
currentTime = 0L,
185+
)
186+
187+
verify(mockGroupRepository).insert(parentGroup1)
188+
verify(mockGroupRepository).insert(childGroup)
189+
verify(mockGroupRepository).insert(grandChildGroup)
190+
verify(mockGroupRepository, never()).update(any())
191+
}
192+
}
193+
139194
/**
140195
* Issue #1655. If the list of groups in the backup has a child before the parent then the
141196
* parent must be restored first. Otherwise the SqliteConstraintException will be thrown.
@@ -184,10 +239,10 @@ class BackupManagerTest {
184239
currentTime = 0L,
185240
)
186241

242+
verify(mockGroupRepository).insert(parentGroup2)
187243
verify(mockGroupRepository).insert(parentGroup1)
188244
verify(mockGroupRepository).insert(childGroup)
189245
verify(mockGroupRepository).insert(grandChildGroup)
190-
verify(mockGroupRepository).insert(parentGroup2)
191246
verify(mockGroupRepository, never()).update(any())
192247
}
193248
}

0 commit comments

Comments
 (0)