Added icon based on the system key when a polycentric user has not set a profile picture. Made managing Polycentric profile most robust. Fixed an issue where latest events were not always being shown in relation to Polycentric profiles. Added copyable (on long press) system key in Polycentric profile activity. Added tutorial videos tab and flow.

This commit is contained in:
Koen 2023-12-20 10:56:16 +01:00
parent 65174ffc97
commit 309a57f5a1
28 changed files with 503 additions and 203 deletions

View File

@ -1,11 +1,13 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.CommentViewHolder
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import userpackage.Protocol import userpackage.Protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@ -47,6 +49,15 @@ fun Protocol.Claim.resolveChannelUrls(): List<String> {
} }
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() { suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
if (!systemState.servers.contains(PolycentricCache.STAGING_SERVER)) {
removeServer(PolycentricCache.STAGING_SERVER)
}
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
removeServer(PolycentricCache.SERVER)
}
val exceptions = fullyBackfillServers() val exceptions = fullyBackfillServers()
for (pair in exceptions) { for (pair in exceptions) {
val server = pair.key val server = pair.key

View File

@ -8,12 +8,15 @@ import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.KeyPair import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret import com.futo.polycentric.core.ProcessSecret
@ -21,6 +24,9 @@ import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.base64UrlToByteArray import com.futo.polycentric.core.base64UrlToByteArray
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol import userpackage.Protocol
import userpackage.Protocol.ExportBundle import userpackage.Protocol.ExportBundle
@ -29,6 +35,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonScanProfile: LinearLayout; private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText; private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@ -52,6 +59,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonHelp = findViewById(R.id.button_help); _buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile); _buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile); _buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile); _editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener { findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish(); finish();
@ -94,42 +102,57 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
return; return;
} }
try { _loaderOverlay.show()
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val exportBundle = ExportBundle.parseFrom(urlInfo.body); lifecycleScope.launch(Dispatchers.IO) {
val keyPair = KeyPair.fromProto(exportBundle.keyPair); try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); val exportBundle = ExportBundle.parseFrom(urlInfo.body);
if (existingProcessSecret != null) { val keyPair = KeyPair.fromProto(exportBundle.keyPair);
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
return;
}
val processSecret = ProcessSecret(keyPair, Process.random()); val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
Store.instance.addProcessSecret(processSecret); if (existingProcessSecret != null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
}
return@launch;
}
val processHandle = processSecret.toProcessHandle(); val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
for (e in exportBundle.events.eventsList) { val processHandle = processSecret.toProcessHandle();
try {
val se = SignedEvent.fromProto(e); for (e in exportBundle.events.eventsList) {
Store.instance.putSignedEvent(se); try {
} catch (e: Throwable) { val se = SignedEvent.fromProto(e);
Logger.w(TAG, "Ignored invalid event", e); Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
} }
} }
StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
UIDialogs.toast(this, getString(R.string.failed_to_import_profile) + " '${e.message}'");
} }
} }

View File

