Merge branch 'dev' into session-restore

This commit is contained in:
Niels Andriesse
2020-01-28 10:36:46 +11:00
373 changed files with 14757 additions and 7394 deletions

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.loki
import android.content.Context
import android.content.Intent
import nl.komponents.kovenant.functional.map
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.jobs.PushContentReceiveJob
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener
@@ -33,7 +34,7 @@ class BackgroundPollWorker : PersistentAlarmManagerListener() {
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context)
val lokiAPIDatabase = DatabaseFactory.getLokiAPIDatabase(context)
try {
LokiAPI(userHexEncodedPublicKey, lokiAPIDatabase).getMessages().map { messages ->
LokiAPI(userHexEncodedPublicKey, lokiAPIDatabase, (context.applicationContext as ApplicationContext).broadcaster).getMessages().map { messages ->
messages.forEach {
PushContentReceiveJob(context).processEnvelope(SignalServiceEnvelope(it))
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.loki
import android.content.Context
import android.content.Intent
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.util.concurrent.TimeUnit
class BackgroundPublicChatPollWorker : PersistentAlarmManagerListener() {
companion object {
private val pollInterval = TimeUnit.MINUTES.toMillis(4)
@JvmStatic
fun schedule(context: Context) {
BackgroundPublicChatPollWorker().onReceive(context, Intent())
}
}
override fun getNextScheduledExecutionTime(context: Context): Long {
return TextSecurePreferences.getPublicChatBackgroundPollTime(context)
}
override fun onAlarm(context: Context, scheduledTime: Long): Long {
if (scheduledTime != 0L) {
val publicChats = DatabaseFactory.getLokiThreadDatabase(context).getAllPublicChats().map { it.value }
for (publicChat in publicChats) {
val poller = LokiPublicChatPoller(context, publicChat)
poller.stop()
poller.pollForNewMessages()
}
}
val nextTime = System.currentTimeMillis() + pollInterval
TextSecurePreferences.setPublicChatBackgroundPollTime(context, nextTime)
return nextTime
}
}

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.ConversationListActivity
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.redesign.utilities.show
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.crypto.ProfileCipher
import org.whispersystems.signalservice.loki.utilities.Analytics
@@ -44,7 +45,7 @@ class DisplayNameActivity : BaseActionBarActivity() {
application.setUpP2PAPI()
application.startLongPollingIfNeeded()
application.setUpStorageAPIIfNeeded()
startActivity(Intent(this, ConversationListActivity::class.java))
show(Intent(this, ConversationListActivity::class.java))
finish()
val publicChatAPI = ApplicationContext.getInstance(this).lokiPublicChatAPI
if (publicChatAPI != null) {

View File

@@ -1,16 +0,0 @@
package org.thoughtcrime.securesms.loki
import org.thoughtcrime.securesms.database.model.MessageRecord
interface FriendRequestViewDelegate {
/**
* Implementations of this method should update the thread's friend request status
* and send a friend request accepted message.
*/
fun acceptFriendRequest(friendRequest: MessageRecord)
/**
* Implementations of this method should update the thread's friend request status
* and remove the pre keys associated with the contact.
*/
fun rejectFriendRequest(friendRequest: MessageRecord)
}

View File

@@ -55,15 +55,24 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
private val grantSignature = "grant_signature"
@JvmStatic val createPairingAuthorisationTableCommand = "CREATE TABLE $pairingAuthorisationCache ($primaryDevicePublicKey TEXT, $secondaryDevicePublicKey TEXT, " +
"$requestSignature TEXT NULLABLE DEFAULT NULL, $grantSignature TEXT NULLABLE DEFAULT NULL, PRIMARY KEY ($primaryDevicePublicKey, $secondaryDevicePublicKey));"
// User count cache
private val userCountCache = "loki_user_count_cache"
private val publicChatID = "public_chat_id"
private val userCount = "user_count"
@JvmStatic val createUserCountTableCommand = "CREATE TABLE $userCountCache ($publicChatID STRING PRIMARY KEY, $userCount INTEGER DEFAULT 0);"
}
override fun getSwarmCache(hexEncodedPublicKey: String): Set<LokiAPITarget>? {
val database = databaseHelper.readableDatabase
return database.get(swarmCache, "${Companion.hexEncodedPublicKey} = ?", wrap(hexEncodedPublicKey)) { cursor ->
val swarmAsString = cursor.getString(cursor.getColumnIndexOrThrow(swarm))
swarmAsString.split(", ").map { targetAsString ->
val components = targetAsString.split("?port=")
LokiAPITarget(components[0], components[1].toInt())
swarmAsString.split(", ").mapNotNull { targetAsString ->
val components = targetAsString.split("-")
val address = components[0]
val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
val idKey = components.getOrNull(2) ?: return@mapNotNull null
val encryptionKey = components.getOrNull(3)?: return@mapNotNull null
LokiAPITarget(address, port, LokiAPITarget.KeySet(idKey, encryptionKey))
}
}?.toSet()
}
@@ -71,7 +80,12 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
override fun setSwarmCache(hexEncodedPublicKey: String, newValue: Set<LokiAPITarget>) {
val database = databaseHelper.writableDatabase
val swarmAsString = newValue.joinToString(", ") { target ->
"${target.address}?port=${target.port}"
var string = "${target.address}-${target.port}"
val keySet = target.publicKeySet
if (keySet != null) {
string += "-${keySet.idKey}-${keySet.encryptionKey}"
}
string
}
val row = wrap(mapOf( Companion.hexEncodedPublicKey to hexEncodedPublicKey, swarm to swarmAsString ))
database.insertOrUpdate(swarmCache, row, "${Companion.hexEncodedPublicKey} = ?", wrap(hexEncodedPublicKey))
@@ -186,14 +200,29 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
}
override fun removePairingAuthorisations(hexEncodedPublicKey: String) {
val database = databaseHelper.readableDatabase
val database = databaseHelper.writableDatabase
database.delete(pairingAuthorisationCache, "$primaryDevicePublicKey = ? OR $secondaryDevicePublicKey = ?", arrayOf( hexEncodedPublicKey, hexEncodedPublicKey ))
}
fun removePairingAuthorisation(primaryDevicePublicKey: String, secondaryDevicePublicKey: String) {
val database = databaseHelper.readableDatabase
val database = databaseHelper.writableDatabase
database.delete(pairingAuthorisationCache, "${Companion.primaryDevicePublicKey} = ? OR ${Companion.secondaryDevicePublicKey} = ?", arrayOf( primaryDevicePublicKey, secondaryDevicePublicKey ))
}
fun getUserCount(group: Long, server: String): Int? {
val database = databaseHelper.readableDatabase
val index = "$server.$group"
return database.get(userCountCache, "$publicChatID = ?", wrap(index)) { cursor ->
cursor.getInt(userCount)
}?.toInt()
}
override fun setUserCount(userCount: Int, group: Long, server: String) {
val database = databaseHelper.writableDatabase
val index = "$server.$group"
val row = wrap(mapOf( publicChatID to index, LokiAPIDatabase.userCount to userCount.toString() ))
database.insertOrUpdate(userCountCache, row, "$publicChatID = ?", wrap(index))
}
}
// region Convenience

View File

@@ -10,7 +10,6 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.jobs.PushDecryptJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.recipients.Recipient
@@ -154,7 +153,7 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki
return SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, false, 0, false, null, false, quote, null, signalLinkPreviews, null)
}
private fun pollForNewMessages() {
fun pollForNewMessages() {
fun processIncomingMessage(message: LokiPublicChatMessage) {
// If the sender of the current message is not a secondary device, we need to set the display name in the database
val primaryDevice = LokiStorageAPI.shared.getPrimaryDevicePublicKey(message.hexEncodedPublicKey).get()
@@ -221,6 +220,9 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki
}
var userDevices = setOf<String>()
var uniqueDevices = setOf<String>()
val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize()
val database = DatabaseFactory.getLokiAPIDatabase(context)
LokiStorageAPI.configure(false, userHexEncodedPublicKey, userPrivateKey, database)
LokiStorageAPI.shared.getAllDevicePublicKeys(userHexEncodedPublicKey).bind { devices ->
userDevices = devices
api.getMessages(group.channel, group.server)

View File

@@ -259,7 +259,7 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDelegate, ScanListene
TextSecurePreferences.setHasSeenWelcomeScreen(this, false)
TextSecurePreferences.setPromptedPushRegistration(this, false)
}
// endregion
override fun onQrDataFound(data: String?) {
runOnUiThread {
if (data != null && PublicKeyValidation.isValid(data.trim())) {

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.os.Bundle
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.preferences.ChatsPreferenceFragment
class ChatSettingsActivity : PassphraseRequiredActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_fragment_wrapper)
supportActionBar!!.title = "Chats"
val fragment = ChatsPreferenceFragment()
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragmentContainer, fragment)
transaction.commit()
}
}

View File

@@ -0,0 +1,141 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentPagerAdapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_create_private_chat.*
import kotlinx.android.synthetic.main.fragment_enter_public_key.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.loki.utilities.PublicKeyValidation
class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
private val adapter = CreatePrivateChatActivityAdapter(this)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
// Set content view
setContentView(R.layout.activity_create_private_chat)
// Set title
supportActionBar!!.title = "New Session"
// Set up view pager
viewPager.adapter = adapter
tabLayout.setupWithViewPager(viewPager)
}
// endregion
// region Interaction
override fun handleQRCodeScanned(hexEncodedPublicKey: String) {
createPrivateChatIfPossible(hexEncodedPublicKey)
}
fun createPrivateChatIfPossible(hexEncodedPublicKey: String) {
if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, "Invalid Session ID", Toast.LENGTH_SHORT).show() }
val masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(this)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this)
val targetHexEncodedPublicKey = if (hexEncodedPublicKey == masterHexEncodedPublicKey) userHexEncodedPublicKey else hexEncodedPublicKey
val recipient = Recipient.from(this, Address.fromSerialized(targetHexEncodedPublicKey), true)
val intent = Intent(this, ConversationActivity::class.java)
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address)
intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA))
intent.setDataAndType(getIntent().data, getIntent().type)
val existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient)
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, existingThread)
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT)
startActivity(intent)
finish()
}
// endregion
}
// region Adapter
private class CreatePrivateChatActivityAdapter(val activity: CreatePrivateChatActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> EnterPublicKeyFragment()
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = "Scan a users QR code to start a session. QR codes can be found by tapping the QR code icon in account settings."
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence? {
return when (index) {
0 -> "Enter Session ID"
1 -> "Scan QR Code"
else -> throw IllegalStateException()
}
}
}
// endregion
// region Enter Public Key Fragment
class EnterPublicKeyFragment : Fragment() {
private val hexEncodedPublicKey: String
get() {
val masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context!!)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context!!)
return masterHexEncodedPublicKey ?: userHexEncodedPublicKey
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_enter_public_key, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
publicKeyTextView.imeOptions = publicKeyTextView.imeOptions or 16777216 // Always use incognito keyboard
publicKeyTextView.text = hexEncodedPublicKey
copyButton.setOnClickListener { copyPublicKey() }
shareButton.setOnClickListener { sharePublicKey() }
createPrivateChatButton.setOnClickListener { createPrivateChatIfPossible() }
}
private fun copyPublicKey() {
val clipboard = activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey)
clipboard.primaryClip = clip
Toast.makeText(context!!, R.string.activity_register_public_key_copied_message, Toast.LENGTH_SHORT).show()
}
private fun sharePublicKey() {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey)
intent.type = "text/plain"
startActivity(intent)
}
private fun createPrivateChatIfPossible() {
val hexEncodedPublicKey = publicKeyEditText.text.trim().toString()
(activity!! as CreatePrivateChatActivity).createPrivateChatIfPossible(hexEncodedPublicKey)
}
}
// endregion

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Intent
import android.os.Bundle
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_display_name_v2.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.redesign.utilities.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.loki.redesign.utilities.show
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.crypto.ProfileCipher
class DisplayNameActivity : BaseActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
setContentView(R.layout.activity_display_name_v2)
displayNameEditText.imeOptions = displayNameEditText.imeOptions or 16777216 // Always use incognito keyboard
registerButton.setOnClickListener { register() }
}
private fun register() {
val displayName = displayNameEditText.text.toString().trim()
if (displayName.isEmpty()) {
return Toast.makeText(this, "Please pick a display name", Toast.LENGTH_SHORT).show()
}
if (!displayName.matches(Regex("[a-zA-Z0-9_]+"))) {
return Toast.makeText(this, "Please pick a display name that consists of only a-z, A-Z, 0-9 and _ characters", Toast.LENGTH_SHORT).show()
}
if (displayName.toByteArray().size > ProfileCipher.NAME_PADDED_LENGTH) {
return Toast.makeText(this, "Please pick a shorter display name", Toast.LENGTH_SHORT).show()
}
val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(displayNameEditText.windowToken, 0)
TextSecurePreferences.setProfileName(this, displayName)
TextSecurePreferences.setHasSeenWelcomeScreen(this, true)
TextSecurePreferences.setPromptedPushRegistration(this, true)
val application = ApplicationContext.getInstance(this)
application.setUpStorageAPIIfNeeded()
application.setUpP2PAPI()
val publicChatAPI = ApplicationContext.getInstance(this).lokiPublicChatAPI
if (publicChatAPI != null) {
// TODO: This won't be necessary anymore when we don't auto-join the Loki Public Chat anymore
application.createDefaultPublicChatsIfNeeded()
val servers = DatabaseFactory.getLokiThreadDatabase(this).getAllPublicChatServers()
servers.forEach { publicChatAPI.setDisplayName(displayName, it) }
}
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
show(intent)
}
}

