{"action":"created","comment":{"url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/comments/225436727","pull_request_review_id":165028652,"id":225436727,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDIyNTQzNjcyNw==","diff_hunk":"@@ -14,145 +15,175 @@\n package net.syncthing.java.bep\n \n import com.google.protobuf.ByteString\n+import kotlinx.coroutines.experimental.*\n+import kotlinx.coroutines.experimental.channels.Channel\n import net.syncthing.java.bep.BlockExchangeProtos.ErrorCode\n import net.syncthing.java.bep.BlockExchangeProtos.Request\n+import net.syncthing.java.bep.utils.longSumBy\n+import net.syncthing.java.core.beans.BlockInfo\n+import net.syncthing.java.core.beans.FileBlocks\n import net.syncthing.java.core.beans.FileInfo\n+import net.syncthing.java.core.interfaces.TempRepository\n import net.syncthing.java.core.utils.NetworkUtils\n import org.apache.commons.io.FileUtils\n import org.bouncycastle.util.encoders.Hex\n import org.slf4j.LoggerFactory\n import java.io.*\n+import java.lang.Exception\n import java.security.MessageDigest\n import java.util.*\n-import java.util.concurrent.ConcurrentHashMap\n-import java.util.concurrent.atomic.AtomicReference\n+import kotlin.collections.HashMap\n \n class BlockPuller internal constructor(private val connectionHandler: ConnectionHandler,\n-                                       private val indexHandler: IndexHandler) {\n+                                       private val indexHandler: IndexHandler,\n+                                       private val responseHandler: ResponseHandler,\n+                                       private val tempRepository: TempRepository) {\n \n     private val logger = LoggerFactory.getLogger(javaClass)\n-    private val blocksByHash = ConcurrentHashMap<String, ByteArray>()\n-    private val hashList = mutableListOf<String>()\n-    private val missingHashes: MutableSet<String> = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())\n-    private val requestIds: MutableSet<Int> = Collections.newSetFromMap(ConcurrentHashMap<Int, Boolean>())\n-    private val lock = Object()\n \n-    fun pullFile(fileInfo: FileInfo): FileDownloadObserver {\n+    fun pullFileSync(\n+            fileInfo: FileInfo,\n+            progressListener: (progress: Double, progressMessage: String) -> Unit = { _, _ -> }\n+    ): InputStream {\n+        return runBlocking {\n+            pullFileCoroutine(fileInfo, progressListener)\n+        }\n+    }\n+\n+    suspend fun pullFileCoroutine(\n+            fileInfo: FileInfo,\n+            progressListener: (progress: Double, progressMessage: String) -> Unit = { _, _ -> }\n+    ): InputStream {\n         val fileBlocks = indexHandler.waitForRemoteIndexAcquired(connectionHandler)\n                 .getFileInfoAndBlocksByPath(fileInfo.folder, fileInfo.path)\n                 ?.value\n                 ?: throw IOException(\"file not found in local index for folder = ${fileInfo.folder} path = ${fileInfo.path}\")\n         logger.info(\"pulling file = {}\", fileBlocks)\n-        NetworkUtils.assertProtocol(connectionHandler.hasFolder(fileBlocks.folder), {\"supplied connection handler $connectionHandler will not share folder ${fileBlocks.folder}\"})\n-        val error = AtomicReference<Exception>()\n-        val fileDownloadObserver = object : FileDownloadObserver() {\n-\n-            private fun receivedData() = (blocksByHash.size * BlockPusher.BLOCK_SIZE).toLong()\n+        NetworkUtils.assertProtocol(connectionHandler.hasFolder(fileBlocks.folder), { \"supplied connection handler $connectionHandler will not share folder ${fileBlocks.folder}\" })\n \n-            private fun totalData() = ((blocksByHash.size + missingHashes.size) * BlockPusher.BLOCK_SIZE).toLong()\n+        val totalTransferSize = fileBlocks.blocks.distinctBy { it.hash }.longSumBy { it.size.toLong() }\n \n-            override fun progress() = if (isCompleted()) 1.0 else receivedData() / totalData().toDouble()\n+        val blockTempIdByHash = Collections.synchronizedMap(HashMap<String, String>())\n \n-            override fun progressMessage() = (Math.round(progress() * 1000.0) / 10.0).toString() + \"% \" +\n-                    FileUtils.byteCountToDisplaySize(receivedData()) + \" / \" + FileUtils.byteCountToDisplaySize(totalData())\n+        try {\n+            var receivedData = 0L\n+            val reportProgressLock = Object()\n \n-            override fun isCompleted() = missingHashes.isEmpty()\n+            fun updateProgress(newReceivedDataSize: Long) {\n+                synchronized(reportProgressLock) {\n+                    receivedData += newReceivedDataSize\n \n-            override fun inputStream(): InputStream {\n-                    NetworkUtils.assertProtocol(missingHashes.isEmpty(), {\"pull failed, some blocks are still missing\"})\n-                    val blockList = hashList.map { blocksByHash[it] }.toList()\n-                    return SequenceInputStream(Collections.enumeration(blockList.map { ByteArrayInputStream(it) }))\n-                }\n+                    val progress = receivedData / totalTransferSize.toDouble()\n+                    val progressMessage = (Math.round(progress * 1000.0) / 10.0).toString() + \"% \" +\n+                            FileUtils.byteCountToDisplaySize(receivedData) + \" / \" + FileUtils.byteCountToDisplaySize(totalTransferSize)\n \n-            override fun checkError() {\n-                if (error.get() != null) {\n-                    throw IOException(error.get())\n+                    progressListener(progress, progressMessage)\n                 }\n             }\n \n-            @Throws(InterruptedException::class)\n-            override fun waitForProgressUpdate(): Double {\n-                if (!isCompleted()) {\n-                    synchronized(lock) {\n-                        checkError()\n-                        lock.wait()\n-                        checkError()\n+            coroutineScope {\n+                val pipe = Channel<BlockInfo>()\n+\n+                repeat(4 /* 4 blocks per time */) { workerNumber ->\n+                    async {\n+                        for (block in pipe) {\n+                            logger.debug(\"request block with hash = {} from worker {}\", block.hash, workerNumber)\n+\n+                            val blockContent = pullBlock(fileBlocks, block, 1000 * 15 /* 15 seconds timeout per block */)\n+\n+                            blockTempIdByHash[block.hash] = tempRepository.pushTempData(blockContent)\n+\n+                            updateProgress(blockContent.size.toLong())\n+                        }\n                     }\n                 }\n-                return progress()\n-            }\n \n-            override fun close() {\n-                missingHashes.clear()\n-                hashList.clear()\n-                blocksByHash.clear()\n-            }\n-        }\n-        synchronized(lock) {\n-            hashList.addAll(fileBlocks.blocks.map { it.hash })\n-            missingHashes.addAll(hashList)\n-            for (block in fileBlocks.blocks) {\n-                if (missingHashes.contains(block.hash)) {\n-                    val requestId = Math.abs(Random().nextInt())\n-                    requestIds.add(requestId)\n-                    connectionHandler.sendMessage(Request.newBuilder()\n-                            .setId(requestId)\n-                            .setFolder(fileBlocks.folder)\n-                            .setName(fileBlocks.path)\n-                            .setOffset(block.offset)\n-                            .setSize(block.size)\n-                            .setHash(ByteString.copyFrom(Hex.decode(block.hash)))\n-                            .build())\n-                    logger.debug(\"sent request for block, hash = {}\", block.hash)\n+                fileBlocks.blocks.distinctBy { it.hash }.forEach { block ->\n+                    pipe.send(block)\n                 }\n-            }\n-            return fileDownloadObserver\n-        }\n-    }\n \n-    fun onResponseMessageReceived(response: BlockExchangeProtos.Response) {\n-        synchronized(lock) {\n-            if (!requestIds.contains(response.id)) {\n-                return\n+                pipe.close()\n             }\n-            NetworkUtils.assertProtocol(response.code == ErrorCode.NO_ERROR, {\"received error response, code = ${response.code}\"})\n-            val data = response.data.toByteArray()\n-            val hash = Hex.toHexString(MessageDigest.getInstance(\"SHA-256\").digest(data))\n-            if (missingHashes.remove(hash)) {\n-                blocksByHash.put(hash, data)\n-                logger.debug(\"aquired block, hash = {}\", hash)\n-                lock.notify()\n-            } else {\n-                logger.warn(\"received not-needed block, hash = {}\", hash)\n+\n+            // the sequence is evaluated lazy -> only one block per time is loaded\n+            val fileBlocksIterator = fileBlocks.blocks\n+                    .asSequence()\n+                    .map { tempRepository.popTempData(blockTempIdByHash[it.hash]!!) }\n+                    .map { ByteArrayInputStream(it) }\n+                    .iterator()\n+\n+            return object : SequenceInputStream(object : Enumeration<InputStream> {\n+                override fun hasMoreElements() = fileBlocksIterator.hasNext()\n+                override fun nextElement() = fileBlocksIterator.next()\n+            }) {\n+                override fun close() {\n+                    super.close()\n+\n+                    // delete all temp blocks now\n+                    // they are deleted after reading, but the consumer could stop before reading the whole stream\n+                    tempRepository.deleteTempData(blockTempIdByHash.values.toList())\n+                }\n             }\n+        } catch (ex: Exception) {\n+            // delete all temp blocks now\n+            tempRepository.deleteTempData(blockTempIdByHash.values.toList())\n+\n+            throw ex\n         }\n     }\n \n-    abstract inner class FileDownloadObserver : Closeable {\n+    private suspend fun pullBlock(fileBlocks: FileBlocks, block: BlockInfo, timeoutInMillis: Long): ByteArray {\n+        logger.debug(\"sent request for block, hash = {}\", block.hash)\n+\n+        val response =\n+                withTimeout(timeoutInMillis) {\n+                    try {\n+                        doRequest(\n+                                Request.newBuilder()\n+                                        .setFolder(fileBlocks.folder)\n+                                        .setName(fileBlocks.path)\n+                                        .setOffset(block.offset)\n+                                        .setSize(block.size)\n+                                        .setHash(ByteString.copyFrom(Hex.decode(block.hash)))\n+                        )\n+                    } catch (ex: TimeoutCancellationException) {\n+                        // It seems like the TimeoutCancellationException\n+                        // is handled differently so that the timeout is ignored.\n+                        // Due to that, it's converted to an IOException.\n+\n+                        throw IOException(\"timeout during requesting block\")\n+                    }\n+                }\n \n-        abstract fun progress(): Double\n+        NetworkUtils.assertProtocol(response.code == ErrorCode.NO_ERROR) {\n+            \"received error response, code = ${response.code}\"\n+        }\n \n-        abstract fun progressMessage(): String\n+        val data = response.data.toByteArray()\n+        val hash = Hex.toHexString(MessageDigest.getInstance(\"SHA-256\").digest(data))\n \n-        abstract fun isCompleted(): Boolean\n+        if (hash != block.hash) {\n+            throw IllegalStateException(\"expected block with hash ${block.hash}, but got block with hash $hash\")\n+        }\n \n-        abstract fun inputStream(): InputStream\n+        return data\n+    }\n \n-        abstract fun checkError()\n+    private suspend fun doRequest(request: Request.Builder): BlockExchangeProtos.Response {\n+        return suspendCancellableCoroutine {\n+            continuation ->\n \n-        @Throws(InterruptedException::class)\n-        abstract fun waitForProgressUpdate(): Double\n+            val requestId = responseHandler.registerListener {\n+                response ->\n \n-        @Throws(InterruptedException::class)\n-        fun waitForComplete(): FileDownloadObserver {\n-            while (!isCompleted()) {\n-                waitForProgressUpdate()\n+                continuation.resume(response)\n             }\n-            return this\n-        }\n-\n-        abstract override fun close()\n \n+            connectionHandler.sendMessage(\n+                    request\n+                            .setId(requestId)\n+                            .build()\n+            )","path":"syncthing-bep/src/main/kotlin/net/syncthing/java/bep/BlockPuller.kt","position":271,"original_position":271,"commit_id":"59ba2c324edb571db6399ae51ddf7ecae96830d0","original_commit_id":"59ba2c324edb571db6399ae51ddf7ecae96830d0","user":{"login":"AudriusButkevicius","id":1144861,"node_id":"MDQ6VXNlcjExNDQ4NjE=","avatar_url":"https://avatars1.githubusercontent.com/u/1144861?v=4","gravatar_id":"","url":"https://api.github.com/users/AudriusButkevicius","html_url":"https://github.com/AudriusButkevicius","followers_url":"https://api.github.com/users/AudriusButkevicius/followers","following_url":"https://api.github.com/users/AudriusButkevicius/following{/other_user}","gists_url":"https://api.github.com/users/AudriusButkevicius/gists{/gist_id}","starred_url":"https://api.github.com/users/AudriusButkevicius/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/AudriusButkevicius/subscriptions","organizations_url":"https://api.github.com/users/AudriusButkevicius/orgs","repos_url":"https://api.github.com/users/AudriusButkevicius/repos","events_url":"https://api.github.com/users/AudriusButkevicius/events{/privacy}","received_events_url":"https://api.github.com/users/AudriusButkevicius/received_events","type":"User","site_admin":false},"body":"Feels like wonky formatting again.","created_at":"2018-10-16T08:08:55Z","updated_at":"2018-10-16T08:10:54Z","html_url":"https://github.com/syncthing/syncthing-lite/pull/57#discussion_r225436727","pull_request_url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57","author_association":"MEMBER","_links":{"self":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/comments/225436727"},"html":{"href":"https://github.com/syncthing/syncthing-lite/pull/57#discussion_r225436727"},"pull_request":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57"}}},"pull_request":{"url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57","id":220951666,"node_id":"MDExOlB1bGxSZXF1ZXN0MjIwOTUxNjY2","html_url":"https://github.com/syncthing/syncthing-lite/pull/57","diff_url":"https://github.com/syncthing/syncthing-lite/pull/57.diff","patch_url":"https://github.com/syncthing/syncthing-lite/pull/57.patch","issue_url":"https://api.github.com/repos/syncthing/syncthing-lite/issues/57","number":57,"state":"open","locked":false,"title":"Refactor file downloading","user":{"login":"l-jonas","id":43265000,"node_id":"MDQ6VXNlcjQzMjY1MDAw","avatar_url":"https://avatars1.githubusercontent.com/u/43265000?v=4","gravatar_id":"","url":"https://api.github.com/users/l-jonas","html_url":"https://github.com/l-jonas","followers_url":"https://api.github.com/users/l-jonas/followers","following_url":"https://api.github.com/users/l-jonas/following{/other_user}","gists_url":"https://api.github.com/users/l-jonas/gists{/gist_id}","starred_url":"https://api.github.com/users/l-jonas/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/l-jonas/subscriptions","organizations_url":"https://api.github.com/users/l-jonas/orgs","repos_url":"https://api.github.com/users/l-jonas/repos","events_url":"https://api.github.com/users/l-jonas/events{/privacy}","received_events_url":"https://api.github.com/users/l-jonas/received_events","type":"User","site_admin":false},"body":"This fixes https://github.com/syncthing/syncthing-lite/issues/56 (errors cause an error message and no crash with this)","created_at":"2018-10-07T14:33:05Z","updated_at":"2018-10-16T08:10:53Z","closed_at":null,"merged_at":null,"merge_commit_sha":"63e1f26c4f2eae9b0b1b1938e17650620937678e","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57/commits","review_comments_url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57/comments","review_comment_url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/comments{/number}","comments_url":"https://api.github.com/repos/syncthing/syncthing-lite/issues/57/comments","statuses_url":"https://api.github.com/repos/syncthing/syncthing-lite/statuses/59ba2c324edb571db6399ae51ddf7ecae96830d0","head":{"label":"l-jonas:refactor-file-downloading","ref":"refactor-file-downloading","sha":"59ba2c324edb571db6399ae51ddf7ecae96830d0","user":{"login":"l-jonas","id":43265000,"node_id":"MDQ6VXNlcjQzMjY1MDAw","avatar_url":"https://avatars1.githubusercontent.com/u/43265000?v=4","gravatar_id":"","url":"https://api.github.com/users/l-jonas","html_url":"https://github.com/l-jonas","followers_url":"https://api.github.com/users/l-jonas/followers","following_url":"https://api.github.com/users/l-jonas/following{/other_user}","gists_url":"https://api.github.com/users/l-jonas/gists{/gist_id}","starred_url":"https://api.github.com/users/l-jonas/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/l-jonas/subscriptions","organizations_url":"https://api.github.com/users/l-jonas/orgs","repos_url":"https://api.github.com/users/l-jonas/repos","events_url":"https://api.github.com/users/l-jonas/events{/privacy}","received_events_url":"https://api.github.com/users/l-jonas/received_events","type":"User","site_admin":false},"repo":{"id":148874779,"node_id":"MDEwOlJlcG9zaXRvcnkxNDg4NzQ3Nzk=","name":"syncthing-lite","full_name":"l-jonas/syncthing-lite","private":false,"owner":{"login":"l-jonas","id":43265000,"node_id":"MDQ6VXNlcjQzMjY1MDAw","avatar_url":"https://avatars1.githubusercontent.com/u/43265000?v=4","gravatar_id":"","url":"https://api.github.com/users/l-jonas","html_url":"https://github.com/l-jonas","followers_url":"https://api.github.com/users/l-jonas/followers","following_url":"https://api.github.com/users/l-jonas/following{/other_user}","gists_url":"https://api.github.com/users/l-jonas/gists{/gist_id}","starred_url":"https://api.github.com/users/l-jonas/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/l-jonas/subscriptions","organizations_url":"https://api.github.com/users/l-jonas/orgs","repos_url":"https://api.github.com/users/l-jonas/repos","events_url":"https://api.github.com/users/l-jonas/events{/privacy}","received_events_url":"https://api.github.com/users/l-jonas/received_events","type":"User","site_admin":false},"html_url":"https://github.com/l-jonas/syncthing-lite","description":"A Sync Browser: a browser app for Syncthing-compatible shares","fork":true,"url":"https://api.github.com/repos/l-jonas/syncthing-lite","forks_url":"https://api.github.com/repos/l-jonas/syncthing-lite/forks","keys_url":"https://api.github.com/repos/l-jonas/syncthing-lite/keys{/key_id}","collaborators_url":"https://api.github.com/repos/l-jonas/syncthing-lite/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/l-jonas/syncthing-lite/teams","hooks_url":"https://api.github.com/repos/l-jonas/syncthing-lite/hooks","issue_events_url":"https://api.github.com/repos/l-jonas/syncthing-lite/issues/events{/number}","events_url":"https://api.github.com/repos/l-jonas/syncthing-lite/events","assignees_url":"https://api.github.com/repos/l-jonas/syncthing-lite/assignees{/user}","branches_url":"https://api.github.com/repos/l-jonas/syncthing-lite/branches{/branch}","tags_url":"https://api.github.com/repos/l-jonas/syncthing-lite/tags","blobs_url":"https://api.github.com/repos/l-jonas/syncthing-lite/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/l-jonas/syncthing-lite/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/l-jonas/syncthing-lite/git/refs{/sha}","trees_url":"https://api.github.com/repos/l-jonas/syncthing-lite/git/trees{/sha}","statuses_url":"https://api.github.com/repos/l-jonas/syncthing-lite/statuses/{sha}","languages_url":"https://api.github.com/repos/l-jonas/syncthing-lite/languages","stargazers_url":"https://api.github.com/repos/l-jonas/syncthing-lite/stargazers","contributors_url":"https://api.github.com/repos/l-jonas/syncthing-lite/contributors","subscribers_url":"https://api.github.com/repos/l-jonas/syncthing-lite/subscribers","subscription_url":"https://api.github.com/repos/l-jonas/syncthing-lite/subscription","commits_url":"https://api.github.com/repos/l-jonas/syncthing-lite/commits{/sha}","git_commits_url":"https://api.github.com/repos/l-jonas/syncthing-lite/git/commits{/sha}","comments_url":"https://api.github.com/repos/l-jonas/syncthing-lite/comments{/number}","issue_comment_url":"https://api.github.com/repos/l-jonas/syncthing-lite/issues/comments{/number}","contents_url":"https://api.github.com/repos/l-jonas/syncthing-lite/contents/{+path}","compare_url":"https://api.github.com/repos/l-jonas/syncthing-lite/compare/{base}...{head}","merges_url":"https://api.github.com/repos/l-jonas/syncthing-lite/merges","archive_url":"https://api.github.com/repos/l-jonas/syncthing-lite/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/l-jonas/syncthing-lite/downloads","issues_url":"https://api.github.com/repos/l-jonas/syncthing-lite/issues{/number}","pulls_url":"https://api.github.com/repos/l-jonas/syncthing-lite/pulls{/number}","milestones_url":"https://api.github.com/repos/l-jonas/syncthing-lite/milestones{/number}","notifications_url":"https://api.github.com/repos/l-jonas/syncthing-lite/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/l-jonas/syncthing-lite/labels{/name}","releases_url":"https://api.github.com/repos/l-jonas/syncthing-lite/releases{/id}","deployments_url":"https://api.github.com/repos/l-jonas/syncthing-lite/deployments","created_at":"2018-09-15T06:03:38Z","updated_at":"2018-10-10T06:44:57Z","pushed_at":"2018-10-12T10:45:15Z","git_url":"git://github.com/l-jonas/syncthing-lite.git","ssh_url":"git@github.com:l-jonas/syncthing-lite.git","clone_url":"https://github.com/l-jonas/syncthing-lite.git","svn_url":"https://github.com/l-jonas/syncthing-lite","homepage":null,"size":1370,"stargazers_count":0,"watchers_count":0,"language":"Kotlin","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"open_issues_count":0,"license":{"key":"mpl-2.0","name":"Mozilla Public License 2.0","spdx_id":"MPL-2.0","url":"https://api.github.com/licenses/mpl-2.0","node_id":"MDc6TGljZW5zZTE0"},"forks":0,"open_issues":0,"watchers":0,"default_branch":"master"}},"base":{"label":"syncthing:master","ref":"master","sha":"3242d3d742be9c35f66d3785e2a4333feabfc4af","user":{"login":"syncthing","id":7628018,"node_id":"MDEyOk9yZ2FuaXphdGlvbjc2MjgwMTg=","avatar_url":"https://avatars0.githubusercontent.com/u/7628018?v=4","gravatar_id":"","url":"https://api.github.com/users/syncthing","html_url":"https://github.com/syncthing","followers_url":"https://api.github.com/users/syncthing/followers","following_url":"https://api.github.com/users/syncthing/following{/other_user}","gists_url":"https://api.github.com/users/syncthing/gists{/gist_id}","starred_url":"https://api.github.com/users/syncthing/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syncthing/subscriptions","organizations_url":"https://api.github.com/users/syncthing/orgs","repos_url":"https://api.github.com/users/syncthing/repos","events_url":"https://api.github.com/users/syncthing/events{/privacy}","received_events_url":"https://api.github.com/users/syncthing/received_events","type":"Organization","site_admin":false},"repo":{"id":111242768,"node_id":"MDEwOlJlcG9zaXRvcnkxMTEyNDI3Njg=","name":"syncthing-lite","full_name":"syncthing/syncthing-lite","private":false,"owner":{"login":"syncthing","id":7628018,"node_id":"MDEyOk9yZ2FuaXphdGlvbjc2MjgwMTg=","avatar_url":"https://avatars0.githubusercontent.com/u/7628018?v=4","gravatar_id":"","url":"https://api.github.com/users/syncthing","html_url":"https://github.com/syncthing","followers_url":"https://api.github.com/users/syncthing/followers","following_url":"https://api.github.com/users/syncthing/following{/other_user}","gists_url":"https://api.github.com/users/syncthing/gists{/gist_id}","starred_url":"https://api.github.com/users/syncthing/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/syncthing/subscriptions","organizations_url":"https://api.github.com/users/syncthing/orgs","repos_url":"https://api.github.com/users/syncthing/repos","events_url":"https://api.github.com/users/syncthing/events{/privacy}","received_events_url":"https://api.github.com/users/syncthing/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/syncthing/syncthing-lite","description":"A Sync Browser: a browser app for Syncthing-compatible shares","fork":true,"url":"https://api.github.com/repos/syncthing/syncthing-lite","forks_url":"https://api.github.com/repos/syncthing/syncthing-lite/forks","keys_url":"https://api.github.com/repos/syncthing/syncthing-lite/keys{/key_id}","collaborators_url":"https://api.github.com/repos/syncthing/syncthing-lite/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/syncthing/syncthing-lite/teams","hooks_url":"https://api.github.com/repos/syncthing/syncthing-lite/hooks","issue_events_url":"https://api.github.com/repos/syncthing/syncthing-lite/issues/events{/number}","events_url":"https://api.github.com/repos/syncthing/syncthing-lite/events","assignees_url":"https://api.github.com/repos/syncthing/syncthing-lite/assignees{/user}","branches_url":"https://api.github.com/repos/syncthing/syncthing-lite/branches{/branch}","tags_url":"https://api.github.com/repos/syncthing/syncthing-lite/tags","blobs_url":"https://api.github.com/repos/syncthing/syncthing-lite/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/syncthing/syncthing-lite/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/syncthing/syncthing-lite/git/refs{/sha}","trees_url":"https://api.github.com/repos/syncthing/syncthing-lite/git/trees{/sha}","statuses_url":"https://api.github.com/repos/syncthing/syncthing-lite/statuses/{sha}","languages_url":"https://api.github.com/repos/syncthing/syncthing-lite/languages","stargazers_url":"https://api.github.com/repos/syncthing/syncthing-lite/stargazers","contributors_url":"https://api.github.com/repos/syncthing/syncthing-lite/contributors","subscribers_url":"https://api.github.com/repos/syncthing/syncthing-lite/subscribers","subscription_url":"https://api.github.com/repos/syncthing/syncthing-lite/subscription","commits_url":"https://api.github.com/repos/syncthing/syncthing-lite/commits{/sha}","git_commits_url":"https://api.github.com/repos/syncthing/syncthing-lite/git/commits{/sha}","comments_url":"https://api.github.com/repos/syncthing/syncthing-lite/comments{/number}","issue_comment_url":"https://api.github.com/repos/syncthing/syncthing-lite/issues/comments{/number}","contents_url":"https://api.github.com/repos/syncthing/syncthing-lite/contents/{+path}","compare_url":"https://api.github.com/repos/syncthing/syncthing-lite/compare/{base}...{head}","merges_url":"https://api.github.com/repos/syncthing/syncthing-lite/merges","archive_url":"https://api.github.com/repos/syncthing/syncthing-lite/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/syncthing/syncthing-lite/downloads","issues_url":"https://api.github.com/repos/syncthing/syncthing-lite/issues{/number}","pulls_url":"https://api.github.com/repos/syncthing/syncthing-lite/pulls{/number}","milestones_url":"https://api.github.com/repos/syncthing/syncthing-lite/milestones{/number}","notifications_url":"https://api.github.com/repos/syncthing/syncthing-lite/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/syncthing/syncthing-lite/labels{/name}","releases_url":"https://api.github.com/repos/syncthing/syncthing-lite/releases{/id}","deployments_url":"https://api.github.com/repos/syncthing/syncthing-lite/deployments","created_at":"2017-11-18T22:00:14Z","updated_at":"2018-10-12T20:38:02Z","pushed_at":"2018-10-12T10:48:11Z","git_url":"git://github.com/syncthing/syncthing-lite.git","ssh_url":"git@github.com:syncthing/syncthing-lite.git","clone_url":"https://github.com/syncthing/syncthing-lite.git","svn_url":"https://github.com/syncthing/syncthing-lite","homepage":null,"size":1268,"stargazers_count":48,"watchers_count":48,"language":"Kotlin","has_issues":true,"has_projects":false,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":7,"mirror_url":null,"archived":false,"open_issues_count":18,"license":{"key":"mpl-2.0","name":"Mozilla Public License 2.0","spdx_id":"MPL-2.0","url":"https://api.github.com/licenses/mpl-2.0","node_id":"MDc6TGljZW5zZTE0"},"forks":7,"open_issues":18,"watchers":48,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57"},"html":{"href":"https://github.com/syncthing/syncthing-lite/pull/57"},"issue":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/issues/57"},"comments":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/issues/57/comments"},"review_comments":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57/comments"},"review_comment":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/pulls/57/commits"},"statuses":{"href":"https://api.github.com/repos/syncthing/syncthing-lite/statuses/59ba2c324edb571db6399ae51ddf7ecae96830d0"}},"author_association":"NONE"}}
{"actor":{"display_login":"AudriusButkevicius"}}