@ -1,6 +1,8 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.app.Activity import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -12,6 +14,7 @@ import android.webkit.MimeTypeMap
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -21,14 +24,16 @@ import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.github.dhaval2404.imagepicker.ImagePicker import com.github.dhaval2404.imagepicker.ImagePicker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -46,6 +51,8 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonDelete: BigButton; private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String; private lateinit var _username: String;
private lateinit var _imagePolycentric: ImageView; private lateinit var _imagePolycentric: ImageView;
private lateinit var _loaderOverlay: LoaderOverlay;
private lateinit var _textSystem: TextView;
private var _avatarUri: Uri? = null; private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
@ -63,28 +70,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
_buttonExport = findViewById(R.id.button_export); _buttonExport = findViewById(R.id.button_export);
_buttonLogout = findViewById(R.id.button_logout); _buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete); _buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
_textSystem = findViewById(R.id.text_system)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { findViewById<ImageButton>(R.id.button_back).setOnClickListener {
saveIfRequired(); saveIfRequired();
finish(); finish();
}; };
lifecycleScope.launch(Dispatchers.IO) {
try {
val processHandle = StatePolycentric.instance.processHandle!!;
Synchronization.fullyBackFillClient(processHandle, processHandle.system, "https://srv1-stg.polycentric.io");
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
}
}
updateUI();
_imagePolycentric.setOnClickListener { _imagePolycentric.setOnClickListener {
ImagePicker.with(this) ImagePicker.with(this)
.cropSquare() .cropSquare()
@ -120,6 +112,37 @@ class PolycentricProfileActivity : AppCompatActivity() {
finish(); finish();
}); });
} }
_textSystem.setOnLongClickListener {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("system", _textSystem.text)
clipboard.setPrimaryClip(clip)
return@setOnLongClickListener true
}
updateUI()
StatePolycentric.instance.processHandle?.let { processHandle ->
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide()
}
}
}
}
} }
private fun saveIfRequired() { private fun saveIfRequired() {
@ -128,13 +151,17 @@ class PolycentricProfileActivity : AppCompatActivity() {
var hasChanges = false; var hasChanges = false;
val username = _editName.text.toString(); val username = _editName.text.toString();
if (username.length < 3) { if (username.length < 3) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long)); withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
}
return@launch; return@launch;
} }
val processHandle = StatePolycentric.instance.processHandle; val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) { if (processHandle == null) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset)); withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
}
return@launch; return@launch;
} }
@ -219,6 +246,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private fun updateUI() { private fun updateUI() {
val processHandle = StatePolycentric.instance.processHandle!!; val processHandle = StatePolycentric.instance.processHandle!!;
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system)) val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system))
_textSystem.text = processHandle.system.key.toBase64Url()
_username = systemState.username; _username = systemState.username;
_editName.text.clear(); _editName.text.clear();
_editName.text.append(_username); _editName.text.append(_username);

View File

@ -6,11 +6,12 @@ import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.* import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
@ -25,7 +26,11 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.* import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -93,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
val comment = _editComment.text.toString(); val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!! val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref) val eventPointer = processHandle.post(comment, ref)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {

View File

@ -465,7 +465,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate); _creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
val banner = profile?.systemState?.banner?.selectHighestResolutionImage() val banner = profile?.systemState?.banner?.selectHighestResolutionImage()

View File