View File

@@ -0,0 +1,253 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.annotation.SuppressLint
import android.arch.lifecycle.Observer
import android.content.Intent
import android.database.Cursor
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.os.AsyncTask
import android.os.Bundle
import android.os.Handler
import android.support.design.widget.Snackbar
import android.support.v4.app.LoaderManager
import android.support.v4.content.Loader
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.support.v7.widget.helper.ItemTouchHelper
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.View
import kotlinx.android.synthetic.main.activity_home.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.loki.getColorWithID
import org.thoughtcrime.securesms.loki.redesign.utilities.push
import org.thoughtcrime.securesms.loki.redesign.utilities.show
import org.thoughtcrime.securesms.loki.redesign.views.ConversationView
import org.thoughtcrime.securesms.loki.redesign.views.SeedReminderViewDelegate
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.notifications.MessageNotifier
import org.thoughtcrime.securesms.util.TextSecurePreferences
import kotlin.math.abs
class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListener, SeedReminderViewDelegate {
private lateinit var glide: GlideRequests
private val hexEncodedPublicKey: String
get() {
val masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(this)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this)
return masterHexEncodedPublicKey ?: userHexEncodedPublicKey
}
// region Lifecycle
constructor() : super()
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
// Process any outstanding deletes
val threadDatabase = DatabaseFactory.getThreadDatabase(this)
val archivedConversationCount = threadDatabase.archivedConversationListCount
if (archivedConversationCount > 0) {
val archivedConversations = threadDatabase.archivedConversationList
archivedConversations.moveToFirst()
fun deleteThreadAtCurrentPosition() {
val threadID = archivedConversations.getLong(archivedConversations.getColumnIndex(ThreadDatabase.ID))
AsyncTask.execute {
threadDatabase.deleteConversation(threadID)
MessageNotifier.updateNotification(this)
}
}
deleteThreadAtCurrentPosition()
while (archivedConversations.moveToNext()) {
deleteThreadAtCurrentPosition()
}
}
// Set content view
setContentView(R.layout.activity_home)
// Set custom toolbar
setSupportActionBar(toolbar)
// Set up Glide
glide = GlideApp.with(this)
// Set up toolbar buttons
profileButton.glide = glide
profileButton.hexEncodedPublicKey = hexEncodedPublicKey
profileButton.update()
profileButton.setOnClickListener { openSettings() }
joinPublicChatButton.setOnClickListener { joinPublicChat() }
// Set up seed reminder view
val isMasterDevice = (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == null)
val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this)
if (!hasViewedSeed && isMasterDevice) {
val seedReminderViewTitle = SpannableString("You're almost finished! 80%")
seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
seedReminderView.title = seedReminderViewTitle
seedReminderView.subtitle = "Secure your account by saving your recovery phrase"
seedReminderView.setProgress(80, false)
seedReminderView.delegate = this
} else {
seedReminderView.visibility = View.GONE
}
// Set up recycler view
val cursor = DatabaseFactory.getThreadDatabase(this).conversationList
val homeAdapter = HomeAdapter(this, cursor)
homeAdapter.glide = glide
homeAdapter.conversationClickListener = this
recyclerView.adapter = homeAdapter
recyclerView.layoutManager = LinearLayoutManager(this)
ItemTouchHelper(SwipeCallback(this)).attachToRecyclerView(recyclerView)
// This is a workaround for the fact that CursorRecyclerViewAdapter doesn't actually auto-update (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, object : LoaderManager.LoaderCallbacks<Cursor> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return HomeLoader(this@HomeActivity)
}
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
homeAdapter.changeCursor(cursor)
}
override fun onLoaderReset(cursor: Loader<Cursor>) {
homeAdapter.changeCursor(null)
}
})
// Set up new conversation button
newConversationButton.setOnClickListener { createPrivateChat() }
// Set up typing observer
ApplicationContext.getInstance(this).typingStatusRepository.typingThreads.observe(this, Observer<Set<Long>> { threadIDs ->
val adapter = recyclerView.adapter as HomeAdapter
adapter.typingThreadIDs = threadIDs ?: setOf()
})
// Set up public chats and RSS feeds if needed
if (TextSecurePreferences.getLocalNumber(this) != null) {
val application = ApplicationContext.getInstance(this)
application.createDefaultPublicChatsIfNeeded()
application.createRSSFeedsIfNeeded()
application.lokiPublicChatManager.startPollersIfNeeded()
application.startRSSFeedPollersIfNeeded()
}
}
override fun onResume() {
super.onResume()
val isMasterDevice = (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == null)
val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this)
if (hasViewedSeed || !isMasterDevice) {
seedReminderView.visibility = View.GONE
}
}
// endregion
override fun handleSeedReminderViewContinueButtonTapped() {
val intent = Intent(this, SeedActivity::class.java)
show(intent)
}
override fun onConversationClick(view: ConversationView) {
val thread = view.thread ?: return
openConversation(thread)
}
override fun onLongConversationClick(view: ConversationView) {
// Do nothing
}
private fun openConversation(thread: ThreadRecord) {
val intent = Intent(this, ConversationActivity::class.java)
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, thread.recipient.getAddress())
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, thread.threadId)
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, thread.distributionType)
intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis())
intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, thread.lastSeen)
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1)
push(intent)
}
private fun openSettings() {
val intent = Intent(this, SettingsActivity::class.java)
show(intent)
}
private fun createPrivateChat() {
val intent = Intent(this, CreatePrivateChatActivity::class.java)
show(intent)
}
private fun joinPublicChat() {
val intent = Intent(this, JoinPublicChatActivity::class.java)
show(intent)
}
private class SwipeCallback(val activity: HomeActivity) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
return false
}
@SuppressLint("StaticFieldLeak")
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val threadID = (viewHolder as HomeAdapter.ViewHolder).view.thread!!.threadId
val threadDatabase = DatabaseFactory.getThreadDatabase(activity)
threadDatabase.archiveConversation(threadID)
val deleteThread = object : Runnable {
override fun run() {
AsyncTask.execute {
val publicChat = DatabaseFactory.getLokiThreadDatabase(activity).getPublicChat(threadID)
if (publicChat != null) {
val apiDatabase = DatabaseFactory.getLokiAPIDatabase(activity)
apiDatabase.removeLastMessageServerID(publicChat.channel, publicChat.server)
apiDatabase.removeLastDeletionServerID(publicChat.channel, publicChat.server)
ApplicationContext.getInstance(activity).lokiPublicChatAPI!!.leave(publicChat.channel, publicChat.server)
}
threadDatabase.deleteConversation(threadID)
MessageNotifier.updateNotification(activity)
}
}
}
val handler = Handler()
handler.postDelayed(deleteThread, 5000)
val snackbar = Snackbar.make(activity.contentView, "Conversation Deleted", Snackbar.LENGTH_LONG)
snackbar.setAction("Undo") {
threadDatabase.unarchiveConversation(threadID)
handler.removeCallbacks(deleteThread)
animate(viewHolder, 0.0f)
}
snackbar.setActionTextColor(activity.resources.getColorWithID(R.color.accent, activity.theme))
snackbar.show()
}
override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dx: Float, dy: Float, actionState: Int, isCurrentlyActive: Boolean) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && dx < 0) {
val itemView = viewHolder.itemView
animate(viewHolder, dx)
val backgroundPaint = Paint()
backgroundPaint.color = activity.resources.getColorWithID(R.color.destructive, activity.theme)
c.drawRect(itemView.right.toFloat() - abs(dx), itemView.top.toFloat(), itemView.right.toFloat(), itemView.bottom.toFloat(), backgroundPaint)
val icon = BitmapFactory.decodeResource(activity.resources, R.drawable.ic_trash_filled_32)
val iconPaint = Paint()
val left = itemView.right.toFloat() - abs(dx) + activity.resources.getDimension(R.dimen.medium_spacing)
val top = itemView.top.toFloat() + (itemView.bottom.toFloat() - itemView.top.toFloat() - icon.height) / 2
c.drawBitmap(icon, left, top, iconPaint)
} else {
super.onChildDraw(c, recyclerView, viewHolder, dx, dy, actionState, isCurrentlyActive)
}
}
private fun animate(viewHolder: RecyclerView.ViewHolder, dx: Float) {
val alpha = 1.0f - abs(dx) / viewHolder.itemView.width.toFloat()
viewHolder.itemView.alpha = alpha
viewHolder.itemView.translationX = dx
}
}
// endregion
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Context
import android.database.Cursor
import android.support.v7.widget.RecyclerView
import android.view.ViewGroup
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.loki.redesign.views.ConversationView
import org.thoughtcrime.securesms.mms.GlideRequests
class HomeAdapter(context: Context, cursor: Cursor) : CursorRecyclerViewAdapter<HomeAdapter.ViewHolder>(context, cursor) {
private val threadDatabase = DatabaseFactory.getThreadDatabase(context)
lateinit var glide: GlideRequests
var typingThreadIDs = setOf<Long>()
set(value) { field = value; notifyDataSetChanged() }
var conversationClickListener: ConversationClickListener? = null
class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
override fun onCreateItemViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = ConversationView(context)
view.setOnClickListener { conversationClickListener?.onConversationClick(view) }
view.setOnLongClickListener {
conversationClickListener?.onLongConversationClick(view)
true
}
return ViewHolder(view)
}
override fun onBindItemViewHolder(viewHolder: ViewHolder, cursor: Cursor) {
val thread = getThread(cursor)!!
val isTyping = typingThreadIDs.contains(thread.threadId)
viewHolder.view.bind(thread, isTyping, glide)
}
private fun getThread(cursor: Cursor): ThreadRecord? {
return threadDatabase.readerFor(cursor).current
}
}
interface ConversationClickListener {
fun onConversationClick(view: ConversationView)
fun onLongConversationClick(view: ConversationView)
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Context
import android.database.Cursor
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.util.AbstractCursorLoader
class HomeLoader(context: Context) : AbstractCursorLoader(context) {
override fun getCursor(): Cursor {
return DatabaseFactory.getThreadDatabase(context).conversationList
}
}

View File

@@ -0,0 +1,140 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentPagerAdapter
import android.util.Patterns
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_join_public_chat.*
import kotlinx.android.synthetic.main.fragment_enter_chat_url.*
import network.loki.messenger.R
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.TextSecurePreferences
class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
private val adapter = JoinPublicChatActivityAdapter(this)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
// Set content view
setContentView(R.layout.activity_join_public_chat)
// Set title
supportActionBar!!.title = "Join Open Group"
// Set up view pager
viewPager.adapter = adapter
tabLayout.setupWithViewPager(viewPager)
}
// endregion
// region Updating
private fun showLoader() {
loader.visibility = View.VISIBLE
loader.animate().setDuration(150).alpha(1.0f).start()
}
private fun hideLoader() {
loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
loader.visibility = View.GONE
}
})
}
// endregion
// region Interaction
override fun handleQRCodeScanned(url: String) {
joinPublicChatIfPossible(url)
}
fun joinPublicChatIfPossible(url: String) {
if (!Patterns.WEB_URL.matcher(url).matches() || !url.startsWith("https://")) {
return Toast.makeText(this, "Invalid URL", Toast.LENGTH_SHORT).show()
}
showLoader()
val application = ApplicationContext.getInstance(this)
val channel: Long = 1
val displayName = TextSecurePreferences.getProfileName(this)
val lokiPublicChatAPI = application.lokiPublicChatAPI!!
application.lokiPublicChatManager.addChat(url, channel).successUi {
lokiPublicChatAPI.getMessages(channel, url)
lokiPublicChatAPI.setDisplayName(displayName, url)
lokiPublicChatAPI.join(channel, url)
val profileKey: ByteArray = ProfileKeyUtil.getProfileKey(this)
val profileUrl: String? = TextSecurePreferences.getProfileAvatarUrl(this)
lokiPublicChatAPI.setProfilePicture(url, profileKey, profileUrl)
finish()
}.failUi {
hideLoader()
Toast.makeText(this, "Couldn't join channel", Toast.LENGTH_SHORT).show()
}
}
// endregion
}
// region Adapter
private class JoinPublicChatActivityAdapter(val activity: JoinPublicChatActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> EnterChatURLFragment()
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = "Scan the QR code of the open group you'd like to join"
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence? {
return when (index) {
0 -> "Open Group URL"
1 -> "Scan QR Code"
else -> throw IllegalStateException()
}
}
}
// endregion
// region Enter Chat URL Fragment
class EnterChatURLFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_enter_chat_url, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
chatURLEditText.imeOptions = chatURLEditText.imeOptions or 16777216 // Always use incognito keyboard
joinPublicChatButton.setOnClickListener { joinPublicChatIfPossible() }
}
private fun joinPublicChatIfPossible() {
val inputMethodManager = context!!.getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(chatURLEditText.windowToken, 0)
val chatURL = chatURLEditText.text.trim().toString().toLowerCase().replace("http://", "https://")
(activity!! as JoinPublicChatActivity).joinPublicChatIfPossible(chatURL)
}
}
// endregion

View File

@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Intent
import android.os.AsyncTask
import android.os.Bundle
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_landing.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.logging.Log
import org.thoughtcrime.securesms.loki.redesign.dialogs.LinkDeviceSlaveModeDialog
import org.thoughtcrime.securesms.loki.redesign.dialogs.LinkDeviceSlaveModeDialogDelegate
import org.thoughtcrime.securesms.loki.redesign.utilities.push
import org.thoughtcrime.securesms.loki.redesign.utilities.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.loki.redesign.utilities.show
import org.thoughtcrime.securesms.loki.sendPairingAuthorisationMessage
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.Hex
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.curve25519.Curve25519
import org.whispersystems.libsignal.ecc.Curve
import org.whispersystems.libsignal.ecc.ECKeyPair
import org.whispersystems.libsignal.util.KeyHelper
import org.whispersystems.signalservice.loki.api.PairingAuthorisation
import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey
import org.whispersystems.signalservice.loki.utilities.retryIfNeeded
class LandingActivity : BaseActionBarActivity(), LinkDeviceSlaveModeDialogDelegate {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_landing)
setUpActionBarSessionLogo()
fakeChatView.startAnimating()
registerButton.setOnClickListener { register() }
restoreButton.setOnClickListener { restore() }
linkButton.setOnClickListener { linkDevice() }
if (TextSecurePreferences.databaseResetFromUnpair(this)) {
Toast.makeText(this, "Your device was unlinked successfully", Toast.LENGTH_LONG).show()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
if (resultCode != RESULT_OK) { return }
val hexEncodedPublicKey = data!!.getStringExtra("hexEncodedPublicKey")
requestDeviceLink(hexEncodedPublicKey)
}
private fun register() {
val intent = Intent(this, RegisterActivity::class.java)
push(intent)
}
private fun restore() {
val intent = Intent(this, RestoreActivity::class.java)
push(intent)
}
private fun linkDevice() {
val intent = Intent(this, LinkDeviceActivity::class.java)
show(intent, true)
}
private fun requestDeviceLink(hexEncodedPublicKey: String) {
var seed: ByteArray? = null
var keyPair: ECKeyPair? = null
fun generateKeyPair() {
val seedCandidate = Curve25519.getInstance(Curve25519.BEST).generateSeed(16)
try {
keyPair = Curve.generateKeyPair(seedCandidate + seedCandidate) // Validate the seed
} catch (exception: Exception) {
return generateKeyPair()
}
seed = seedCandidate
}
generateKeyPair()
IdentityKeyUtil.save(this, IdentityKeyUtil.lokiSeedKey, Hex.toStringCondensed(seed))
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(keyPair!!.publicKey.serialize()))
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(keyPair!!.privateKey.serialize()))
val userHexEncodedPublicKey = keyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
DatabaseFactory.getIdentityDatabase(this).saveIdentity(Address.fromSerialized(userHexEncodedPublicKey),
IdentityKeyUtil.getIdentityKeyPair(this).publicKey, IdentityDatabase.VerifiedStatus.VERIFIED,
true, System.currentTimeMillis(), true)
TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey)
TextSecurePreferences.setHasSeenWelcomeScreen(this, true)
TextSecurePreferences.setPromptedPushRegistration(this, true)
val authorisation = PairingAuthorisation(hexEncodedPublicKey, userHexEncodedPublicKey).sign(PairingAuthorisation.Type.REQUEST, keyPair!!.privateKey.serialize())
if (authorisation == null) {
Log.d("Loki", "Failed to sign device link request.")
reset()
return Toast.makeText(application, "Couldn't link device.", Toast.LENGTH_SHORT).show()
}
val application = ApplicationContext.getInstance(this)
application.startLongPollingIfNeeded()
application.setUpP2PAPI()
application.setUpStorageAPIIfNeeded()
val linkDeviceDialog = LinkDeviceSlaveModeDialog()
linkDeviceDialog.delegate = this
linkDeviceDialog.show(supportFragmentManager, "Link Device Dialog")
AsyncTask.execute {
retryIfNeeded(8) {
sendPairingAuthorisationMessage(this@LandingActivity, authorisation.primaryDevicePublicKey, authorisation)
}
}
}
override fun onDeviceLinkRequestAuthorized(authorization: PairingAuthorisation) {
TextSecurePreferences.setMasterHexEncodedPublicKey(this, authorization.primaryDevicePublicKey)
val intent = Intent(this, HomeActivity::class.java)
show(intent)
finish()
}
override fun onDeviceLinkCanceled() {
reset()
}
private fun reset() {
IdentityKeyUtil.delete(this, IdentityKeyUtil.lokiSeedKey)
TextSecurePreferences.removeLocalNumber(this)
TextSecurePreferences.setHasSeenWelcomeScreen(this, false)
TextSecurePreferences.setPromptedPushRegistration(this, false)
}
}

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Intent
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentPagerAdapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_link_device.*
import kotlinx.android.synthetic.main.fragment_enter_session_id.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragmentDelegate
import org.whispersystems.signalservice.loki.utilities.PublicKeyValidation
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
private val adapter = LinkDeviceActivityAdapter(this)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Set content view
setContentView(R.layout.activity_link_device)
// Set title
supportActionBar!!.title = "Link Device"
// Set up view pager
viewPager.adapter = adapter
tabLayout.setupWithViewPager(viewPager)
}
// endregion
// region Interaction
override fun handleQRCodeScanned(hexEncodedPublicKey: String) {
requestDeviceLinkIfPossible(hexEncodedPublicKey)
}
fun requestDeviceLinkIfPossible(hexEncodedPublicKey: String) {
if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) {
Toast.makeText(this, "Invalid Session ID", Toast.LENGTH_SHORT).show()
} else {
val intent = Intent()
intent.putExtra("hexEncodedPublicKey", hexEncodedPublicKey)
setResult(RESULT_OK, intent)
finish()
}
}
// endregion
}
// region Adapter
private class LinkDeviceActivityAdapter(val activity: LinkDeviceActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> EnterSessionIDFragment()
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = "Link to your existing account by going into your in-app settings and clicking \"Linked Devices\""
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence? {
return when (index) {
0 -> "Enter Session ID"
1 -> "Scan QR Code"
else -> throw IllegalStateException()
}
}
}
// endregion
// region Enter Session ID Fragment
class EnterSessionIDFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_enter_session_id, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sessionIDEditText.imeOptions = sessionIDEditText.imeOptions or 16777216 // Always use incognito keyboard
requestDeviceLinkButton.setOnClickListener { requestDeviceLinkIfPossible() }
}
private fun requestDeviceLinkIfPossible() {
val inputMethodManager = context!!.getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(sessionIDEditText.windowToken, 0)
val hexEncodedPublicKey = sessionIDEditText.text.trim().toString().toLowerCase()
(activity!! as LinkDeviceActivity).requestDeviceLinkIfPossible(hexEncodedPublicKey)
}
}
// endregion

View File