@ -314,8 +314,8 @@ class PostDetailFragment : MainFragment {
private fun updatePolycentricRating() { private fun updatePolycentricRating() {
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
val value = _post?.id?.value ?: _postOverview?.id?.value ?: return; val ref = Models.referenceFromBuffer((_post?.url ?: _postOverview?.url)?.toByteArray() ?: return)
val ref = Models.referenceFromBuffer(value.toByteArray()); val extraBytesRef = (_post?.id?.value ?: _postOverview?.id?.value)?.toByteArray()
val version = _version; val version = _version;
_rating.onLikeDislikeUpdated.remove(this); _rating.onLikeDislikeUpdated.remove(this);
@ -333,7 +333,8 @@ class PostDetailFragment : MainFragment {
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue( ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build() ByteString.copyFrom(Opinion.dislike.data)).build()
) ),
extraByteReferences = listOfNotNull(extraBytesRef)
); );
if (version != _version) { if (version != _version) {
@ -342,8 +343,8 @@ class PostDetailFragment : MainFragment {
val likes = queryReferencesResponse.countsList[0]; val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1]; val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref); val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref); val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (version != _version) { if (version != _version) {
@ -468,9 +469,7 @@ class PostDetailFragment : MainFragment {
if (_postOverview == null) { if (_postOverview == null) {
fetchPolycentricProfile(); fetchPolycentricProfile();
updatePolycentricRating(); updatePolycentricRating();
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); };
_addCommentView.setContext(value.url, ref);
} }
updateCommentType(true); updateCommentType(true);
@ -489,9 +488,7 @@ class PostDetailFragment : MainFragment {
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textContent.text = value.description.fixHtmlWhitespace(); _textContent.text = value.description.fixHtmlWhitespace();
_platformIndicator.setPlatformFromClientID(value.id.pluginId); _platformIndicator.setPlatformFromClientID(value.id.pluginId);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); };
_addCommentView.setContext(value.url, ref);
updatePolycentricRating(); updatePolycentricRating();
fetchPolycentricProfile(); fetchPolycentricProfile();
@ -636,12 +633,12 @@ class PostDetailFragment : MainFragment {
if (cachedPolycentricProfile?.profile == null) { if (cachedPolycentricProfile?.profile == null) {
_layoutMonetization.visibility = View.GONE; _layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate); _creatorThumbnail.setHarborAvailable(false, animate, null);
return; return;
} }
_layoutMonetization.visibility = View.VISIBLE; _layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate); _creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto());
} }
private fun fetchPost() { private fun fetchPost() {
@ -665,14 +662,16 @@ class PostDetailFragment : MainFragment {
private fun fetchPolycentricComments() { private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments") Logger.i(TAG, "fetchPolycentricComments")
val post = _post; val post = _post;
val idValue = post?.id?.value val ref = (_post?.url ?: _postOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
if (idValue == null) { val extraBytesRef = (_post?.id?.value ?: _postOverview?.id?.value)?.toByteArray()
Logger.w(TAG, "Failed to fetch polycentric comments because id was null")
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear(); _commentsList.clear();
return return
} }
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post.url, Models.referenceFromBuffer(idValue.toByteArray())); }; _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
} }
private fun updateCommentType(reloadComments: Boolean) { private fun updateCommentType(reloadComments: Boolean) {

View File

@ -129,8 +129,8 @@ class TutorialFragment : MainFragment() {
override val dash: IDashManifestSource? = null override val dash: IDashManifestSource? = null
override val hls: IHLSManifestSource? = null override val hls: IHLSManifestSource? = null
override val subtitles: List<ISubtitleSource> = emptyList() override val subtitles: List<ISubtitleSource> = emptyList()
override val shareUrl: String = "" override val shareUrl: String = videoUrl
override val url: String = "" override val url: String = videoUrl
override val datetime: OffsetDateTime? = OffsetDateTime.parse("2023-12-18T00:00:00Z") override val datetime: OffsetDateTime? = OffsetDateTime.parse("2023-12-18T00:00:00Z")
override val thumbnails: Thumbnails = Thumbnails(arrayOf(Thumbnail(thumbnailUrl))) override val thumbnails: Thumbnails = Thumbnails(arrayOf(Thumbnail(thumbnailUrl)))
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("tutorial", "f422ced6-b551-4b62-818e-27a4f5f4918a"), "Grayjay", "", "https://releases.grayjay.app/tutorials/author.jpeg") override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("tutorial", "f422ced6-b551-4b62-818e-27a4f5f4918a"), "Grayjay", "", "https://releases.grayjay.app/tutorials/author.jpeg")

View File

@ -1201,9 +1201,9 @@ class VideoDetailView : ConstraintLayout {
}; };
} }
val ref = video.id.value?.let { Models.referenceFromBuffer(it.toByteArray()) }; val ref = Models.referenceFromBuffer(video.url.toByteArray())
_addCommentView.setContext(video.url, ref); val extraBytesRef = video.id.value?.toByteArray()
_addCommentView.setContext(video.url, ref)
_player.setMetadata(video.name, video.author.name); _player.setMetadata(video.name, video.author.name);
if (video !is TutorialFragment.TutorialVideo) { if (video !is TutorialFragment.TutorialVideo) {
@ -1264,57 +1264,54 @@ class VideoDetailView : ConstraintLayout {
_rating.onLikeDislikeUpdated.remove(this); _rating.onLikeDislikeUpdated.remove(this);
if (ref != null) { _rating.visibility = View.GONE;
_rating.visibility = View.GONE;
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null, val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
arrayListOf( arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(), ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build() ByteString.copyFrom(Opinion.dislike.data)).build()
) ),
); extraByteReferences = listOfNotNull(extraBytesRef)
);
val likes = queryReferencesResponse.countsList[0]; val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1]; val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref); val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref); val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE; _rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike); args.processHandle.opinion(ref, Opinion.dislike);
} else { } else {
args.processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.neutral);
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
} }
}
fragment.lifecycleScope.launch(Dispatchers.IO) { StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
try { };
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
} }
} else {
_rating.visibility = View.GONE;
} }
when (video.rating) { when (video.rating) {
@ -1361,28 +1358,30 @@ class VideoDetailView : ConstraintLayout {
updateQueueState(); updateQueueState();
fragment.lifecycleScope.launch(Dispatchers.IO) { if (video !is TutorialFragment.TutorialVideo) {
val historyItem = getHistoryIndex(videoDetail); fragment.lifecycleScope.launch(Dispatchers.IO) {
val historyItem = getHistoryIndex(videoDetail);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); _historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
_layoutResume.visibility = View.VISIBLE; _layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
delay(8000); delay(8000);
_layoutResume.visibility = View.GONE; _layoutResume.visibility = View.GONE;
_textResume.text = ""; _textResume.text = "";
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e); Logger.e(TAG, "Failed to set resume changes.", e);
}
} }
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} }
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} }
} }
} }
@ -1954,7 +1953,9 @@ class VideoDetailView : ConstraintLayout {
return return
} }
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, Models.referenceFromBuffer(idValue.toByteArray())); }; val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = video.id.value?.toByteArray()
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); };
} }
private fun fetchVideo() { private fun fetchVideo() {
Logger.i(TAG, "fetchVideo") Logger.i(TAG, "fetchVideo")
@ -2216,9 +2217,11 @@ class VideoDetailView : ConstraintLayout {
val v = video ?: return; val v = video ?: return;
val currentTime = System.currentTimeMillis(); val currentTime = System.currentTimeMillis();
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
fragment.lifecycleScope.launch(Dispatchers.IO) { if (v !is TutorialFragment.TutorialVideo) {
val history = getHistoryIndex(v); fragment.lifecycleScope.launch(Dispatchers.IO) {
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); val history = getHistoryIndex(v);
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
}
} }
_lastPositionSaveTime = currentTime; _lastPositionSaveTime = currentTime;
} }
@ -2301,7 +2304,7 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate); _creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
val username = cachedPolycentricProfile?.profile?.systemState?.username val username = cachedPolycentricProfile?.profile?.systemState?.username