@@ -0,0 +1,146 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.os.AsyncTask
import android.os.Bundle
import android.support.v4.app.LoaderManager
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import android.support.v7.widget.LinearLayoutManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_linked_devices.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.devicelist.Device
import org.thoughtcrime.securesms.loki.DeviceListBottomSheetFragment
import org.thoughtcrime.securesms.loki.redesign.dialogs.EditDeviceNameDialog
import org.thoughtcrime.securesms.loki.redesign.dialogs.EditDeviceNameDialogDelegate
import org.thoughtcrime.securesms.loki.redesign.dialogs.LinkDeviceMasterModeDialog
import org.thoughtcrime.securesms.loki.redesign.dialogs.LinkDeviceMasterModeDialogDelegate
import org.thoughtcrime.securesms.loki.signAndSendPairingAuthorisationMessage
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.loki.api.LokiStorageAPI
import org.whispersystems.signalservice.loki.api.PairingAuthorisation
class LinkedDevicesActivity : PassphraseRequiredActionBarActivity, LoaderManager.LoaderCallbacks<List<Device>>, DeviceClickListener, EditDeviceNameDialogDelegate, LinkDeviceMasterModeDialogDelegate {
private var devices = listOf<Device>()
set(value) { field = value; linkedDevicesAdapter.devices = value }
private val linkedDevicesAdapter by lazy {
val result = LinkedDevicesAdapter(this)
result.deviceClickListener = this
result
}
// region Lifecycle
constructor() : super()
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_linked_devices)
supportActionBar!!.title = "Devices"
recyclerView.adapter = linkedDevicesAdapter
recyclerView.layoutManager = LinearLayoutManager(this)
linkDeviceButton.setOnClickListener { linkDevice() }
LoaderManager.getInstance(this).initLoader(0, null, this)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_linked_devices, menu)
return true
}
// endregion
// region Updating
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<List<Device>> {
return LinkedDevicesLoader(this)
}
override fun onLoadFinished(loader: Loader<List<Device>>, devices: List<Device>?) {
update(devices ?: listOf())
}
override fun onLoaderReset(loader: Loader<List<Device>>) {
update(listOf())
}
private fun update(devices: List<Device>) {
this.devices = devices
emptyStateContainer.visibility = if (devices.isEmpty()) View.VISIBLE else View.GONE
}
override fun handleDeviceNameChanged(device: Device) {
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
// endregion
// region Interaction
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val id = item.itemId
when(id) {
R.id.linkDeviceButton -> linkDevice()
else -> { /* Do nothing */ }
}
return super.onOptionsItemSelected(item)
}
private fun linkDevice() {
if (devices.isEmpty()) {
val linkDeviceDialog = LinkDeviceMasterModeDialog()
linkDeviceDialog.delegate = this
linkDeviceDialog.show(supportFragmentManager, "Link Device Dialog")
} else {
val builder = AlertDialog.Builder(this)
builder.setTitle("Multi Device Limit Reached")
builder.setMessage("It's currently not allowed to link more than one device.")
builder.setPositiveButton("OK", { dialog, _ -> dialog.dismiss() })
builder.create().show()
}
}
override fun onDeviceClick(device: Device) {
val bottomSheet = DeviceListBottomSheetFragment()
bottomSheet.onEditTapped = {
bottomSheet.dismiss()
val editDeviceNameDialog = EditDeviceNameDialog()
editDeviceNameDialog.device = device
editDeviceNameDialog.delegate = this
editDeviceNameDialog.show(supportFragmentManager, "Edit Device Name Dialog")
}
bottomSheet.onUnlinkTapped = {
bottomSheet.dismiss()
unlinkDevice(device.id)
}
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
}
private fun unlinkDevice(slaveDeviceHexEncodedPublicKey: String) {
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this)
val database = DatabaseFactory.getLokiAPIDatabase(this)
database.removePairingAuthorisation(userHexEncodedPublicKey, slaveDeviceHexEncodedPublicKey)
LokiStorageAPI.shared.updateUserDeviceMappings().success {
MessageSender.sendUnpairRequest(this, slaveDeviceHexEncodedPublicKey)
}
LoaderManager.getInstance(this).restartLoader(0, null, this)
Toast.makeText(this, "Your device was unlinked successfully", Toast.LENGTH_LONG).show()
}
override fun onDeviceLinkRequestAuthorized(authorization: PairingAuthorisation) {
AsyncTask.execute {
signAndSendPairingAuthorisationMessage(this, authorization)
Util.runOnMain {
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
}
}
override fun onDeviceLinkCanceled() {
// Do nothing
}
// endregion
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Context
import android.support.v7.widget.RecyclerView
import android.view.ViewGroup
import org.thoughtcrime.securesms.devicelist.Device
import org.thoughtcrime.securesms.loki.redesign.views.DeviceView
class LinkedDevicesAdapter(private val context: Context) : RecyclerView.Adapter<LinkedDevicesAdapter.ViewHolder>() {
var devices = listOf<Device>()
set(value) { field = value; notifyDataSetChanged() }
var deviceClickListener: DeviceClickListener? = null
class ViewHolder(val view: DeviceView) : RecyclerView.ViewHolder(view)
override fun getItemCount(): Int {
return devices.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = DeviceView(context)
return ViewHolder(view)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val device = devices[position]
viewHolder.view.setOnClickListener { deviceClickListener?.onDeviceClick(device) }
viewHolder.view.bind(device)
}
}
interface DeviceClickListener {
fun onDeviceClick(device: Device)
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Context
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.devicelist.Device
import org.thoughtcrime.securesms.loki.MnemonicUtilities
import org.thoughtcrime.securesms.util.AsyncLoader
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.loki.api.LokiStorageAPI
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
import java.io.File
class LinkedDevicesLoader(context: Context) : AsyncLoader<List<Device>>(context) {
private val mnemonicCodec by lazy {
val languageFileDirectory = File(context.applicationInfo.dataDir)
MnemonicCodec(languageFileDirectory)
}
override fun loadInBackground(): List<Device>? {
try {
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context)
val slaveDeviceHexEncodedPublicKeys = LokiStorageAPI.shared.getSecondaryDevicePublicKeys(userHexEncodedPublicKey).get()
return slaveDeviceHexEncodedPublicKeys.map { hexEncodedPublicKey ->
val shortID = MnemonicUtilities.getFirst3Words(mnemonicCodec, hexEncodedPublicKey)
val name = DatabaseFactory.getLokiUserDatabase(context).getDisplayName(hexEncodedPublicKey)
Device(hexEncodedPublicKey, shortID, name)
}.sortedBy { it.name }
} catch (e: Exception) {
return null
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.os.Bundle
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment
class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_fragment_wrapper)
supportActionBar!!.title = "Notifications"
val fragment = NotificationsPreferenceFragment()
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragmentContainer, fragment)
transaction.commit()
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.os.Bundle
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment
class PrivacySettingsActivity : PassphraseRequiredActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_fragment_wrapper)
supportActionBar!!.title = "Privacy"
val fragment = AppProtectionPreferenceFragment()
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragmentContainer, fragment)
transaction.commit()
}
}

View File

@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.os.Environment
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentPagerAdapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.tbruyelle.rxpermissions2.RxPermissions
import kotlinx.android.synthetic.main.activity_qr_code.*
import kotlinx.android.synthetic.main.fragment_view_my_qr_code.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.loki.redesign.fragments.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.loki.redesign.utilities.QRCodeUtilities
import org.thoughtcrime.securesms.loki.toPx
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FileProviderUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.loki.utilities.PublicKeyValidation
import java.io.File
import java.io.FileOutputStream
class QRCodeActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
private val adapter = QRCodeActivityAdapter(this)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
// Set content view
setContentView(R.layout.activity_qr_code)
// Set title
supportActionBar!!.title = "QR Code"
// Set up view pager
viewPager.adapter = adapter
tabLayout.setupWithViewPager(viewPager)
}
// endregion
// region Interaction
override fun handleQRCodeScanned(hexEncodedPublicKey: String) {
createPrivateChatIfPossible(hexEncodedPublicKey)
}
fun createPrivateChatIfPossible(hexEncodedPublicKey: String) {
if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, "Invalid Session ID", Toast.LENGTH_SHORT).show() }
val masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(this)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this)
val targetHexEncodedPublicKey = if (hexEncodedPublicKey == masterHexEncodedPublicKey) userHexEncodedPublicKey else hexEncodedPublicKey
val recipient = Recipient.from(this, Address.fromSerialized(targetHexEncodedPublicKey), true)
val intent = Intent(this, ConversationActivity::class.java)
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address)
intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA))
intent.setDataAndType(getIntent().data, getIntent().type)
val existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient)
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, existingThread)
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT)
startActivity(intent)
finish()
}
// endregion
}
// region Adapter
private class QRCodeActivityAdapter(val activity: QRCodeActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> ViewMyQRCodeFragment()
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = "Scan someone\'s QR code to start a conversation with them"
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence? {
return when (index) {
0 -> "View My QR Code"
1 -> "Scan QR Code"
else -> throw IllegalStateException()
}
}
}
// endregion
// region View My QR Code Fragment
class ViewMyQRCodeFragment : Fragment() {
private val hexEncodedPublicKey: String
get() {
val masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context!!)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context!!)
return masterHexEncodedPublicKey ?: userHexEncodedPublicKey
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_view_my_qr_code, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val size = toPx(280, resources)
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false)
qrCodeImageView.setImageBitmap(qrCode)
// val explanation = SpannableStringBuilder("This is your unique public QR code. Other users can scan this to start a conversation with you.")
// explanation.setSpan(StyleSpan(Typeface.BOLD), 8, 34, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
explanationTextView.text = "This is your QR code. Other users can scan it to start a session with you."
shareButton.setOnClickListener { shareQRCode() }
}
private fun shareQRCode() {
fun proceed() {
val directory = File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_PICTURES)
val fileName = "$hexEncodedPublicKey.png"
val file = File(directory, fileName)
file.createNewFile()
val fos = FileOutputStream(file)
val size = toPx(280, resources)
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false)
qrCode.compress(Bitmap.CompressFormat.PNG, 100, fos)
fos.flush()
fos.close()
val intent = Intent(Intent.ACTION_SEND)
intent.putExtra(Intent.EXTRA_STREAM, FileProviderUtil.getUriFor(activity!!, file))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.type = "image/png"
startActivity(Intent.createChooser(intent, "Share QR Code"))
}
if (RxPermissions(this).isGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
proceed()
} else {
@SuppressWarnings("unused")
val unused = RxPermissions(this).request(Manifest.permission.WRITE_EXTERNAL_STORAGE).subscribe { isGranted ->
if (isGranted) {
proceed()
}
}
}
}
}
// endregion

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_register.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.loki.redesign.utilities.push
import org.thoughtcrime.securesms.loki.redesign.utilities.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.Hex
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.curve25519.Curve25519
import org.whispersystems.libsignal.ecc.Curve
import org.whispersystems.libsignal.ecc.ECKeyPair
import org.whispersystems.libsignal.util.KeyHelper
import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey
import java.io.File
import java.io.FileOutputStream
class RegisterActivity : BaseActionBarActivity() {
private var seed: ByteArray? = null
private var keyPair: ECKeyPair? = null
set(value) { field = value; updatePublicKeyTextView() }
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_register)
setUpLanguageFileDirectory()
setUpActionBarSessionLogo()
registerButton.setOnClickListener { register() }
copyButton.setOnClickListener { copyPublicKey() }
val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms and Conditions and Privacy Statement")
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 60, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 65, 82, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsButton.text = termsExplanation
termsButton.setOnClickListener { showTerms() }
updateKeyPair()
}
// endregion
// region General
private fun setUpLanguageFileDirectory() {
val languages = listOf( "english", "japanese", "portuguese", "spanish" )
val directory = File(applicationInfo.dataDir)
for (language in languages) {
val fileName = "$language.txt"
if (directory.list().contains(fileName)) { continue }
val inputStream = assets.open("mnemonic/$fileName")
val file = File(directory, fileName)
val outputStream = FileOutputStream(file)
val buffer = ByteArray(1024)
while (true) {
val count = inputStream.read(buffer)
if (count < 0) { break }
outputStream.write(buffer, 0, count)
}
inputStream.close()
outputStream.close()
}
}
// endregion
// region Updating
private fun updateKeyPair() {
val seedCandidate = Curve25519.getInstance(Curve25519.BEST).generateSeed(16)
try {
this.keyPair = Curve.generateKeyPair(seedCandidate + seedCandidate) // Validate the seed
} catch (exception: Exception) {
return updateKeyPair()
}
seed = seedCandidate
}
private fun updatePublicKeyTextView() {
val hexEncodedPublicKey = keyPair!!.hexEncodedPublicKey
val characterCount = hexEncodedPublicKey.count()
var count = 0
val limit = 32
fun animate() {
val numberOfIndexesToShuffle = 32 - count
val indexesToShuffle = (0 until characterCount).shuffled().subList(0, numberOfIndexesToShuffle)
var mangledHexEncodedPublicKey = hexEncodedPublicKey
for (index in indexesToShuffle) {
try {
mangledHexEncodedPublicKey = mangledHexEncodedPublicKey.substring(0, index) + "0123456789abcdef__".random() + mangledHexEncodedPublicKey.substring(index + 1, mangledHexEncodedPublicKey.count())
} catch (exception: Exception) {
// Do nothing
}
}
count += 1
if (count < limit) {
publicKeyTextView.text = mangledHexEncodedPublicKey
Handler().postDelayed({
animate()
}, 32)
} else {
publicKeyTextView.text = hexEncodedPublicKey
}
}
animate()
}
// endregion
// region Interaction
private fun register() {
IdentityKeyUtil.save(this, IdentityKeyUtil.lokiSeedKey, Hex.toStringCondensed(seed))
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(keyPair!!.publicKey.serialize()))
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(keyPair!!.privateKey.serialize()))
val userHexEncodedPublicKey = keyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
DatabaseFactory.getIdentityDatabase(this).saveIdentity(Address.fromSerialized(userHexEncodedPublicKey),
IdentityKeyUtil.getIdentityKeyPair(this).publicKey, IdentityDatabase.VerifiedStatus.VERIFIED,
true, System.currentTimeMillis(), true)
TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey)
TextSecurePreferences.setHasViewedSeed(this, false)
val intent = Intent(this, DisplayNameActivity::class.java)
push(intent)
}
private fun copyPublicKey() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", keyPair!!.hexEncodedPublicKey)
clipboard.primaryClip = clip
Toast.makeText(this, R.string.activity_register_public_key_copied_message, Toast.LENGTH_SHORT).show()
}
private fun showTerms() {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/loki-project/loki-messenger-android/blob/master/privacy-policy.md"))
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, "Couldn't open link", Toast.LENGTH_SHORT).show()
}
}
// endregion
}

View File