View File

@ -56,7 +56,7 @@ class PolycentricCache {
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope, private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
{ system -> { system ->
val signedProfileEvents = ApiMethods.getQueryLatest( val signedEventsList = ApiMethods.getQueryLatest(
SERVER, SERVER,
system.toProto(), system.toProto(),
listOf( listOf(
@ -72,8 +72,9 @@ class PolycentricCache {
ContentType.MEMBERSHIP_URLS.value, ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value ContentType.DONATION_DESTINATIONS.value
) )
).eventsList.map { e -> SignedEvent.fromProto(e) } ).eventsList.map { e -> SignedEvent.fromProto(e) };
.groupBy { e -> e.event.contentType }
val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } }; .map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
val storageSystemState = StorageTypeSystemState.create() val storageSystemState = StorageTypeSystemState.create()
@ -151,17 +152,7 @@ class PolycentricCache {
private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope, private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope,
{ {
val urlData = if (it.startsWith("polycentric://")) { val dataLink = getDataLinkFromUrl(it) ?: throw Exception("Only URLInfoDataLink is supported");
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
throw Exception("Only URLInfoDataLink is supported");
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink); return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink);
}, },
{ return@BatchedTaskHandler null }, { return@BatchedTaskHandler null },
@ -325,9 +316,10 @@ class PolycentricCache {
.build(); .build();
private const val TAG = "PolycentricCache" private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-stg.polycentric.io" const val STAGING_SERVER = "https://srv1-stg.polycentric.io"
const val SERVER = "https://srv1-prod.polycentric.io"
private var _instance: PolycentricCache? = null; private var _instance: PolycentricCache? = null;
private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3; private val CACHE_EXPIRATION_SECONDS = 60 * 5;
@JvmStatic @JvmStatic
val instance: PolycentricCache val instance: PolycentricCache
@ -343,5 +335,20 @@ class PolycentricCache {
it._scope.cancel("PolycentricCache finished"); it._scope.cancel("PolycentricCache finished");
} }
} }
fun getDataLinkFromUrl(it: String): Protocol.URLInfoDataLink? {
val urlData = if (it.startsWith("polycentric://")) {
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
return null
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return dataLink
}
} }
} }

View File

@ -27,7 +27,20 @@ import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStorage
import com.futo.polycentric.core.* import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.SqlLiteDbHelper
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toBase64
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@ -38,7 +51,6 @@ import userpackage.Protocol
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import kotlin.Exception
class StatePolycentric { class StatePolycentric {
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean); private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
@ -128,21 +140,21 @@ class StatePolycentric {
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked); _likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
} }
fun hasDisliked(ref: Protocol.Reference): Boolean { fun hasDisliked(data: ByteArray): Boolean {
if (!enabled) { if (!enabled) {
return false return false
} }
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false; val entry = _likeDislikeMap[data.toBase64()] ?: return false;
return entry.hasDisliked; return entry.hasDisliked;
} }
fun hasLiked(ref: Protocol.Reference): Boolean { fun hasLiked(data: ByteArray): Boolean {
if (!enabled) { if (!enabled) {
return false return false
} }
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false; val entry = _likeDislikeMap[data.toBase64()] ?: return false;
return entry.hasLiked; return entry.hasLiked;
} }
@ -316,7 +328,7 @@ class StatePolycentric {
return LikesDislikesReplies(likes, dislikes, replyCount) return LikesDislikesReplies(likes, dislikes, replyCount)
} }
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager<IPlatformComment> { suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> {
if (!enabled) { if (!enabled) {
return EmptyPager() return EmptyPager()
} }
@ -338,7 +350,8 @@ class StatePolycentric {
Protocol.QueryReferencesRequestCountReferences.newBuilder() Protocol.QueryReferencesRequestCountReferences.newBuilder()
.setFromType(ContentType.POST.value) .setFromType(ContentType.POST.value)
.build()) .build())
.build() .build(),
extraByteReferences = extraByteReferences
); );
val results = mapQueryReferences(contextUrl, response); val results = mapQueryReferences(contextUrl, response);
@ -407,7 +420,8 @@ class StatePolycentric {
ContentType.AVATAR.value, ContentType.AVATAR.value,
ContentType.USERNAME.value ContentType.USERNAME.value
) )
).eventsList.map { e -> SignedEvent.fromProto(e) }; ).eventsList.map { e -> SignedEvent.fromProto(e) }.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { x -> x.event.unixMilliseconds ?: 0 } };
val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value }; val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value };
val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value }; val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value };

View File

@ -0,0 +1,117 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import java.security.MessageDigest
class IdenticonView(context: Context, attrs: AttributeSet) : View(context, attrs) {
var hashString: String = "default"
set(value) {
field = value
hash = md5(value)
invalidate()
}
private var hash = ByteArray(16)
private val path = Path()
private val paint = Paint().apply {
style = Paint.Style.FILL
}
init {
hashString = "default"
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val radius = (width.coerceAtMost(height) / 2).toFloat()
val clipPath = path.apply {
reset()
addCircle(width / 2f, height / 2f, radius, Path.Direction.CW)
}
canvas.clipPath(clipPath)
val size = width.coerceAtMost(height) / 5
val colors = generateColorsFromHash(hash)
for (x in 0 until 5) {
for (y in 0 until 5) {
val shapeIndex = getShapeIndex(x, y, hash)
paint.color = colors[shapeIndex % colors.size]
drawShape(canvas, x, y, size, shapeIndex)
}
}
}
private fun md5(input: String): ByteArray {
val md = MessageDigest.getInstance("MD5")
return md.digest(input.toByteArray(Charsets.UTF_8))
}
private fun generateColorsFromHash(hash: ByteArray): List<Int> {
val hue = hash[0].toFloat() / 255f
return listOf(
adjustColor(hue, 0.5f, 0.4f),
adjustColor(hue, 0.5f, 0.8f),
adjustColor(hue, 0.5f, 0.3f, 0.9f),
adjustColor(hue, 0.5f, 0.4f, 0.7f)
)
}
private fun getShapeIndex(x: Int, y: Int, hash: ByteArray): Int {
val index = if (x < 3) y else 4 - y
return hash[index].toInt() shr x * 2 and 0x03
}
private fun drawShape(canvas: Canvas, x: Int, y: Int, size: Int, shapeIndex: Int) {
val left = x * size.toFloat()
val top = y * size.toFloat()
val path = Path()
when (shapeIndex) {
0 -> {
// Square
path.addRect(left, top, left + size, top + size, Path.Direction.CW)
}
1 -> {
// Circle
val radius = size / 2f
path.addCircle(left + radius, top + radius, radius, Path.Direction.CW)
}
2 -> {
// Diamond
val halfSize = size / 2f
path.moveTo(left + halfSize, top)
path.lineTo(left + size, top + halfSize)
path.lineTo(left + halfSize, top + size)
path.lineTo(left, top + halfSize)
path.close()
}
3 -> {
// Triangle
path.moveTo(left + size / 2f, top)
path.lineTo(left + size, top + size)
path.lineTo(left, top + size)
path.close()
}
}
canvas.drawPath(path, paint)
}
private fun adjustColor(hue: Float, saturation: Float, lightness: Float, alpha: Float = 1.0f): Int {
val color = Color.HSVToColor(floatArrayOf(hue * 360, saturation, lightness))
val red = Color.red(color)
val green = Color.green(color)
val blue = Color.blue(color)
return Color.argb((alpha * 255).toInt(), red, green, blue)
}
}