@@ -0,0 +1,108 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_restore.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.loki.redesign.utilities.push
import org.thoughtcrime.securesms.loki.redesign.utilities.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.Hex
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.libsignal.ecc.Curve
import org.whispersystems.libsignal.util.KeyHelper
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey
import java.io.File
import java.io.FileOutputStream
class RestoreActivity : BaseActionBarActivity() {
private lateinit var languageFileDirectory: File
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpLanguageFileDirectory()
setUpActionBarSessionLogo()
setContentView(R.layout.activity_restore)
mnemonicEditText.imeOptions = mnemonicEditText.imeOptions or 16777216 // Always use incognito keyboard
restoreButton.setOnClickListener { restore() }
val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms and Conditions and Privacy Statement")
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 60, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 65, 82, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
termsButton.text = termsExplanation
termsButton.setOnClickListener { showTerms() }
}
// endregion
// region General
private fun setUpLanguageFileDirectory() {
val languages = listOf( "english", "japanese", "portuguese", "spanish" )
val directory = File(applicationInfo.dataDir)
for (language in languages) {
val fileName = "$language.txt"
if (directory.list().contains(fileName)) { continue }
val inputStream = assets.open("mnemonic/$fileName")
val file = File(directory, fileName)
val outputStream = FileOutputStream(file)
val buffer = ByteArray(1024)
while (true) {
val count = inputStream.read(buffer)
if (count < 0) { break }
outputStream.write(buffer, 0, count)
}
inputStream.close()
outputStream.close()
}
languageFileDirectory = directory
}
// endregion
// region Interaction
private fun restore() {
val mnemonic = mnemonicEditText.text.toString()
try {
val hexEncodedSeed = MnemonicCodec(languageFileDirectory).decode(mnemonic)
var seed = Hex.fromStringCondensed(hexEncodedSeed)
IdentityKeyUtil.save(this, IdentityKeyUtil.lokiSeedKey, Hex.toStringCondensed(seed))
if (seed.size == 16) { seed = seed + seed }
val keyPair = Curve.generateKeyPair(seed)
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(keyPair.publicKey.serialize()))
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(keyPair.privateKey.serialize()))
val userHexEncodedPublicKey = keyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
DatabaseFactory.getIdentityDatabase(this).saveIdentity(Address.fromSerialized(userHexEncodedPublicKey),
IdentityKeyUtil.getIdentityKeyPair(this).publicKey, IdentityDatabase.VerifiedStatus.VERIFIED,
true, System.currentTimeMillis(), true)
TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey)
TextSecurePreferences.setHasViewedSeed(this, true)
val intent = Intent(this, DisplayNameActivity::class.java)
push(intent)
} catch (e: Exception) {
val message = if (e is MnemonicCodec.DecodingError) e.description else MnemonicCodec.DecodingError.Generic.description
return Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private fun showTerms() {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/loki-project/loki-messenger-android/blob/master/privacy-policy.md"))
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, "Couldn't open link", Toast.LENGTH_SHORT).show()
}
}
// endregion
}

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.widget.LinearLayout
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_seed_v2.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.loki.getColorWithID
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
import org.whispersystems.signalservice.loki.utilities.hexEncodedPrivateKey
import java.io.File
class SeedActivity : BaseActionBarActivity() {
private val seed by lazy {
val languageFileDirectory = File(applicationInfo.dataDir)
var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.lokiSeedKey)
if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account
}
MnemonicCodec(languageFileDirectory).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
}
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_seed_v2)
supportActionBar!!.title = "Your Recovery Phrase"
val seedReminderViewTitle = SpannableString("You're almost finished! 90%")
seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
seedReminderView.title = seedReminderViewTitle
seedReminderView.subtitle = "Tap and hold the redacted words to reveal your recovery phrase, then store it safely to secure your Session ID."
seedReminderView.setProgress(90, false)
seedReminderView.hideContinueButton()
var redactedSeed = seed
var index = 0
for (character in seed) {
if (character.isLetter()) {
redactedSeed = redactedSeed.replaceRange(index, index + 1, "")
}
index += 1
}
seedTextView.setTextColor(resources.getColorWithID(R.color.accent, theme))
seedTextView.text = redactedSeed
seedTextView.setOnLongClickListener { revealSeed(); true }
revealButton.setOnLongClickListener { revealSeed(); true }
copyButton.setOnClickListener { copySeed() }
}
// endregion
// region Updating
private fun revealSeed() {
val seedReminderViewTitle = SpannableString("Account secured! 100%")
seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 17, 21, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
seedReminderView.title = seedReminderViewTitle
seedReminderView.subtitle = "Make sure to store your recovery phrase in a safe place"
seedReminderView.setProgress(100, true)
val seedTextViewLayoutParams = seedTextView.layoutParams as LinearLayout.LayoutParams
seedTextViewLayoutParams.height = seedTextView.height
seedTextView.layoutParams = seedTextViewLayoutParams
seedTextView.setTextColor(resources.getColorWithID(R.color.text, theme))
seedTextView.text = seed
TextSecurePreferences.setHasViewedSeed(this, true)
}
// endregion
// region Interaction
private fun copySeed() {
revealSeed()
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Seed", seed)
clipboard.primaryClip = clip
Toast.makeText(this, R.string.activity_register_public_key_copied_message, Toast.LENGTH_SHORT).show()
}
// endregion
}

View File

@@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.loki.redesign.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_settings.*
import network.loki.messenger.R
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.ui.alwaysUi
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.redesign.dialogs.ClearAllDataDialog
import org.thoughtcrime.securesms.loki.redesign.dialogs.SeedDialog
import org.thoughtcrime.securesms.loki.redesign.utilities.push
import org.thoughtcrime.securesms.loki.toPx
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.crypto.ProfileCipher
import org.whispersystems.signalservice.api.util.StreamDetails
import org.whispersystems.signalservice.loki.api.LokiStorageAPI
import java.io.ByteArrayInputStream
import java.io.File
import java.security.SecureRandom
class SettingsActivity : PassphraseRequiredActionBarActivity() {
private lateinit var glide: GlideRequests
private var isEditingDisplayName = false
set(value) { field = value; handleIsEditingDisplayNameChanged() }
private var displayNameToBeUploaded: String? = null
private var profilePictureToBeUploaded: ByteArray? = null
private var tempFile: File? = null
private val hexEncodedPublicKey: String
get() {
val masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(this)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this)
return masterHexEncodedPublicKey ?: userHexEncodedPublicKey
}
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_settings)
setSupportActionBar(toolbar)
cancelButton.setOnClickListener { cancelEditingDisplayName() }
saveButton.setOnClickListener { saveDisplayName() }
showQRCodeButton.setOnClickListener { showQRCode() }
glide = GlideApp.with(this)
profilePictureView.glide = glide
profilePictureView.hexEncodedPublicKey = hexEncodedPublicKey
profilePictureView.isLarge = true
profilePictureView.update()
profilePictureView.setOnClickListener { showEditProfilePictureUI() }
displayNameContainer.setOnClickListener { showEditDisplayNameUI() }
displayNameTextView.text = DatabaseFactory.getLokiUserDatabase(this).getDisplayName(hexEncodedPublicKey)
publicKeyTextView.text = hexEncodedPublicKey
copyButton.setOnClickListener { copyPublicKey() }
shareButton.setOnClickListener { sharePublicKey() }
val isMasterDevice = (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == null)
if (!isMasterDevice) {
linkedDevicesButtonTopSeparator.visibility = View.GONE
linkedDevicesButton.visibility = View.GONE
seedButtonTopSeparator.visibility = View.GONE
seedButton.visibility = View.GONE
}
privacyButton.setOnClickListener { showPrivacySettings() }
notificationsButton.setOnClickListener { showNotificationSettings() }
chatsButton.setOnClickListener { showChatSettings() }
linkedDevicesButton.setOnClickListener { showLinkedDevices() }
seedButton.setOnClickListener { showSeed() }
clearAllDataButton.setOnClickListener { clearAllData() }
}
public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
AvatarSelection.REQUEST_CODE_AVATAR -> {
if (resultCode != Activity.RESULT_OK) { return }
val outputFile = Uri.fromFile(File(cacheDir, "cropped"))
var inputFile: Uri? = data?.data
if (inputFile == null && tempFile != null) {
inputFile = Uri.fromFile(tempFile)
}
AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar)
}
AvatarSelection.REQUEST_CODE_CROP_IMAGE -> {
if (resultCode != Activity.RESULT_OK) { return }
AsyncTask.execute {
try {
profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
Handler(Looper.getMainLooper()).post {
updateProfile(true)
}
} catch (e: BitmapDecodingException) {
e.printStackTrace()
}
}
}
}
}
// endregion
// region Updating
private fun handleIsEditingDisplayNameChanged() {
cancelButton.visibility = if (isEditingDisplayName) View.VISIBLE else View.GONE
showQRCodeButton.visibility = if (isEditingDisplayName) View.GONE else View.VISIBLE
saveButton.visibility = if (isEditingDisplayName) View.VISIBLE else View.GONE
displayNameTextView.visibility = if (isEditingDisplayName) View.INVISIBLE else View.VISIBLE
displayNameEditText.visibility = if (isEditingDisplayName) View.VISIBLE else View.INVISIBLE
val titleTextViewLayoutParams = titleTextView.layoutParams as LinearLayout.LayoutParams
titleTextViewLayoutParams.leftMargin = if (isEditingDisplayName) toPx(16, resources) else 0
titleTextView.layoutParams = titleTextViewLayoutParams
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (isEditingDisplayName) {
displayNameEditText.requestFocus()
inputMethodManager.showSoftInput(displayNameEditText, 0)
} else {
inputMethodManager.hideSoftInputFromWindow(displayNameEditText.windowToken, 0)
}
}
private fun updateProfile(isUpdatingProfilePicture: Boolean) {
showLoader()
val promises = mutableListOf<Promise<*, Exception>>()
val displayName = displayNameToBeUploaded
if (displayName != null) {
val publicChatAPI = ApplicationContext.getInstance(this).lokiPublicChatAPI
if (publicChatAPI != null) {
val servers = DatabaseFactory.getLokiThreadDatabase(this).getAllPublicChatServers()
promises.addAll(servers.map { publicChatAPI.setDisplayName(displayName, it) })
}
TextSecurePreferences.setProfileName(this, displayName)
}
val profilePicture = profilePictureToBeUploaded
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
val profileKey = ProfileKeyUtil.getProfileKeyFromEncodedString(encodedProfileKey)
if (isUpdatingProfilePicture && profilePicture != null) {
val storageAPI = LokiStorageAPI.shared
val deferred = deferred<Unit, Exception>()
AsyncTask.execute {
val stream = StreamDetails(ByteArrayInputStream(profilePicture), "image/jpeg", profilePicture.size.toLong())
val (_, url) = storageAPI.uploadProfilePicture(storageAPI.server, profileKey, stream)
TextSecurePreferences.setProfileAvatarUrl(this, url)
deferred.resolve(Unit)
}
promises.add(deferred.promise)
}
all(promises).alwaysUi {
if (displayName != null) {
displayNameTextView.text = displayName
}
displayNameToBeUploaded = null
if (isUpdatingProfilePicture && profilePicture != null) {
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, SecureRandom().nextInt())
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
ApplicationContext.getInstance(this).updatePublicChatProfileAvatarIfNeeded()
profilePictureView.update()
}
profilePictureToBeUploaded = null
hideLoader()
}
}
private fun showLoader() {
loader.visibility = View.VISIBLE
loader.animate().setDuration(150).alpha(1.0f).start()
}
private fun hideLoader() {
loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
loader.visibility = View.GONE
}
})
}
// endregion
// region Interaction
private fun cancelEditingDisplayName() {
isEditingDisplayName = false
}
private fun saveDisplayName() {
val displayName = displayNameEditText.text.toString().trim()
if (displayName.isEmpty()) {
return Toast.makeText(this, "Please pick a display name", Toast.LENGTH_SHORT).show()
}
if (!displayName.matches(Regex("[a-zA-Z0-9_]+"))) {
return Toast.makeText(this, "Please pick a display name that consists of only a-z, A-Z, 0-9 and _ characters", Toast.LENGTH_SHORT).show()
}
if (displayName.toByteArray().size > ProfileCipher.NAME_PADDED_LENGTH) {
return Toast.makeText(this, "Please pick a shorter display name", Toast.LENGTH_SHORT).show()
}
isEditingDisplayName = false
displayNameToBeUploaded = displayName
updateProfile(false)
}
private fun showQRCode() {
val intent = Intent(this, QRCodeActivity::class.java)
push(intent)
}
private fun showEditProfilePictureUI() {
tempFile = AvatarSelection.startAvatarSelection(this, false, true)
}
private fun showEditDisplayNameUI() {
isEditingDisplayName = true
}
private fun copyPublicKey() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey)
clipboard.primaryClip = clip
Toast.makeText(this, R.string.activity_register_public_key_copied_message, Toast.LENGTH_SHORT).show()
}
private fun sharePublicKey() {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey)
intent.type = "text/plain"
startActivity(intent)
}
private fun showPrivacySettings() {
val intent = Intent(this, PrivacySettingsActivity::class.java)
push(intent)
}
private fun showNotificationSettings() {
val intent = Intent(this, NotificationSettingsActivity::class.java)
push(intent)
}
private fun showChatSettings() {
val intent = Intent(this, ChatSettingsActivity::class.java)
push(intent)
}
private fun showLinkedDevices() {
val intent = Intent(this, LinkedDevicesActivity::class.java)
push(intent)
}
private fun showSeed() {
SeedDialog().show(supportFragmentManager, "Recovery Phrase Dialog")
}
private fun clearAllData() {
ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog")
}
// endregion
}

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.loki.redesign.dialogs
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import android.view.LayoutInflater
import kotlinx.android.synthetic.main.dialog_clear_all_data.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
class ClearAllDataDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(context!!)
val contentView = LayoutInflater.from(context!!).inflate(R.layout.dialog_clear_all_data, null)
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.clearAllDataButton.setOnClickListener { clearAllData() }
builder.setView(contentView)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return result
}
private fun clearAllData() {
ApplicationContext.getInstance(context).clearData()
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.loki.redesign.dialogs
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import android.view.LayoutInflater
import android.view.View
import kotlinx.android.synthetic.main.dialog_edit_device_name.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.devicelist.Device
class EditDeviceNameDialog : DialogFragment() {
private lateinit var contentView: View
var device: Device? = null
var delegate: EditDeviceNameDialogDelegate? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(context!!)
contentView = LayoutInflater.from(context!!).inflate(R.layout.dialog_edit_device_name, null)
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.okButton.setOnClickListener { updateDeviceName() }
builder.setView(contentView)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return result
}
private fun updateDeviceName() {
DatabaseFactory.getLokiUserDatabase(context).setDisplayName(device!!.id, contentView.deviceNameEditText.text.toString())
delegate?.handleDeviceNameChanged(device!!)
dismiss()
}
}
interface EditDeviceNameDialogDelegate {
fun handleDeviceNameChanged(device: Device)
}

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.loki.redesign.dialogs
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.dialog_link_device_master_mode.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.MnemonicUtilities
import org.thoughtcrime.securesms.loki.redesign.utilities.QRCodeUtilities
import org.thoughtcrime.securesms.loki.toPx
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.loki.api.DeviceLinkingSession
import org.whispersystems.signalservice.loki.api.DeviceLinkingSessionListener
import org.whispersystems.signalservice.loki.api.PairingAuthorisation
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
class LinkDeviceMasterModeDialog : DialogFragment(), DeviceLinkingSessionListener {
private val languageFileDirectory by lazy { MnemonicUtilities.getLanguageFileDirectory(context!!) }
private lateinit var contentView: View
private var authorization: PairingAuthorisation? = null
var delegate: LinkDeviceMasterModeDialogDelegate? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(context!!)
contentView = LayoutInflater.from(context!!).inflate(R.layout.dialog_link_device_master_mode, null)
val size = toPx(128, resources)
val hexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context!!)
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false)
contentView.qrCodeImageView.setImageBitmap(qrCode)
contentView.cancelButton.setOnClickListener { onDeviceLinkCanceled() }
contentView.authorizeButton.setOnClickListener { authorizeDeviceLink() }
builder.setView(contentView)
DeviceLinkingSession.shared.startListeningForLinkingRequests() // FIXME: This flag is named poorly as it's actually also used for authorizations
DeviceLinkingSession.shared.addListener(this)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return result
}
override fun requestUserAuthorization(authorization: PairingAuthorisation) {
if (authorization.type != PairingAuthorisation.Type.REQUEST || authorization.primaryDevicePublicKey != TextSecurePreferences.getLocalNumber(context!!) || this.authorization != null) { return }
Util.runOnMain {
this.authorization = authorization
contentView.qrCodeImageView.visibility = View.GONE
val titleTextViewLayoutParams = contentView.titleTextView.layoutParams as LinearLayout.LayoutParams
titleTextViewLayoutParams.topMargin = toPx(8, resources)
contentView.titleTextView.layoutParams = titleTextViewLayoutParams
contentView.titleTextView.text = "Linking Request Received"
contentView.explanationTextView.text = "Please check that the words below match those shown on your other device"
contentView.mnemonicTextView.visibility = View.VISIBLE
contentView.mnemonicTextView.text = MnemonicUtilities.getFirst3Words(MnemonicCodec(languageFileDirectory), authorization.secondaryDevicePublicKey)
contentView.authorizeButton.visibility = View.VISIBLE
}
}
private fun authorizeDeviceLink() {
val authorization = this.authorization ?: return
delegate?.onDeviceLinkRequestAuthorized(authorization)
DeviceLinkingSession.shared.stopListeningForLinkingRequests()
DeviceLinkingSession.shared.removeListener(this)
dismiss()
}
private fun onDeviceLinkCanceled() {
DeviceLinkingSession.shared.stopListeningForLinkingRequests()
DeviceLinkingSession.shared.removeListener(this)
if (authorization != null) {
DatabaseFactory.getLokiPreKeyBundleDatabase(context).removePreKeyBundle(authorization!!.secondaryDevicePublicKey)
}
dismiss()
delegate?.onDeviceLinkCanceled()
}
}
interface LinkDeviceMasterModeDialogDelegate {
fun onDeviceLinkRequestAuthorized(authorization: PairingAuthorisation)
fun onDeviceLinkCanceled()
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.loki.redesign.dialogs
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.os.Handler
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.dialog_link_device_slave_mode.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.loki.MnemonicUtilities
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.loki.api.DeviceLinkingSession
import org.whispersystems.signalservice.loki.api.DeviceLinkingSessionListener
import org.whispersystems.signalservice.loki.api.PairingAuthorisation
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
class LinkDeviceSlaveModeDialog : DialogFragment(), DeviceLinkingSessionListener {
private val languageFileDirectory by lazy { MnemonicUtilities.getLanguageFileDirectory(context!!) }
private lateinit var contentView: View
private var authorization: PairingAuthorisation? = null
var delegate: LinkDeviceSlaveModeDialogDelegate? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(context!!)
contentView = LayoutInflater.from(context!!).inflate(R.layout.dialog_link_device_slave_mode, null)
val hexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context)
contentView.mnemonicTextView.text = MnemonicUtilities.getFirst3Words(MnemonicCodec(languageFileDirectory), hexEncodedPublicKey)
contentView.cancelButton.setOnClickListener { onDeviceLinkCanceled() }
builder.setView(contentView)
DeviceLinkingSession.shared.startListeningForLinkingRequests()
DeviceLinkingSession.shared.addListener(this)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return result
}
override fun onDeviceLinkRequestAuthorized(authorization: PairingAuthorisation) {
if (authorization.type != PairingAuthorisation.Type.GRANT || authorization.secondaryDevicePublicKey != TextSecurePreferences.getLocalNumber(context!!) || this.authorization != null) { return }
Util.runOnMain {
this.authorization = authorization
DeviceLinkingSession.shared.stopListeningForLinkingRequests()
DeviceLinkingSession.shared.removeListener(this)
contentView.spinner.visibility = View.GONE
val titleTextViewLayoutParams = contentView.titleTextView.layoutParams as LinearLayout.LayoutParams
titleTextViewLayoutParams.topMargin = 0
contentView.titleTextView.layoutParams = titleTextViewLayoutParams
contentView.titleTextView.text = "Device Link Authorized"
contentView.explanationTextView.text = "Your device has been linked successfully"
contentView.mnemonicTextView.visibility = View.GONE
contentView.cancelButton.visibility = View.GONE
Handler().postDelayed({
dismiss()
delegate?.onDeviceLinkRequestAuthorized(authorization)
}, 4000)
}
}
private fun onDeviceLinkCanceled() {
DeviceLinkingSession.shared.stopListeningForLinkingRequests()
DeviceLinkingSession.shared.removeListener(this)
dismiss()
delegate?.onDeviceLinkCanceled()
}
}
interface LinkDeviceSlaveModeDialogDelegate {
fun onDeviceLinkRequestAuthorized(authorization: PairingAuthorisation)
fun onDeviceLinkCanceled()
}

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.loki.redesign.dialogs
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import android.view.LayoutInflater
import android.widget.Toast
import kotlinx.android.synthetic.main.dialog_seed.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
import org.whispersystems.signalservice.loki.utilities.hexEncodedPrivateKey
import java.io.File
class SeedDialog : DialogFragment() {
private val seed by lazy {
val languageFileDirectory = File(context!!.applicationInfo.dataDir)
var hexEncodedSeed = IdentityKeyUtil.retrieve(context!!, IdentityKeyUtil.lokiSeedKey)
if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(context!!).hexEncodedPrivateKey // Legacy account
}
MnemonicCodec(languageFileDirectory).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(context!!)
val contentView = LayoutInflater.from(context!!).inflate(R.layout.dialog_seed, null)
contentView.seedTextView.text = seed
contentView.cancelButton.setOnClickListener { dismiss() }
contentView.copyButton.setOnClickListener { copySeed() }
builder.setView(contentView)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return result
}
private fun copySeed() {
val clipboard = activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Seed", seed)
clipboard.primaryClip = clip
Toast.makeText(context!!, R.string.activity_register_public_key_copied_message, Toast.LENGTH_SHORT).show()
dismiss()
}
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.loki.redesign.fragments
import android.content.res.Configuration
import android.os.Bundle
import android.support.v4.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.fragment_scan_qr_code_v2.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.qr.ScanListener
import org.thoughtcrime.securesms.qr.ScanningThread
class ScanQRCodeFragmentV2 : Fragment() {
private val scanningThread = ScanningThread()
var scanListener: ScanListener? = null
set(value) { field = value; scanningThread.setScanListener(scanListener) }
var message: CharSequence = ""
override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? {
return layoutInflater.inflate(R.layout.fragment_scan_qr_code_v2, viewGroup, false)
}
override fun onViewCreated(view: View, bundle: Bundle?) {
super.onViewCreated(view, bundle)
when (resources.configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> overlayView.orientation = LinearLayout.HORIZONTAL
else -> overlayView.orientation = LinearLayout.VERTICAL
}
messageTextView.text = message
}
override fun onResume() {
super.onResume()
cameraView.onResume()
cameraView.setPreviewCallback(scanningThread)
try {
scanningThread.start()
} catch (exception: Exception) {
// Do nothing
}
scanningThread.setScanListener(scanListener)
}
override fun onConfigurationChanged(newConfiguration: Configuration) {
super.onConfigurationChanged(newConfiguration)
this.cameraView.onPause()
when (newConfiguration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> overlayView.orientation = LinearLayout.HORIZONTAL
else -> overlayView.orientation = LinearLayout.VERTICAL
}
cameraView.onResume()
cameraView.setPreviewCallback(scanningThread)
}
override fun onPause() {
super.onPause()
this.cameraView.onPause()
this.scanningThread.stopScanning()
}
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.loki.redesign.fragments
import android.os.Bundle
import android.support.v4.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.fragment_scan_qr_code_placeholder.*
import network.loki.messenger.R
class ScanQRCodePlaceholderFragment: Fragment() {
var delegate: ScanQRCodePlaceholderFragmentDelegate? = null
override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? {
return layoutInflater.inflate(R.layout.fragment_scan_qr_code_placeholder, viewGroup, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
grantCameraAccessButton.setOnClickListener { delegate?.requestCameraAccess() }
}
}
interface ScanQRCodePlaceholderFragmentDelegate {
fun requestCameraAccess()
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.loki.redesign.fragments
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.content.ContextCompat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.tbruyelle.rxpermissions2.RxPermissions
import network.loki.messenger.R
import org.thoughtcrime.securesms.qr.ScanListener
class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDelegate, ScanListener {
var delegate: ScanQRCodeWrapperFragmentDelegate? = null
var message: CharSequence = ""
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_scan_qr_code_wrapper, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
update()
}
private fun update() {
val fragment: Fragment
if (ContextCompat.checkSelfPermission(activity!!, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
val scanQRCodeFragment = ScanQRCodeFragmentV2()
scanQRCodeFragment.scanListener = this
scanQRCodeFragment.message = message
fragment = scanQRCodeFragment
} else {
val scanQRCodePlaceholderFragment = ScanQRCodePlaceholderFragment()
scanQRCodePlaceholderFragment.delegate = this
fragment = scanQRCodePlaceholderFragment
}
val transaction = childFragmentManager.beginTransaction()
transaction.replace(R.id.fragmentContainer, fragment)
transaction.commit()
}
override fun requestCameraAccess() {
@SuppressWarnings("unused")
val unused = RxPermissions(this).request(Manifest.permission.CAMERA).subscribe { isGranted ->
if (isGranted) {
update()
}
}
}
override fun onQrDataFound(string: String) {
delegate?.handleQRCodeScanned(string)
}
}
interface ScanQRCodeWrapperFragmentDelegate {
fun handleQRCodeScanned(string: String)
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.loki.redesign.utilities
import android.content.Intent
import android.support.v7.app.ActionBar
import android.support.v7.app.AppCompatActivity
import android.view.Gravity
import android.widget.ImageView
import android.widget.RelativeLayout
import network.loki.messenger.R
fun AppCompatActivity.setUpActionBarSessionLogo() {
supportActionBar!!.setDisplayShowHomeEnabled(false)
supportActionBar!!.setDisplayShowTitleEnabled(false)
val logoImageView = ImageView(this)
logoImageView.setImageResource(R.drawable.session_logo)
val logoImageViewContainer = RelativeLayout(this)
logoImageViewContainer.addView(logoImageView)
logoImageViewContainer.gravity = Gravity.CENTER
val logoImageViewContainerLayoutParams = ActionBar.LayoutParams(ActionBar.LayoutParams.MATCH_PARENT, ActionBar.LayoutParams.WRAP_CONTENT)
supportActionBar!!.setCustomView(logoImageViewContainer, logoImageViewContainerLayoutParams)
supportActionBar!!.setDisplayShowCustomEnabled(true)
}
fun AppCompatActivity.push(intent: Intent, isForResult: Boolean = false) {
if (isForResult) {
startActivityForResult(intent, 42)
} else {
startActivity(intent)
}
overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out)
}
fun AppCompatActivity.show(intent: Intent, isForResult: Boolean = false) {
if (isForResult) {
startActivityForResult(intent, 42)
} else {
startActivity(intent)
}
overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out)
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.loki.redesign.utilities
import android.content.Context
import android.content.Intent
import android.support.v4.content.LocalBroadcastManager
class Broadcaster(private val context: Context) : org.whispersystems.signalservice.loki.utilities.Broadcaster {
override fun broadcast(event: String, long: Long) {
val intent = Intent(event)
intent.putExtra("long", long)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
}

View File

@@ -1,12 +1,16 @@
package org.thoughtcrime.securesms.loki
package org.thoughtcrime.securesms.loki.redesign.utilities
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Range
import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.getColorWithID
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.util.regex.Pattern
@@ -14,7 +18,7 @@ object MentionUtilities {
@JvmStatic
fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String {
return MentionUtilities.highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant
return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant
}
@JvmStatic
@@ -22,13 +26,14 @@ object MentionUtilities {
var text = text
val pattern = Pattern.compile("@[0-9a-fA-F]*")
var matcher = pattern.matcher(text)
val mentions = mutableListOf<Range<Int>>()
val mentions = mutableListOf<Tuple2<Range<Int>, String>>()
var startIndex = 0
val publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID)
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context)
if (matcher.find(startIndex)) {
while (true) {
val hexEncodedPublicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @
val userDisplayName: String? = if (hexEncodedPublicKey.toLowerCase() == TextSecurePreferences.getLocalNumber(context).toLowerCase()) {
val userDisplayName: String? = if (hexEncodedPublicKey.toLowerCase() == userHexEncodedPublicKey.toLowerCase()) {
TextSecurePreferences.getProfileName(context)
} else if (publicChat != null) {
DatabaseFactory.getLokiUserDatabase(context).getServerDisplayName(publicChat.id, hexEncodedPublicKey)
@@ -39,7 +44,7 @@ object MentionUtilities {
text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length)
val endIndex = matcher.start() + 1 + userDisplayName.length
startIndex = endIndex
mentions.add(Range.create(matcher.start(), endIndex))
mentions.add(Tuple2(Range.create(matcher.start(), endIndex), hexEncodedPublicKey))
} else {
startIndex = matcher.end()
}
@@ -48,9 +53,12 @@ object MentionUtilities {
}
}
val result = SpannableString(text)
for (range in mentions) {
val highlightColor = if (isOutgoingMessage) context.resources.getColor(R.color.loki_dark_green) else context.resources.getColor(R.color.loki_green)
result.setSpan(BackgroundColorSpan(highlightColor), range.lower, range.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val userLinkedDeviceHexEncodedPublicKeys = DatabaseFactory.getLokiAPIDatabase(context).getPairingAuthorisations(userHexEncodedPublicKey).flatMap { listOf( it.primaryDevicePublicKey, it.secondaryDevicePublicKey ) }.toMutableSet()
userLinkedDeviceHexEncodedPublicKeys.add(userHexEncodedPublicKey)
for (mention in mentions) {
if (!userLinkedDeviceHexEncodedPublicKeys.contains(mention.second)) { continue }
result.setSpan(ForegroundColorSpan(context.resources.getColorWithID(R.color.accent, context.theme)), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return result
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.loki.redesign.utilities
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.qrcode.QRCodeWriter
object QRCodeUtilities {
fun encode(data: String, size: Int, isInverted: Boolean = false, hasTransparentBackground: Boolean = true): Bitmap {
try {
val hints = hashMapOf( EncodeHintType.MARGIN to 1 )
val result = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, size, size, hints)
val bitmap = Bitmap.createBitmap(result.width, result.height, Bitmap.Config.ARGB_8888)
for (y in 0 until result.height) {
for (x in 0 until result.width) {
if (result.get(x, y)) {
bitmap.setPixel(x, y, if (isInverted) Color.WHITE else Color.BLACK)
} else if (!hasTransparentBackground) {
bitmap.setPixel(x, y, if (isInverted) Color.BLACK else Color.WHITE)
}
}
}
return bitmap
} catch (e: WriterException) {
return Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
}
}
}

View File

@@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.view_conversation.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.loki.LokiAPIUtilities.populateUserHexEncodedPublicKeyCacheIfNeeded
import org.thoughtcrime.securesms.loki.redesign.utilities.MentionUtilities.highlightMentions
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.whispersystems.signalservice.loki.api.LokiAPI
import java.util.*
class ConversationView : LinearLayout {
var thread: ThreadRecord? = null
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
val inflater = context.applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_conversation, null)
addView(contentView)
}
// endregion
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) {
this.thread = thread
populateUserHexEncodedPublicKeyCacheIfNeeded(thread.threadId, context) // FIXME: This is a terrible place to do this
unreadMessagesIndicatorView.visibility = if (thread.unreadCount > 0) View.VISIBLE else View.INVISIBLE
if (thread.recipient.isGroupRecipient) {
val users = LokiAPI.userHexEncodedPublicKeyCache[thread.threadId]?.toList() ?: listOf()
val randomUsers = users.sorted() // Sort to provide a level of stability
profilePictureView.hexEncodedPublicKey = randomUsers.getOrNull(0) ?: ""
profilePictureView.additionalHexEncodedPublicKey = randomUsers.getOrNull(1) ?: ""
profilePictureView.isRSSFeed = thread.recipient.name == "Loki News" || thread.recipient.name == "Loki Messenger Updates"
} else {
profilePictureView.hexEncodedPublicKey = thread.recipient.address.toString()
profilePictureView.additionalHexEncodedPublicKey = null
profilePictureView.isRSSFeed = false
}
profilePictureView.glide = glide
profilePictureView.update()
val senderDisplayName = if (thread.recipient.isLocalNumber) context.getString(R.string.note_to_self) else if (!thread.recipient.name.isNullOrEmpty()) thread.recipient.name else thread.recipient.address.toString()
displayNameTextView.text = senderDisplayName
timestampTextView.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), thread.date)
muteIndicatorImageView.visibility = if (thread.recipient.isMuted) VISIBLE else GONE
val rawSnippet = thread.getDisplayBody(context)
val snippet = highlightMentions(rawSnippet, thread.threadId, context)
snippetTextView.text = snippet
snippetTextView.typeface = if (thread.unreadCount > 0) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) {
typingIndicatorView.startAnimation()
} else {
typingIndicatorView.stopAnimation()
}
typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE
statusIndicatorImageView.visibility = View.VISIBLE
when {
!thread.isOutgoing || thread.isVerificationStatusChange -> statusIndicatorImageView.visibility = View.GONE
thread.isFailed -> statusIndicatorImageView.setImageResource(R.drawable.ic_error)
thread.isPending -> statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot)
thread.isRemoteRead -> statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check)
else -> statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check)
}
}
// endregion
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.view_device.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.devicelist.Device
import org.thoughtcrime.securesms.loki.toPx
class DeviceView : LinearLayout {
var device: Device? = null
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
val inflater = context.applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_device, null)
addView(contentView)
}
// endregion
// region Updating
fun bind(device: Device) {
titleTextView.text = if (!device.name.isNullOrBlank()) device.name else "Unnamed Device"
// FIXME: Hacky way of getting the view to be screen width
val titleTextViewLayoutParams = titleTextView.layoutParams
titleTextViewLayoutParams.width = resources.displayMetrics.widthPixels - toPx(32, resources)
titleTextView.layoutParams = titleTextViewLayoutParams
subtitleTextView.text = device.shortId
}
// endregion
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.content.Context.LAYOUT_INFLATER_SERVICE
import android.os.Handler
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ScrollView
import kotlinx.android.synthetic.main.view_fake_chat.view.*
import network.loki.messenger.R
class FakeChatView : ScrollView {
// region Settings
private val spacing = context.resources.getDimension(R.dimen.medium_spacing)
private val startDelay: Long = 2000
private val delayBetweenMessages: Long = 3000
private val animationDuration: Long = 400
// endregion
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
val inflater = context.applicationContext.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_fake_chat, null)
addView(contentView)
isVerticalScrollBarEnabled = false
}
// endregion
// region Animation
fun startAnimating() {
listOf( bubble1, bubble2, bubble3, bubble4, bubble5 ).forEach { it.alpha = 0.0f }
fun show(view: View) {
view.animate().alpha(1.0f).setDuration(animationDuration).start()
}
Handler().postDelayed({
show(bubble1)
Handler().postDelayed({
show(bubble2)
Handler().postDelayed({
show(bubble3)
smoothScrollTo(0, (bubble1.height + spacing).toInt())
Handler().postDelayed({
show(bubble4)
smoothScrollTo(0, (bubble1.height + spacing).toInt() + (bubble2.height + spacing).toInt())
Handler().postDelayed({
show(bubble5)
smoothScrollTo(0, (bubble1.height + spacing).toInt() + (bubble2.height + spacing).toInt() + (bubble3.height + spacing).toInt())
}, delayBetweenMessages)
}, delayBetweenMessages)
}, delayBetweenMessages)
}, delayBetweenMessages)
}, startDelay)
}
// endregion
}