View File

@ -9,15 +9,21 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.* import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
@ -104,7 +110,8 @@ class CommentViewHolder : ViewHolder {
fun bind(comment: IPlatformComment, readonly: Boolean) { fun bind(comment: IPlatformComment, readonly: Boolean) {
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false); _creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false); val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
_textAuthor.text = comment.author.name; _textAuthor.text = comment.author.name;
val date = comment.date; val date = comment.date;
@ -161,8 +168,8 @@ class CommentViewHolder : ViewHolder {
_pillRatingLikesDislikes.visibility = View.VISIBLE; _pillRatingLikesDislikes.visibility = View.VISIBLE;
if (comment is PolycentricPlatformComment) { if (comment is PolycentricPlatformComment) {
val hasLiked = StatePolycentric.instance.hasLiked(comment.reference); val hasLiked = StatePolycentric.instance.hasLiked(comment.reference.toByteArray());
val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference); val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference.toByteArray());
_pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked); _pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked);
} else { } else {
_pillRatingLikesDislikes.setRating(comment.rating); _pillRatingLikesDislikes.setRating(comment.rating);

View File

@ -126,7 +126,8 @@ class CommentWithReferenceViewHolder : ViewHolder {
_taskGetLiveComment.cancel() _taskGetLiveComment.cancel()
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false); _creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false); val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
_textAuthor.text = comment.author.name; _textAuthor.text = comment.author.name;
val date = comment.date; val date = comment.date;
@ -168,8 +169,8 @@ class CommentWithReferenceViewHolder : ViewHolder {
if (likesDislikesReplies != null) { if (likesDislikesReplies != null) {
Log.i(TAG, "updateLikesDislikesReplies set") Log.i(TAG, "updateLikesDislikesReplies set")
val hasLiked = StatePolycentric.instance.hasLiked(c.reference); val hasLiked = StatePolycentric.instance.hasLiked(c.reference.toByteArray());
val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference); val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference.toByteArray());
_pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked); _pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked);
_buttonReplies.setLoading(false) _buttonReplies.setLoading(false)

View File