View File

@@ -1,17 +1,21 @@
package org.thoughtcrime.securesms.loki
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import com.github.ybq.android.spinkit.style.DoubleBounce
import network.loki.messenger.R
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.loki.getColorWithID
import org.thoughtcrime.securesms.loki.toPx
import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestStatus
class FriendRequestView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : LinearLayout(context, attrs, defStyleAttr) {
@@ -28,14 +32,16 @@ class FriendRequestView(context: Context, attrs: AttributeSet?, defStyleAttr: In
private val label by lazy {
val result = TextView(context)
result.setTextColor(resources.getColorWithID(R.color.white, context.theme))
result.setTextColor(resources.getColorWithID(R.color.text, context.theme))
result.textAlignment = TextView.TEXT_ALIGNMENT_CENTER
result.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.small_font_size))
result
}
private val buttonLinearLayout by lazy {
val result = LinearLayout(context)
result.orientation = HORIZONTAL
result.setPadding(0, resources.getDimension(R.dimen.medium_spacing).toInt(), 0, 0)
result
}
@@ -64,39 +70,45 @@ class FriendRequestView(context: Context, attrs: AttributeSet?, defStyleAttr: In
if (isUISetUp) { return }
isUISetUp = true
orientation = VERTICAL
setPadding(toPx(48, resources), 0, toPx(48, resources), 0)
addView(topSpacer)
addView(label)
if (!message!!.isOutgoing) {
val loader = ProgressBar(context)
loader.isIndeterminate = true
val color = resources.getColorWithID(R.color.white, context.theme)
loader.indeterminateDrawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN)
loader.indeterminateDrawable = DoubleBounce()
val loaderLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, toPx(24, resources))
loader.layoutParams = loaderLayoutParams
loaderContainer.addView(loader)
addView(loaderContainer)
fun button(): Button {
val result = Button(context)
result.setBackgroundColor(resources.getColorWithID(R.color.transparent, context.theme))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
result.elevation = 0f
result.stateListAnimator = null
}
val buttonLayoutParams = LayoutParams(0, toPx(50, resources))
result.setTextColor(resources.getColorWithID(R.color.text, context.theme))
result.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.small_font_size))
result.isAllCaps = false
result.setPadding(0, 0, 0, 0)
val buttonLayoutParams = LayoutParams(0, resources.getDimension(R.dimen.small_button_height).toInt())
buttonLayoutParams.weight = 1f
result.layoutParams = buttonLayoutParams
return result
}
val acceptButton = button()
acceptButton.text = resources.getString(R.string.view_friend_request_accept_button_title)
acceptButton.setTextColor(resources.getColorWithID(R.color.signal_primary, context.theme))
acceptButton.setOnClickListener { accept() }
buttonLinearLayout.addView(acceptButton)
val rejectButton = button()
rejectButton.text = resources.getString(R.string.view_friend_request_reject_button_title)
rejectButton.setTextColor(resources.getColorWithID(R.color.red, context.theme))
rejectButton.setBackgroundResource(R.drawable.unimportant_dialog_button_background)
rejectButton.setOnClickListener { reject() }
buttonLinearLayout.addView(rejectButton)
val acceptButton = button()
acceptButton.text = resources.getString(R.string.view_friend_request_accept_button_title)
acceptButton.setBackgroundResource(R.drawable.prominent_dialog_button_background)
val acceptButtonLayoutParams = acceptButton.layoutParams as LayoutParams
acceptButtonLayoutParams.setMargins(resources.getDimension(R.dimen.medium_spacing).toInt(), 0, 0, 0)
acceptButton.layoutParams = acceptButtonLayoutParams
acceptButton.setOnClickListener { accept() }
buttonLinearLayout.addView(acceptButton)
buttonLinearLayout.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, toPx(50, resources))
addView(buttonLinearLayout)
}
@@ -155,4 +167,19 @@ class FriendRequestView(context: Context, attrs: AttributeSet?, defStyleAttr: In
delegate?.rejectFriendRequest(message!!)
}
// endregion
}
}
// region Delegate
interface FriendRequestViewDelegate {
/**
* Implementations of this method should update the thread's friend request status
* and send a friend request accepted message.
*/
fun acceptFriendRequest(friendRequest: MessageRecord)
/**
* Implementations of this method should update the thread's friend request status
* and remove the pre keys associated with the contact.
*/
fun rejectFriendRequest(friendRequest: MessageRecord)
}
// endregion

View File

@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.loki
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.util.AttributeSet
@@ -8,11 +8,15 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ListView
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.toPx
import org.thoughtcrime.securesms.mms.GlideRequests
import org.whispersystems.signalservice.loki.messaging.Mention
class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
private var mentionCandidates = listOf<Mention>()
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.mentionCandidates = newValue }
var glide: GlideRequests? = null
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.glide = newValue }
var publicChatServer: String? = null
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.publicChatServer = publicChatServer }
var publicChatChannel: Long? = null
@@ -24,6 +28,7 @@ class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defS
private class Adapter(private val context: Context) : BaseAdapter() {
var mentionCandidates = listOf<Mention>()
set(newValue) { field = newValue; notifyDataSetChanged() }
var glide: GlideRequests? = null
var publicChatServer: String? = null
var publicChatChannel: Long? = null
@@ -40,8 +45,9 @@ class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defS
}
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
val cell = cellToBeReused as MentionCandidateSelectionViewCell? ?: MentionCandidateSelectionViewCell.inflate(LayoutInflater.from(context), parent)
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView.inflate(LayoutInflater.from(context), parent)
val mentionCandidate = getItem(position)
cell.glide = glide
cell.mentionCandidate = mentionCandidate
cell.publicChatServer = publicChatServer
cell.publicChatChannel = publicChatChannel
@@ -53,6 +59,7 @@ class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defS
constructor(context: Context) : this(context, null)
init {
clipToOutline = true
adapter = mentionCandidateSelectionViewAdapter
mentionCandidateSelectionViewAdapter.mentionCandidates = mentionCandidates
setOnItemClickListener { _, _, position, _ ->
@@ -68,7 +75,7 @@ class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defS
}
this.mentionCandidates = mentionCandidates
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = toPx(6 + Math.min(mentionCandidates.count(), 4) * 52, resources)
layoutParams.height = toPx(Math.min(mentionCandidates.count(), 4) * 44, resources)
this.layoutParams = layoutParams
}

View File

@@ -1,21 +1,21 @@
package org.thoughtcrime.securesms.loki
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.cell_mention_candidate_selection_view.view.*
import kotlinx.android.synthetic.main.view_mention_candidate.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.mms.GlideRequests
import org.whispersystems.signalservice.loki.api.LokiPublicChatAPI
import org.whispersystems.signalservice.loki.messaging.Mention
class MentionCandidateSelectionViewCell(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : LinearLayout(context, attrs, defStyleAttr) {
class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : LinearLayout(context, attrs, defStyleAttr) {
var mentionCandidate = Mention("", "")
set(newValue) { field = newValue; update() }
var glide: GlideRequests? = null
var publicChatServer: String? = null
var publicChatChannel: Long? = null
@@ -24,25 +24,18 @@ class MentionCandidateSelectionViewCell(context: Context, attrs: AttributeSet?,
companion object {
fun inflate(layoutInflater: LayoutInflater, parent: ViewGroup): MentionCandidateSelectionViewCell {
return layoutInflater.inflate(R.layout.cell_mention_candidate_selection_view, parent, false) as MentionCandidateSelectionViewCell
fun inflate(layoutInflater: LayoutInflater, parent: ViewGroup): MentionCandidateView {
return layoutInflater.inflate(R.layout.view_mention_candidate, parent, false) as MentionCandidateView
}
}
override fun onFinishInflate() {
super.onFinishInflate()
profilePictureImageViewContainer.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
profilePictureImageViewContainer.clipToOutline = true
}
private fun update() {
displayNameTextView.text = mentionCandidate.displayName
profilePictureImageView.update(mentionCandidate.hexEncodedPublicKey)
profilePictureView.hexEncodedPublicKey = mentionCandidate.hexEncodedPublicKey
profilePictureView.additionalHexEncodedPublicKey = null
profilePictureView.isRSSFeed = false
profilePictureView.glide = glide!!
profilePictureView.update()
if (publicChatServer != null && publicChatChannel != null) {
val isUserModerator = LokiPublicChatAPI.isUserModerator(mentionCandidate.hexEncodedPublicKey, publicChatChannel!!, publicChatServer!!)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.support.annotation.DimenRes
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.RelativeLayout
import com.bumptech.glide.load.engine.DiskCacheStrategy
import kotlinx.android.synthetic.main.view_profile_picture.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.loki.JazzIdenticonDrawable
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.recipients.Recipient
// TODO: Look into a better way of handling different sizes. Maybe an enum (with associated values) encapsulating the different modes?
class ProfilePictureView : RelativeLayout {
lateinit var glide: GlideRequests
var hexEncodedPublicKey: String? = null
var additionalHexEncodedPublicKey: String? = null
var isRSSFeed = false
var isLarge = false
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
val inflater = context.applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_profile_picture, null)
addView(contentView)
}
// endregion
// region Updating
fun update() {
val hexEncodedPublicKey = hexEncodedPublicKey ?: return
val additionalHexEncodedPublicKey = additionalHexEncodedPublicKey
doubleModeImageViewContainer.visibility = if (additionalHexEncodedPublicKey != null && !isRSSFeed) View.VISIBLE else View.INVISIBLE
singleModeImageViewContainer.visibility = if (additionalHexEncodedPublicKey == null && !isRSSFeed && !isLarge) View.VISIBLE else View.INVISIBLE
largeSingleModeImageViewContainer.visibility = if (additionalHexEncodedPublicKey == null && !isRSSFeed && isLarge) View.VISIBLE else View.INVISIBLE
rssImageView.visibility = if (isRSSFeed) View.VISIBLE else View.INVISIBLE
fun setProfilePictureIfNeeded(imageView: ImageView, hexEncodedPublicKey: String, @DimenRes sizeID: Int) {
glide.clear(imageView)
if (hexEncodedPublicKey.isNotEmpty()) {
val signalProfilePicture = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false).contactPhoto
if (signalProfilePicture != null && (signalProfilePicture as? ProfileContactPhoto)?.avatarObject != "0") {
glide.load(signalProfilePicture).diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop().into(imageView)
} else {
val size = resources.getDimensionPixelSize(sizeID)
val jazzIcon = JazzIdenticonDrawable(size, size, hexEncodedPublicKey)
glide.load(jazzIcon).diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop().into(imageView)
}
} else {
imageView.setImageDrawable(null)
}
}
setProfilePictureIfNeeded(doubleModeImageView1, hexEncodedPublicKey, R.dimen.small_profile_picture_size)
setProfilePictureIfNeeded(doubleModeImageView2, additionalHexEncodedPublicKey ?: "", R.dimen.small_profile_picture_size)
setProfilePictureIfNeeded(singleModeImageView, hexEncodedPublicKey, R.dimen.medium_profile_picture_size)
setProfilePictureIfNeeded(largeSingleModeImageView, hexEncodedPublicKey, R.dimen.large_profile_picture_size)
}
// endregion
}

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import kotlinx.android.synthetic.main.view_seed_reminder.view.*
import network.loki.messenger.R
class SeedReminderView : FrameLayout {
var title: CharSequence
get() = titleTextView.text
set(value) { titleTextView.text = value }
var subtitle: CharSequence
get() = subtitleTextView.text
set(value) { subtitleTextView.text = value }
var delegate: SeedReminderViewDelegate? = null
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
val inflater = context.applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_seed_reminder, null)
addView(contentView)
button.setOnClickListener { delegate?.handleSeedReminderViewContinueButtonTapped() }
}
fun setProgress(progress: Int, isAnimated: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
progressBar.setProgress(progress, isAnimated)
} else {
progressBar.progress = progress
}
}
fun hideContinueButton() {
button.visibility = View.GONE
}
}
interface SeedReminderViewDelegate {
fun handleSeedReminderViewContinueButtonTapped()
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.loki.redesign.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import kotlinx.android.synthetic.main.view_separator.view.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.loki.getColorWithID
import org.thoughtcrime.securesms.loki.toPx
class SeparatorView : RelativeLayout {
private val path = Path()
private val paint: Paint = {
val result = Paint()
result.style = Paint.Style.STROKE
result.color = resources.getColorWithID(R.color.separator, context.theme)
result.strokeWidth = toPx(1, resources).toFloat()
result.isAntiAlias = true
result
}()
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
val inflater = context.applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val contentView = inflater.inflate(R.layout.view_separator, null)
val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(contentView, layoutParams)
setWillNotDraw(false)
}
// endregion
// region Updating
override fun onDraw(c: Canvas) {
super.onDraw(c)
val w = width.toFloat()
val h = height.toFloat()
val hMargin = toPx(10, resources).toFloat()
path.reset()
path.moveTo(0.0f, h / 2)
path.lineTo(titleTextView.left - hMargin, h / 2)
path.addRoundRect(titleTextView.left - hMargin, toPx(1, resources).toFloat(), titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW)
path.moveTo(titleTextView.right + hMargin, h / 2)
path.lineTo(w, h / 2)
path.close()
c.drawPath(path, paint)
}
// endregion
}