@ -7,7 +7,7 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.* import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@ -18,8 +18,8 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
@ -149,7 +149,8 @@ open class PlaylistView : LinearLayout {
_neopassAnimator?.cancel(); _neopassAnimator?.cancel();
_neopassAnimator = null; _neopassAnimator = null;
val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); val firstClaim = claims?.ownedClaims?.firstOrNull();
val harborAvailable = firstClaim != null
if (harborAvailable) { if (harborAvailable) {
_imageNeopassChannel?.visibility = View.VISIBLE _imageNeopassChannel?.visibility = View.VISIBLE
if (animate) { if (animate) {
@ -160,7 +161,7 @@ open class PlaylistView : LinearLayout {
_imageNeopassChannel?.visibility = View.GONE _imageNeopassChannel?.visibility = View.GONE
} }
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate) _creatorThumbnail?.setHarborAvailable(harborAvailable, animate, firstClaim?.system?.toProto())
} }
companion object { companion object {

View File

@ -6,21 +6,18 @@ import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.platformplayer.toHumanTimeIndicator import com.futo.platformplayer.toHumanTimeIndicator
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
@ -107,7 +104,7 @@ class SubscriptionViewHolder : ViewHolder {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate); _creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
if (profile != null) { if (profile != null) {

View File

@ -334,7 +334,7 @@ open class PreviewVideoView : LinearLayout {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(content?.author?.thumbnail, animate); _creatorThumbnail.setThumbnail(content?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
} else if (_imageChannel != null) { } else if (_imageChannel != null) {
val dp_28 = 28.dp(context.resources); val dp_28 = 28.dp(context.resources);

View File

@ -1,6 +1,5 @@
package com.futo.platformplayer.views.adapters.viewholders package com.futo.platformplayer.views.adapters.viewholders
import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
@ -8,12 +7,10 @@ import android.widget.TextView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@ -76,7 +73,7 @@ class CreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyVi
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate); _creatorThumbnail.setThumbnail(_channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
if (profile != null) { if (profile != null) {
@ -148,7 +145,7 @@ class SelectableCreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(_channel?.channel?.thumbnail, animate); _creatorThumbnail.setThumbnail(_channel?.channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
if (profile != null) { if (profile != null) {

View File

@ -98,7 +98,7 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(_authorLink?.thumbnail, animate); _creatorThumbnail.setThumbnail(_authorLink?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
if (profile != null) { if (profile != null) {

View File

@ -77,7 +77,7 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(_channel?.thumbnail, animate); _creatorThumbnail.setThumbnail(_channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
if (profile != null) { if (profile != null) {

View File

@ -12,12 +12,16 @@ import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.views.IdenticonView
import userpackage.Protocol
class CreatorThumbnail : ConstraintLayout { class CreatorThumbnail : ConstraintLayout {
private val _root: ConstraintLayout; private val _root: ConstraintLayout;
private val _imageChannelThumbnail: ImageView; private val _imageChannelThumbnail: ImageView;
private val _imageNewActivity: ImageView; private val _imageNewActivity: ImageView;
private val _imageNeoPass: ImageView; private val _imageNeoPass: ImageView;
private val _identicon: IdenticonView;
private var _harborAnimator: ObjectAnimator? = null; private var _harborAnimator: ObjectAnimator? = null;
private var _imageAnimator: ObjectAnimator? = null; private var _imageAnimator: ObjectAnimator? = null;
@ -28,19 +32,22 @@ class CreatorThumbnail : ConstraintLayout {
_root = findViewById(R.id.root); _root = findViewById(R.id.root);
_imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail); _imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail);
_identicon = findViewById(R.id.identicon);
_imageChannelThumbnail.clipToOutline = true; _imageChannelThumbnail.clipToOutline = true;
_imageChannelThumbnail.visibility = View.GONE
_imageNewActivity = findViewById(R.id.image_new_activity); _imageNewActivity = findViewById(R.id.image_new_activity);
_imageNeoPass = findViewById(R.id.image_neopass); _imageNeoPass = findViewById(R.id.image_neopass);
if (!isInEditMode) { if (!isInEditMode) {
setHarborAvailable(false, animate = false); setHarborAvailable(false, animate = false, system = null);
setNewActivity(false); setNewActivity(false);
} }
} }
fun clear() { fun clear() {
_imageChannelThumbnail.visibility = View.GONE;
_imageChannelThumbnail.setImageResource(R.drawable.placeholder_channel_thumbnail); _imageChannelThumbnail.setImageResource(R.drawable.placeholder_channel_thumbnail);
setHarborAvailable(false, animate = false); setHarborAvailable(false, animate = false, system = null);
setNewActivity(false); setNewActivity(false);
} }
@ -50,13 +57,24 @@ class CreatorThumbnail : ConstraintLayout {
return; return;
} }
_imageChannelThumbnail.visibility = View.VISIBLE;
_harborAnimator?.cancel(); _harborAnimator?.cancel();
_harborAnimator = null; _harborAnimator = null;
_imageAnimator?.cancel(); _imageAnimator?.cancel();
_imageAnimator = null; _imageAnimator = null;
setHarborAvailable(url.startsWith("polycentric://"), animate); if (url.startsWith("polycentric://")) {
try {
val dataLink = PolycentricCache.getDataLinkFromUrl(url)
setHarborAvailable(true, animate, dataLink?.system);
} catch (e: Throwable) {
setHarborAvailable(false, animate, null);
}
} else {
setHarborAvailable(false, animate, null);
}
if (animate) { if (animate) {
Glide.with(_imageChannelThumbnail) Glide.with(_imageChannelThumbnail)
@ -72,7 +90,7 @@ class CreatorThumbnail : ConstraintLayout {
} }
} }
fun setHarborAvailable(available: Boolean, animate: Boolean) { fun setHarborAvailable(available: Boolean, animate: Boolean, system: Protocol.PublicKey?) {
_harborAnimator?.cancel(); _harborAnimator?.cancel();
_harborAnimator = null; _harborAnimator = null;
@ -85,6 +103,13 @@ class CreatorThumbnail : ConstraintLayout {
} else { } else {
_imageNeoPass.visibility = View.GONE; _imageNeoPass.visibility = View.GONE;
} }
if (system != null) {
_identicon.hashString = system.toString()
_identicon.visibility = View.VISIBLE
} else {
_identicon.visibility = View.GONE
}
} }
fun setChannelImageResource(resource: Int?, animate: Boolean) { fun setChannelImageResource(resource: Int?, animate: Boolean) {

View File

@ -3,6 +3,7 @@ package com.futo.platformplayer.views.overlays
import android.content.Context import android.content.Context
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
@ -16,6 +17,21 @@ class LoaderOverlay(context: Context, attrs: AttributeSet?) : FrameLayout(contex
inflate(context, R.layout.overlay_loader, this); inflate(context, R.layout.overlay_loader, this);
_container = findViewById(R.id.container); _container = findViewById(R.id.container);
_loader = findViewById(R.id.loader); _loader = findViewById(R.id.loader);
val centerLoader: Boolean;
if (attrs != null) {
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.LoaderOverlay, 0, 0);
centerLoader = attrArr.getBoolean(R.styleable.LoaderOverlay_centerLoader, false);
attrArr.recycle();
} else {
centerLoader = false;
}
if (centerLoader) {
(_loader.layoutParams as LayoutParams).apply {
gravity = Gravity.CENTER
}
}
} }
fun show() { fun show() {

View File

@ -6,8 +6,8 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
@ -102,7 +102,8 @@ class RepliesOverlay : LinearLayout {
} }
_creatorThumbnail.setThumbnail(parentComment.author.thumbnail, false); _creatorThumbnail.setThumbnail(parentComment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(parentComment is PolycentricPlatformComment,false); val polycentricPlatformComment = if (parentComment is PolycentricPlatformComment) parentComment else null
_creatorThumbnail.setHarborAvailable(polycentricPlatformComment != null,false, polycentricPlatformComment?.eventPointer?.system?.toProto());
} }
_topbar.setInfo(context.getString(R.string.Replies), metadata); _topbar.setInfo(context.getString(R.string.Replies), metadata);

View File

@ -94,4 +94,11 @@
android:text="@string/import_profile" /> android:text="@string/import_profile" />
</LinearLayout> </LinearLayout>
<com.futo.platformplayer.views.overlays.LoaderOverlay
android:id="@+id/loader_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:centerLoader="true"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -51,6 +51,21 @@
app:layout_constraintLeft_toLeftOf="@id/image_polycentric" app:layout_constraintLeft_toLeftOf="@id/image_polycentric"
app:layout_constraintRight_toRightOf="@id/image_polycentric" /> app:layout_constraintRight_toRightOf="@id/image_polycentric" />
<TextView
android:id="@+id/text_system"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="gX0eCWctTm6WHVGot4sMAh7NDAIwWsIM5tRsOz9dX04="
android:fontFamily="@font/inter_regular"
android:textSize="10dp"
android:maxLines="1"
android:ellipsize="middle"
android:textColor="@color/gray_67"
android:layout_marginTop="20dp"
app:layout_constraintTop_toBottomOf="@id/edit_profile_name"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<LinearLayout <LinearLayout
android:id="@+id/layout_buttons" android:id="@+id/layout_buttons"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -91,4 +106,11 @@
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
app:buttonBackground="@drawable/background_big_button_red"/> app:buttonBackground="@drawable/background_big_button_red"/>
</LinearLayout> </LinearLayout>
<com.futo.platformplayer.views.overlays.LoaderOverlay
android:id="@+id/loader_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:centerLoader="true"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -17,5 +17,6 @@
android:layout_gravity="top|center_horizontal" android:layout_gravity="top|center_horizontal"
android:alpha="0.7" android:alpha="0.7"
android:layout_marginTop="80dp" android:layout_marginTop="80dp"
android:layout_marginBottom="80dp"
android:contentDescription="@string/loading" /> android:contentDescription="@string/loading" />
</FrameLayout> </FrameLayout>

View File

@ -7,6 +7,18 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:id="@+id/root"> android:id="@+id/root">
<com.futo.platformplayer.views.IdenticonView
android:id="@+id/identicon"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="1:1"
app:srcCompat="@drawable/ic_futo_logo"
android:background="@drawable/rounded_outline"
android:contentDescription="@string/channel_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<ImageView <ImageView
android:id="@+id/image_channel_thumbnail" android:id="@+id/image_channel_thumbnail"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LoaderOverlay">
<attr name="centerLoader" format="boolean" />
</declare-styleable>
</resources>

@ -1 +1 @@
Subproject commit 86cd96c41f15f7f73c091f61800a49f376a38150 Subproject commit 7695198eeaeaaea4726712c460081c411ef67866