├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── co │ │ └── hellocode │ │ └── micro │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── co │ │ │ └── hellocode │ │ │ └── micro │ │ │ ├── BaseRecycler.kt │ │ │ ├── BaseTimelineActivity.kt │ │ │ ├── ConversationActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MediaPostViewHolder.kt │ │ │ ├── NewPost │ │ │ └── NewPostActivity.kt │ │ │ ├── Post.kt │ │ │ ├── PostViewHolder.kt │ │ │ ├── ProfileActivity.kt │ │ │ ├── TimelineRecyclerAdapter.kt │ │ │ ├── extensions │ │ │ └── EditTextExtensions.kt │ │ │ ├── services │ │ │ └── APIService.kt │ │ │ ├── tablayout │ │ │ ├── TabAdapter.kt │ │ │ └── fragments │ │ │ │ ├── BaseTimelineFragment.kt │ │ │ │ ├── DiscoverFragment.kt │ │ │ │ ├── MediaFragment.kt │ │ │ │ ├── MentionsFragment.kt │ │ │ │ └── TimelineFragment.kt │ │ │ └── utils │ │ │ ├── Constants.kt │ │ │ ├── CustomQuoteSpan.kt │ │ │ ├── Extensions.kt │ │ │ ├── FabOffsetter.kt │ │ │ ├── HtmlTagHandler.kt │ │ │ ├── ScrollAwareFabBehaviour.kt │ │ │ └── URLSpanNoUnderline.kt │ └── res │ │ ├── drawable │ │ ├── chat_bubble.png │ │ ├── circle_button_background.xml │ │ ├── compose.png │ │ ├── custom_button.xml │ │ ├── ic_launcher_background.xml │ │ ├── photo.png │ │ ├── pico_ic_foreground.png │ │ ├── reply.png │ │ ├── reply_edit_text.xml │ │ └── white_button_background.xml │ │ ├── layout │ │ ├── activity_conversation.xml │ │ ├── activity_main.xml │ │ ├── activity_new_post.xml │ │ ├── activity_profile_collapsing.xml │ │ ├── activity_timeline.xml │ │ ├── baselayout_timeline.xml │ │ ├── layout_post_image.xml │ │ ├── timeline_item.xml │ │ └── timeline_media_item.xml │ │ ├── menu │ │ ├── menu_main.xml │ │ └── menu_timeline.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── values.xml │ │ └── xml │ │ └── shortcuts.xml │ └── test │ └── java │ └── co │ └── hellocode │ └── micro │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/ 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | .DS_Store 9 | /build 10 | /captures 11 | .externalNativeBuild 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 bellebethcooper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Update: Sep 29, 2018 2 | 3 | This project is no longer maintained. I decided to [leave Micro.blog](http://blog.bellebcooper.com/leaving-microblog.html) and will not be working on Pico anymore. Since the code is open source, please feel free to fork it and maintain your own version. 4 | 5 | Thanks to everyone who used Pico and supported the project. It's one of the most fun projects I've worked on. 6 | 7 | # Pico: A Micro.blog client for Android 8 | 9 | This project is still very early! I threw it together just so I'd have a way to post to Micro.blog from my Android phone. It's evolving into a more fully fledged client, but it still has a long way to go. 10 | 11 | The project uses the MIT license and requires Android version 7.1 or later. Built with Android Studio 3.1.3 against Android SDK 27. If you have trouble building the project, please make sure your copy of Android Studio is up-to-date. 12 | 13 | Feel free to fork it or submit PRs, or just to try it out yourself. 14 | 15 | ## To use: 16 | 17 | 1. Create a new app token from your [Micro.blog account page](https://micro.blog/account) 18 | 2. Copy the token you just created 19 | 3. Run Pico and you should see a dialog asking for your app token (if you don't, please let me know!) 20 | 4. Paste in your app token and tap "Save" 21 | 22 | ## Get involved 23 | 24 | - Chat to me on Micro.blog about Pico! I'm [@belle](http://micro.blog/belle) 25 | - Take a look at the issues here on GitHub to contribute to the source 26 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | android { 6 | compileSdkVersion 27 7 | defaultConfig { 8 | applicationId "co.hellocode.micro" 9 | minSdkVersion 25 10 | targetSdkVersion 27 11 | versionCode 1 12 | versionName "0.0.7" 13 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 14 | } 15 | buildTypes { 16 | release { 17 | minifyEnabled false 18 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 19 | } 20 | } 21 | } 22 | 23 | repositories { 24 | maven { url 'https://jitpack.io' } 25 | jcenter() 26 | } 27 | 28 | dependencies { 29 | implementation fileTree(dir: 'libs', include: ['*.jar']) 30 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" 31 | implementation 'com.android.support:appcompat-v7:27.1.1' 32 | implementation 'com.android.support.constraint:constraint-layout:1.1.2' 33 | implementation 'com.android.support:design:27.1.1' 34 | implementation 'com.android.volley:volley:1.1.1' 35 | implementation 'com.github.hardillb:MultiPartVolley:0.0.3' 36 | implementation 'com.squareup.picasso:picasso:2.71828' 37 | implementation 'com.github.NightWhistler:HtmlSpanner:0.4' 38 | implementation 'jp.wasabeef:picasso-transformations:2.2.1' 39 | testImplementation 'junit:junit:4.12' 40 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 41 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 42 | } 43 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/co/hellocode/micro/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.support.test.InstrumentationRegistry 4 | import android.support.test.runner.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getTargetContext() 22 | assertEquals("co.hellocode.micro", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 15 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 31 | 34 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/BaseRecycler.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.support.v7.widget.RecyclerView 4 | import android.util.Log 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import co.hellocode.micro.utils.inflate 8 | 9 | abstract class BaseViewHolder(parent: ViewGroup, layoutRes: Int, 10 | val rootView: View = parent.inflate(layoutRes)) 11 | : RecyclerView.ViewHolder(rootView) { 12 | 13 | abstract fun bindItem(item: T) 14 | } 15 | 16 | class BaseRecyclerAdapter(private val viewHolderFactory: ((parent: ViewGroup) -> BaseViewHolder), 17 | private val items: ArrayList) 18 | : RecyclerView.Adapter>() { 19 | 20 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { 21 | //return PostViewHolder(parent, true) 22 | return viewHolderFactory(parent) 23 | } 24 | 25 | override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { 26 | val item = items[position] 27 | holder.bindItem(item) 28 | 29 | } 30 | 31 | override fun getItemCount(): Int = items.size 32 | 33 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/BaseTimelineActivity.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.SharedPreferences 7 | import android.os.Bundle 8 | import android.support.design.widget.Snackbar 9 | import android.support.v4.widget.SwipeRefreshLayout 10 | import android.support.v7.app.AppCompatActivity 11 | import android.support.v7.widget.LinearLayoutManager 12 | import android.support.v7.widget.RecyclerView 13 | import android.util.Log 14 | import co.hellocode.micro.newpost.NewPostActivity 15 | import co.hellocode.micro.utils.NEW_POST_REQUEST_CODE 16 | import co.hellocode.micro.utils.PREFS_FILENAME 17 | import co.hellocode.micro.utils.TOKEN 18 | import com.android.volley.AuthFailureError 19 | import com.android.volley.Response 20 | import com.android.volley.TimeoutError 21 | import com.android.volley.toolbox.JsonObjectRequest 22 | import com.android.volley.toolbox.Volley 23 | import kotlinx.android.synthetic.main.activity_main.* 24 | import kotlinx.android.synthetic.main.activity_profile_collapsing.* 25 | import kotlinx.android.synthetic.main.baselayout_timeline.* 26 | import org.json.JSONArray 27 | import org.json.JSONObject 28 | import java.util.* 29 | 30 | 31 | abstract class BaseTimelineActivity : AppCompatActivity() { 32 | 33 | private lateinit var linearLayoutManager: LinearLayoutManager 34 | open lateinit var adapter: TimelineRecyclerAdapter 35 | open var posts = ArrayList() 36 | private lateinit var refresh: SwipeRefreshLayout 37 | open var url = "https://micro.blog/posts/all" 38 | open var title = "Timeline" 39 | 40 | override fun onCreate(savedInstanceState: Bundle?) { 41 | Log.d("BaseTimeline", "oncreate") 42 | super.onCreate(savedInstanceState) 43 | setContentView(contentView()) 44 | setSupportActionBar(toolbar) 45 | supportActionBar?.title = title 46 | this.linearLayoutManager = LinearLayoutManager(this) 47 | val recycler = recycler() 48 | recycler.layoutManager = this.linearLayoutManager 49 | this.adapter = TimelineRecyclerAdapter(this.posts) 50 | recycler.adapter = this.adapter 51 | 52 | if (fab != null) { 53 | fab.setOnClickListener { 54 | val intent = Intent(this, NewPostActivity::class.java) 55 | startActivityForResult(intent, NEW_POST_REQUEST_CODE) 56 | } 57 | } 58 | 59 | this.refresh = refresher 60 | this.refresh.setOnRefreshListener { refresh() } 61 | initialLoad() 62 | } 63 | 64 | open fun contentView(): Int { 65 | return R.layout.activity_timeline 66 | } 67 | 68 | open fun recycler(): RecyclerView { 69 | return recyclerView 70 | } 71 | 72 | open fun initialLoad() { 73 | Log.i("BaseTimeline", "initialLoad") 74 | this.refresh.isRefreshing = true 75 | refresh() 76 | } 77 | 78 | private fun refresh() { 79 | Log.i("BaseTimeline", "refresh") 80 | getTimeline() 81 | } 82 | 83 | // TODO: Remove this, because the API is aggressively cached anyway, so this isn't useful 84 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 85 | super.onActivityResult(requestCode, resultCode, data) 86 | 87 | if (requestCode == NEW_POST_REQUEST_CODE && resultCode == Activity.RESULT_OK) { 88 | refresh() 89 | } 90 | } 91 | 92 | fun prefs(): SharedPreferences { 93 | return getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 94 | } 95 | 96 | private fun getTimeline() { 97 | Log.i("BaseTimeline", "getTimeline: ${this.url}") 98 | val rq = object : JsonObjectRequest( 99 | this.url, 100 | null, 101 | Response.Listener { response -> 102 | // Log.i("MainActivity", "resp: $response") 103 | val items = response["items"] as JSONArray 104 | createPosts(items) 105 | getRequestComplete(response) 106 | this.adapter.notifyDataSetChanged() 107 | this.refresh.isRefreshing = false 108 | }, 109 | Response.ErrorListener { error -> 110 | Log.i("BaseTimelineAct", "err: $error msg: ${error.message}") 111 | if (error.networkResponse != null) { 112 | Log.i("BaseTimelineAct", "err: $error network resp: ${error.networkResponse}") 113 | } 114 | if (error is TimeoutError) { 115 | Snackbar.make(this.refresh, "Request timed out; trying again", Snackbar.LENGTH_SHORT) 116 | this.getTimeline() 117 | } else { 118 | this.refresh.isRefreshing = false 119 | } 120 | // TODO: Handle error 121 | }) { 122 | @Throws(AuthFailureError::class) 123 | override fun getHeaders(): Map { 124 | val headers = HashMap() 125 | val prefs = prefs() 126 | val token: String? = prefs.getString(TOKEN, null) 127 | headers["Authorization"] = "Bearer $token" 128 | return headers 129 | } 130 | } 131 | val queue = Volley.newRequestQueue(this) 132 | queue.add(rq) 133 | } 134 | 135 | open fun getRequestComplete(response: JSONObject) { 136 | 137 | } 138 | 139 | open fun createPosts(items: JSONArray) { 140 | this.posts.clear() 141 | for (i in 0 until items.length()) { 142 | val item = items[i] as JSONObject 143 | this.posts.add(Post(item)) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/ConversationActivity.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.support.v7.app.AppCompatActivity 7 | import android.os.Bundle 8 | import android.support.design.widget.Snackbar 9 | import android.support.v7.widget.RecyclerView 10 | import android.util.Log 11 | import android.view.View 12 | import co.hellocode.micro.utils.PREFS_FILENAME 13 | import co.hellocode.micro.utils.TOKEN 14 | import com.android.volley.AuthFailureError 15 | import com.android.volley.DefaultRetryPolicy 16 | import com.android.volley.Request 17 | import com.android.volley.Response 18 | import com.android.volley.toolbox.StringRequest 19 | import com.android.volley.toolbox.Volley 20 | import kotlinx.android.synthetic.main.activity_conversation.* 21 | import kotlinx.android.synthetic.main.activity_new_post.* 22 | import kotlinx.android.synthetic.main.baselayout_timeline.* 23 | import org.json.JSONArray 24 | import org.json.JSONObject 25 | import kotlin.math.log 26 | 27 | class ConversationActivity() : BaseTimelineActivity() { 28 | override var url = "https://micro.blog/posts/conversation?id=" 29 | override var title = "Conversation" 30 | var startText = "" 31 | private var postID: Int = 0 32 | 33 | override fun initialLoad() { 34 | this.postID = intent.getIntExtra("@string/reply_intent_extra_postID", 0) 35 | Log.i("ConversationAct", "id: ${this.postID}") 36 | if (this.postID == 0) { 37 | // Don't try to load if the postID couldn't be found and is still the default of zero 38 | this.finish() 39 | } 40 | this.url = this.url + postID.toString() 41 | super.initialLoad() 42 | } 43 | 44 | override fun onCreate(savedInstanceState: Bundle?) { 45 | super.onCreate(savedInstanceState) 46 | this.adapter = TimelineRecyclerAdapter(this.posts, canShowConversations = false) 47 | conversation_recyclerView.adapter = this.adapter 48 | 49 | reply_button.setOnClickListener { 50 | submitPost(reply_button) 51 | } 52 | 53 | // set up start text for reply box 54 | val author = intent.getStringExtra("@string/reply_intent_extra_author") 55 | this.startText += "@$author " 56 | val mentions = intent.getStringArrayListExtra("@string/reply_intent_extra_mentions") 57 | if (mentions != null) { 58 | for (mention in mentions) { 59 | this.startText += "$mention " 60 | } 61 | } 62 | reply_view.setOnFocusChangeListener { v, hasFocus -> 63 | if (v === reply_view && hasFocus && (v.text == null || v.text.toString() == "")) { 64 | v.setText(this.startText) 65 | } 66 | } 67 | } 68 | 69 | override fun createPosts(items: JSONArray) { 70 | this.posts.clear() 71 | for (i in 0 until items.length()) { 72 | val item = items[i] as JSONObject 73 | this.posts.add(Post(item)) 74 | } 75 | this.posts.reverse() 76 | } 77 | 78 | override fun contentView(): Int { 79 | return R.layout.activity_conversation 80 | } 81 | 82 | override fun recycler() : RecyclerView { 83 | return conversation_recyclerView 84 | } 85 | 86 | private fun submitPost(view: View) { 87 | spinner.visibility = View.VISIBLE 88 | reply_button.visibility = View.GONE 89 | 90 | val text = reply_view.text.toString() 91 | val queue = Volley.newRequestQueue(this) 92 | val postUrl = "https://micro.blog/micropub" 93 | val replyUrl = "https://micro.blog/posts/reply" 94 | var url = postUrl 95 | 96 | // Use the reply URL with post ID appended if this post is a reply 97 | if (this.postID != 0) { 98 | url = replyUrl + "?id=${this.postID}" 99 | } 100 | 101 | val rq = object : StringRequest( 102 | Request.Method.POST, 103 | url, 104 | Response.Listener { response -> 105 | Log.i("MainActivity", "resp: $response") 106 | Snackbar.make(view, "Success!", Snackbar.LENGTH_LONG).show() 107 | reply_view.setText("") 108 | spinner.visibility = View.GONE 109 | reply_button.visibility = View.VISIBLE 110 | }, 111 | Response.ErrorListener { error -> 112 | Log.i("MainActivity", "err: $error msg: ${error.message}") 113 | Snackbar.make(view, "Error: $error", Snackbar.LENGTH_LONG).show() 114 | spinner.visibility = View.GONE 115 | reply_button.visibility = View.VISIBLE 116 | }) { 117 | @Throws(AuthFailureError::class) 118 | override fun getHeaders(): Map { 119 | val headers = HashMap() 120 | val prefs = this@ConversationActivity.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 121 | val token: String? = prefs?.getString(TOKEN, null) 122 | headers["Authorization"] = "Bearer $token" 123 | return headers 124 | } 125 | 126 | @Throws(AuthFailureError::class) 127 | override fun getParams(): Map { 128 | Log.i("MainActivity", "getParams") 129 | val params = HashMap() 130 | params["h"] = "entry" 131 | params["text"] = text 132 | return params 133 | } 134 | } 135 | // set timeout to zero so Volley won't send multiple of the same request 136 | // seems like a Volley bug: https://groups.google.com/forum/#!topic/volley-users/8PE9dBbD6iA 137 | rq.retryPolicy = DefaultRetryPolicy(0, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) 138 | queue.add(rq) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.support.v7.app.AppCompatActivity 6 | import android.os.Bundle 7 | import android.support.design.widget.TabLayout 8 | import android.support.v7.app.AlertDialog 9 | import android.util.Log 10 | import android.widget.EditText 11 | import android.widget.Toast 12 | import co.hellocode.micro.newpost.NewPostActivity 13 | import co.hellocode.micro.tablayout.TabAdapter 14 | import co.hellocode.micro.utils.NEW_POST_REQUEST_CODE 15 | import co.hellocode.micro.utils.PREFS_FILENAME 16 | import co.hellocode.micro.utils.TOKEN 17 | import kotlinx.android.synthetic.main.activity_main.* 18 | 19 | class MainActivity : AppCompatActivity() { 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | setContentView(R.layout.activity_main) 24 | initToolbar() 25 | 26 | // Make sure user has a token before proceeding 27 | checkForUserToken() 28 | 29 | val adapter = TabAdapter(supportFragmentManager) 30 | view_pager.adapter = adapter 31 | view_pager.offscreenPageLimit = 4 32 | 33 | fab.setOnClickListener { 34 | val intent = Intent(this, NewPostActivity::class.java) 35 | startActivityForResult(intent, NEW_POST_REQUEST_CODE) 36 | } 37 | 38 | tab_layout.setupWithViewPager(view_pager) 39 | tab_layout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { 40 | override fun onTabSelected(tab: TabLayout.Tab) { 41 | 42 | } 43 | 44 | override fun onTabUnselected(tab: TabLayout.Tab) { 45 | 46 | } 47 | 48 | override fun onTabReselected(tab: TabLayout.Tab) { 49 | 50 | } 51 | }) 52 | } 53 | 54 | private fun checkForUserToken() { 55 | val prefs = this.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 56 | val token: String? = prefs?.getString(TOKEN, null) 57 | if (token == null) { 58 | Log.i("MainActivity", "token is null") 59 | val input = EditText(this) 60 | val builder = AlertDialog.Builder(this) 61 | builder.setView(input) 62 | .setTitle("Set your app token") 63 | .setNegativeButton("Cancel") { dialogInterface, _ -> 64 | dialogInterface.cancel() 65 | } 66 | .setPositiveButton("Save") { _, _ -> 67 | // Put token in sharedPrefs so we can use it to make network calls later 68 | prefs.edit().putString(TOKEN, input.text.toString().toLowerCase()).apply() 69 | Toast.makeText(this, "Token set, thanks.", Toast.LENGTH_SHORT).show() 70 | } 71 | .create() 72 | .show() 73 | } 74 | } 75 | 76 | private fun initToolbar() { 77 | setSupportActionBar(toolbar) 78 | supportActionBar!!.title = "Pico" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/MediaPostViewHolder.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.content.Intent 4 | import android.text.format.DateUtils 5 | import android.text.method.LinkMovementMethod 6 | import android.util.Log 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import android.widget.ImageView 11 | import co.hellocode.micro.newpost.NewPostActivity 12 | import co.hellocode.micro.utils.inflate 13 | import com.squareup.picasso.Picasso 14 | import jp.wasabeef.picasso.transformations.CropCircleTransformation 15 | import kotlinx.android.synthetic.main.layout_post_image.view.* 16 | import kotlinx.android.synthetic.main.timeline_item.view.* 17 | import kotlinx.android.synthetic.main.timeline_media_item.view.* 18 | 19 | class MediaPostViewHolder(parent: ViewGroup, private var canShowConversations: Boolean) 20 | : BaseViewHolder(parent, R.layout.timeline_media_item) { 21 | 22 | private var post: Post? = null 23 | 24 | init { 25 | Log.i("MediaPostVH", "init") 26 | if (this.canShowConversations) { 27 | rootView.setOnClickListener { 28 | postDetailIntent(it) 29 | } 30 | rootView.media_post_itemText.setOnClickListener { 31 | postDetailIntent(it) 32 | } 33 | } 34 | rootView.setOnLongClickListener { 35 | if (post == null) { 36 | return@setOnLongClickListener false 37 | } 38 | newPostIntent(it) 39 | true 40 | } 41 | rootView.media_post_itemText.setOnLongClickListener { 42 | if (post == null) { 43 | return@setOnLongClickListener false 44 | } 45 | newPostIntent(it) 46 | true 47 | } 48 | rootView.media_post_avatar.setOnClickListener { 49 | avatarClick(it) 50 | } 51 | } 52 | 53 | private fun avatarClick(view: View) { 54 | if (this.post?.username == null) { 55 | return 56 | } 57 | val intent = Intent(view.context, ProfileActivity::class.java) 58 | intent.putExtra("username", this.post?.username) 59 | view.context.startActivity(intent) 60 | } 61 | 62 | private fun newPostIntent(view: View) { 63 | val intent = Intent(view.context, NewPostActivity::class.java) 64 | intent.putExtra("@string/reply_intent_extra_postID", this.post?.ID) 65 | intent.putExtra("@string/reply_intent_extra_author", this.post?.username) 66 | if (this.post?.mentions != null) { 67 | intent.putStringArrayListExtra("mentions", this.post?.mentions) 68 | } 69 | view.context.startActivity(intent) 70 | } 71 | 72 | private fun postDetailIntent(view: View) { 73 | val intent = Intent(view.context, ConversationActivity::class.java) 74 | intent.putExtra("@string/reply_intent_extra_postID", this.post?.ID) 75 | intent.putExtra("@string/reply_intent_extra_author", this.post?.username) 76 | if (this.post?.mentions != null) { 77 | intent.putStringArrayListExtra("@string/reply_intent_extra_mentions", this.post?.mentions) 78 | } 79 | view.context.startActivity(intent) 80 | } 81 | 82 | override fun bindItem(item: Post) { 83 | Log.i("MediaVH", "bindItem") 84 | this.post = item 85 | // remove any image views leftover from reusing views 86 | for (i in 0 until rootView.media_outer_layout.childCount) { 87 | val v = rootView.media_outer_layout.getChildAt(i) 88 | if (v is ImageView) { 89 | rootView.media_outer_layout.removeViewAt(i) 90 | } 91 | } 92 | // and remove user avatar image 93 | rootView.media_post_avatar.setImageDrawable(null) 94 | 95 | rootView.media_post_itemText.setOnClickListener { v -> 96 | if (this.canShowConversations) { 97 | postDetailIntent(v) 98 | } 99 | } 100 | 101 | rootView.media_post_itemText.text = item.getParsedContent(rootView.context) 102 | rootView.media_post_itemText.movementMethod = LinkMovementMethod.getInstance() // make links open in browser when tapped 103 | rootView.media_post_author.text = item.authorName 104 | rootView.media_post_username.text = "@${item.username}" 105 | 106 | rootView.media_post_timestamp.text = DateUtils.getRelativeTimeSpanString(rootView.context, item.date.time) 107 | 108 | val picasso = Picasso.get() 109 | // picasso.setIndicatorsEnabled(true) // Uncomment this line to see coloured corners on images, indicating where they're loading from 110 | // Red = network, blue = disk, green = memory 111 | picasso.load(item.authorAvatarURL).transform(CropCircleTransformation()).into(rootView.media_post_avatar) 112 | 113 | for (i in item.imageSources) { 114 | val imageView = LayoutInflater.from(rootView.context).inflate( 115 | R.layout.layout_post_image, 116 | null, 117 | false 118 | ) 119 | // using index 1 is going to put multiple images in the wrong order 120 | // but I'm not sure how to fix that just yet 121 | rootView.media_outer_layout.addView(imageView, 1) 122 | if (this.canShowConversations) { 123 | imageView.setOnClickListener { 124 | postDetailIntent(it) 125 | } 126 | } 127 | picasso.load(i).into(imageView.post_image) 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/NewPost/NewPostActivity.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.newpost 2 | 3 | import android.app.Activity 4 | import android.app.ProgressDialog 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.pm.ShortcutManager 8 | import android.graphics.Bitmap 9 | import android.graphics.BitmapFactory 10 | import android.os.Bundle 11 | import android.provider.MediaStore 12 | import android.support.design.widget.Snackbar 13 | import android.support.v7.app.AppCompatActivity 14 | import android.util.Log 15 | import android.view.Menu 16 | import android.view.MenuItem 17 | import android.view.View 18 | import co.hellocode.micro.extensions.onChange 19 | import co.hellocode.micro.R 20 | import co.hellocode.micro.utils.PREFS_FILENAME 21 | import co.hellocode.micro.utils.TOKEN 22 | import com.android.volley.* 23 | import com.android.volley.toolbox.StringRequest 24 | import com.android.volley.toolbox.Volley 25 | import kotlinx.android.synthetic.main.activity_new_post.* 26 | import org.json.JSONObject 27 | import uk.me.hardill.volley.multipart.MultipartRequest 28 | import java.io.ByteArrayOutputStream 29 | 30 | const private val PICK_IMAGE = 1 31 | 32 | class NewPostActivity : AppCompatActivity() { 33 | 34 | var progress: ProgressDialog? = null 35 | var replyPostID: Int? = null 36 | var imagesUploaded: Int = 0 37 | 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | setContentView(R.layout.activity_new_post) 41 | setSupportActionBar(toolbar) 42 | sendButton.isEnabled = false 43 | 44 | // Report that the user started a new post, so the new post shortcut gets shown to them by the OS 45 | val mgr = this.getSystemService(ShortcutManager::class.java) 46 | mgr.reportShortcutUsed("newpost") 47 | 48 | // Check for a post passed by an intent 49 | // This will happen if the New Post activity was opened to create a reply 50 | // Use the post data passed by the intent to populate the text box 51 | // with usernames to reply to, and to store the post ID to send to the API 52 | // when submitting the reply 53 | val author = intent.getStringExtra("@string/reply_intent_extra_author") 54 | Log.i("NewPostAct", "author: $author") 55 | if (author != null) { 56 | // this must be a reply, because we have an author to reply to 57 | val postID = intent.getIntExtra("@string/reply_intent_extra_postID", 0) 58 | if (postID != 0) { 59 | // postID could still be null, because there's a reply action on profile pages 60 | // that lets the user "reply" to the person whose profile they're looking at 61 | // but not to any particular post of theirs 62 | this.replyPostID = postID 63 | } 64 | var startText = "" 65 | startText += "@$author " 66 | val mentions = intent.getStringArrayListExtra("mentions") 67 | if (mentions != null) { 68 | for (mention in mentions) { 69 | startText += "$mention " 70 | } 71 | } 72 | Log.i("NewPost", "id: ${this.replyPostID}") 73 | editText.setText(startText) 74 | } 75 | 76 | editText.requestFocus() 77 | editText.onChange { 78 | Log.i("NewPost", "text changed to: $it") 79 | sendButton.isEnabled = it.isNotEmpty() 80 | } 81 | 82 | // Open the user's photo gallery app to let them choose an image 83 | photoButton.setOnClickListener { 84 | val getIntent = Intent(Intent.ACTION_GET_CONTENT) 85 | getIntent.type = "image/*" 86 | val pickIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) 87 | pickIntent.type = "image/*" 88 | val chooserIntent = Intent.createChooser(getIntent, "Select image") 89 | chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, Array(1) { pickIntent }) 90 | startActivityForResult(chooserIntent, PICK_IMAGE) 91 | } 92 | 93 | sendButton.setOnClickListener { view -> 94 | submitPost(view) 95 | } 96 | } 97 | 98 | private fun submitPost(view: View) { 99 | this.progress = spinner("Posting...") 100 | this.progress?.show() 101 | 102 | val text = editText.text.toString() 103 | val queue = Volley.newRequestQueue(this) 104 | val postUrl = "https://micro.blog/micropub" 105 | val replyUrl = "https://micro.blog/posts/reply" 106 | var url = postUrl 107 | 108 | // Use the reply URL with post ID appended if this post is a reply 109 | if (this.replyPostID != null) { 110 | url = replyUrl + "?id=$replyPostID" 111 | } 112 | 113 | val rq = object : StringRequest( 114 | Request.Method.POST, 115 | url, 116 | Response.Listener { response -> 117 | Log.i("MainActivity", "resp: $response") 118 | this.progress?.hide() 119 | Snackbar.make(view, "Success!", Snackbar.LENGTH_LONG).show() 120 | editText.setText("") 121 | this.progress?.dismiss() 122 | val intent = Intent() 123 | setResult(Activity.RESULT_OK, intent) 124 | this.finish() 125 | }, 126 | Response.ErrorListener { error -> 127 | Log.i("MainActivity", "err: $error msg: ${error.message}") 128 | this.progress?.hide() 129 | Snackbar.make(view, "Error: $error", Snackbar.LENGTH_LONG).show() 130 | // TODO: Handle error 131 | }) { 132 | @Throws(AuthFailureError::class) 133 | override fun getHeaders(): Map { 134 | val headers = HashMap() 135 | val prefs = this@NewPostActivity.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 136 | val token: String? = prefs?.getString(TOKEN, null) 137 | headers["Authorization"] = "Bearer $token" 138 | return headers 139 | } 140 | 141 | @Throws(AuthFailureError::class) 142 | override fun getParams(): Map { 143 | Log.i("MainActivity", "getParams") 144 | val params = HashMap() 145 | params["h"] = "entry" 146 | // If this is a reply, the content param name has to be "text" instead of "content" like a normal post 147 | if (this@NewPostActivity.replyPostID != null) { 148 | params["text"] = text 149 | } else { 150 | params["content"] = text 151 | } 152 | return params 153 | } 154 | } 155 | // set timeout to zero so Volley won't send multiple of the same request 156 | // seems like a Volley bug: https://groups.google.com/forum/#!topic/volley-users/8PE9dBbD6iA 157 | rq.retryPolicy = DefaultRetryPolicy(0, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) 158 | queue.add(rq) 159 | } 160 | 161 | 162 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 163 | super.onActivityResult(requestCode, resultCode, data) 164 | if (requestCode == PICK_IMAGE && resultCode == Activity.RESULT_OK && data != null) { 165 | Log.i("MainActivity", "User picked an image") 166 | 167 | val stream = contentResolver.openInputStream(data.data) 168 | val bitmap = BitmapFactory.decodeStream(stream) 169 | stream.close() 170 | val baos = ByteArrayOutputStream() 171 | bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos) 172 | val image = baos.toByteArray() 173 | postImage(image) 174 | } 175 | } 176 | 177 | fun spinner(message: String): ProgressDialog { 178 | val spinner = ProgressDialog(this) 179 | spinner.setMessage(message) 180 | spinner.isIndeterminate = true 181 | return spinner 182 | } 183 | 184 | private fun postImage(image: ByteArray) { 185 | this.progress = spinner("Uploading...") 186 | this.progress?.show() 187 | getMediaEndpoint(image) 188 | } 189 | 190 | fun uploadImage(endpoint: String, image: ByteArray) { 191 | val headers = HashMap() 192 | val prefs = this@NewPostActivity.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 193 | val token: String? = prefs?.getString(TOKEN, null) 194 | headers["Authorization"] = "Bearer $token" 195 | 196 | val rq = MultipartRequest(endpoint, 197 | headers, 198 | Response.Listener { 199 | if (it != null) { 200 | val data = String(it.data) 201 | Log.i("MainActivity", "Success! Resp: $data") 202 | val obj = JSONObject(data) 203 | if (obj["url"] != null) { 204 | this.imagesUploaded += 1 205 | sendButton.isEnabled = true 206 | val imgURL = obj["url"] as String 207 | editText.append("\n\n![]($imgURL)") 208 | this.progress?.hide() 209 | Snackbar.make(editText.rootView, "Attached image to your post.", Snackbar.LENGTH_SHORT).show() 210 | editText.requestFocus() 211 | } 212 | } 213 | }, 214 | Response.ErrorListener { 215 | this.progress?.hide() 216 | if (it.networkResponse != null) { 217 | Log.i("MainActivity", "Error: ${String(it.networkResponse.data)}") 218 | } else { 219 | Log.i("MainActivity", "Error without network response: ${it.message}") 220 | } 221 | }) 222 | rq.addPart(MultipartRequest.FilePart("file", "image/jpeg", "file", image)) 223 | val queue = Volley.newRequestQueue(this) 224 | queue.add(rq) 225 | } 226 | 227 | private fun getMediaEndpoint(image: ByteArray) { 228 | val url = "https://micro.blog/micropub?q=config" 229 | val rq = object : StringRequest( 230 | Request.Method.GET, 231 | url, 232 | Response.Listener { response -> 233 | Log.i("MainActivity", "resp: $response") 234 | val json = JSONObject(response) 235 | val endpoint = json["media-endpoint"] as String 236 | uploadImage(endpoint, image) 237 | }, 238 | Response.ErrorListener { error -> 239 | Log.i("MainActivity", "err: $error msg: ${error.message}") 240 | this.progress?.hide() 241 | // TODO: Handle error 242 | }) { 243 | @Throws(AuthFailureError::class) 244 | override fun getHeaders(): Map { 245 | val headers = HashMap() 246 | val prefs = this@NewPostActivity.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 247 | val token: String? = prefs?.getString(TOKEN, null) 248 | headers["Authorization"] = "Bearer $token" 249 | return headers 250 | } 251 | 252 | @Throws(AuthFailureError::class) 253 | override fun getParams(): Map { 254 | val params = HashMap() 255 | params["h"] = "entry" 256 | return params 257 | } 258 | } 259 | val queue = Volley.newRequestQueue(this) 260 | queue.add(rq) 261 | } 262 | 263 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 264 | // Inflate the menu; this adds items to the action bar if it is present. 265 | // menuInflater.inflate(R.menu.menu_main, menu) 266 | // return true 267 | return false 268 | } 269 | 270 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 271 | // Handle action bar item clicks here. The action bar will 272 | // automatically handle clicks on the Home/Up button, so long 273 | // as you specify a parent activity in AndroidManifest.xml. 274 | return when (item.itemId) { 275 | R.id.action_settings -> true 276 | else -> super.onOptionsItemSelected(item) 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/Post.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.content.Context 4 | import android.text.Html 5 | import android.text.Spanned 6 | import android.util.Log 7 | import org.json.JSONObject 8 | import java.text.SimpleDateFormat 9 | import java.util.* 10 | import android.text.Spannable 11 | import co.hellocode.micro.utils.CustomQuoteSpan 12 | import co.hellocode.micro.utils.HtmlTagHandler 13 | import co.hellocode.micro.utils.URLSpanNoUnderline 14 | import java.util.regex.Pattern 15 | import kotlin.collections.ArrayList 16 | 17 | 18 | class Post(val item: JSONObject) { 19 | val ID: Int 20 | val content: String 21 | val html: Spanned 22 | val authorName: String 23 | val authorAvatarURL: String 24 | val username: String 25 | var isConversation: Boolean = false 26 | val date: Date 27 | val mentions: ArrayList 28 | val imageSources: ArrayList = ArrayList() 29 | 30 | init { 31 | this.ID = (item["id"] as String).toInt() 32 | val text = item["content_html"] as String 33 | content = text.trim() 34 | this.html = Html.fromHtml(content) 35 | val mentions: ArrayList = arrayListOf() 36 | val regex = Regex("[@]\\w+") 37 | val all = regex.findAll(text) 38 | for (match in all) { 39 | mentions.add(match.value) 40 | } 41 | this.mentions = mentions 42 | val datePublished = item["date_published"] as String 43 | val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ssZ", Locale.ENGLISH) 44 | this.date = dateFormat.parse(datePublished) 45 | val author = (item["author"] as JSONObject) 46 | this.authorName = author.getString("name") 47 | this.authorAvatarURL = author.getString("avatar") 48 | this.username = (author["_microblog"] as JSONObject).getString("username") 49 | val microblogData = (item["_microblog"] as JSONObject) 50 | this.isConversation = microblogData.getBoolean("is_conversation") 51 | 52 | } 53 | 54 | fun getParsedContent(c: Context) : Spannable { 55 | // remove images for now 56 | var parsed = content //.replaceAll("", ""); 57 | 58 | // find images and store sources to add and download 59 | val patt2 = Pattern.compile("]*>", Pattern.DOTALL or Pattern.CASE_INSENSITIVE) 60 | val m2 = patt2.matcher(parsed) 61 | imageSources.clear() 62 | 63 | while (m2.find()) { 64 | Log.i("image parsing", m2.group(0)) 65 | imageSources.add(Html.fromHtml(m2.group(1)).toString()) 66 | parsed = parsed.replace(m2.group(0), "") 67 | } 68 | 69 | // replace tag links with coloured tags 70 | // val patt = Pattern.compile("]*>#([^<]*)") 71 | // val m = patt.matcher(parsed) 72 | // while (m.find()) { 73 | // Log.i("parsing", m.group(0)) 74 | // parsed = parsed.replace(m.group(0), "#" + m.group(1)) 75 | // } 76 | 77 | var newContent: Spannable 78 | 79 | try { 80 | newContent = Html.fromHtml(parsed, null, HtmlTagHandler(c)) as Spannable 81 | } catch (e: RuntimeException) { 82 | Log.e("html", "failed to parse", e) 83 | Log.d("html", parsed) 84 | newContent = Html.fromHtml("

Error parsing content

", null, HtmlTagHandler(c)) as Spannable 85 | } 86 | 87 | newContent = trimTrailingWhitespace(newContent) as Spannable 88 | newContent = URLSpanNoUnderline.removeUnderlines(newContent, c) 89 | CustomQuoteSpan.replaceQuoteSpans(newContent, c) 90 | 91 | 92 | return newContent 93 | } 94 | 95 | fun trimTrailingWhitespace(source: CharSequence?): CharSequence { 96 | 97 | if (source == null) 98 | return "" 99 | 100 | var i = source.length 101 | 102 | // loop back to the first non-whitespace character 103 | while (--i >= 0 && Character.isWhitespace(source[i])) { 104 | } 105 | 106 | return source.subSequence(0, i + 1) 107 | } 108 | 109 | // val imageGetter = Html.ImageGetter { 110 | // for (img in it.getSpans(0, 111 | // it.length(), ImageSpan::class.java)) { 112 | // Picasso.get().load(img.source) 113 | // } 114 | // } 115 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/PostViewHolder.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.content.Intent 4 | import android.text.format.DateUtils 5 | import android.text.method.LinkMovementMethod 6 | import android.util.Log 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import android.widget.ImageView 11 | import co.hellocode.micro.newpost.NewPostActivity 12 | import co.hellocode.micro.utils.inflate 13 | import com.squareup.picasso.Picasso 14 | import jp.wasabeef.picasso.transformations.CropCircleTransformation 15 | import kotlinx.android.synthetic.main.layout_post_image.view.* 16 | import kotlinx.android.synthetic.main.timeline_item.view.* 17 | 18 | class PostViewHolder(parent: ViewGroup, private var canShowConversations: Boolean) 19 | : BaseViewHolder(parent, R.layout.timeline_item) { 20 | 21 | //private var view: View = parent.inflate(R.layout.timeline_item, false) 22 | private var post: Post? = null 23 | 24 | init { 25 | Log.i("PostViewHolder", "init") 26 | if (this.canShowConversations) { 27 | rootView.setOnClickListener { 28 | postDetailIntent(it) 29 | } 30 | rootView.itemText.setOnClickListener { 31 | postDetailIntent(it) 32 | } 33 | } 34 | rootView.setOnLongClickListener { 35 | if (post == null) { 36 | return@setOnLongClickListener false 37 | } 38 | newPostIntent(it) 39 | true 40 | } 41 | rootView.itemText.setOnLongClickListener { 42 | if (post == null) { 43 | return@setOnLongClickListener false 44 | } 45 | newPostIntent(it) 46 | true 47 | } 48 | rootView.avatar.setOnClickListener { 49 | avatarClick(it) 50 | } 51 | } 52 | 53 | private fun avatarClick(view: View) { 54 | if (this.post?.username == null) { 55 | return 56 | } 57 | val intent = Intent(view.context, ProfileActivity::class.java) 58 | intent.putExtra("username", this.post?.username) 59 | view.context.startActivity(intent) 60 | } 61 | 62 | private fun newPostIntent(view: View) { 63 | val intent = Intent(view.context, NewPostActivity::class.java) 64 | intent.putExtra("@string/reply_intent_extra_postID", this.post?.ID) 65 | intent.putExtra("@string/reply_intent_extra_author", this.post?.username) 66 | if (this.post?.mentions != null) { 67 | intent.putStringArrayListExtra("mentions", this.post?.mentions) 68 | } 69 | view.context.startActivity(intent) 70 | } 71 | 72 | private fun postDetailIntent(view: View) { 73 | val intent = Intent(view.context, ConversationActivity::class.java) 74 | intent.putExtra("@string/reply_intent_extra_postID", this.post?.ID) 75 | intent.putExtra("@string/reply_intent_extra_author", this.post?.username) 76 | if (this.post?.mentions != null) { 77 | intent.putStringArrayListExtra("@string/reply_intent_extra_mentions", this.post?.mentions) 78 | } 79 | view.context.startActivity(intent) 80 | } 81 | 82 | override fun bindItem(item: Post) { 83 | Log.i("PostViewHolder", "bindItem") 84 | this.post = item 85 | // remove any image views leftover from reusing views 86 | for (i in 0 until rootView.post_layout.childCount) { 87 | val v = rootView.post_layout.getChildAt(i) 88 | if (v is ImageView) { 89 | rootView.post_layout.removeViewAt(i) 90 | } 91 | } 92 | // and remove user avatar image 93 | rootView.avatar.setImageDrawable(null) 94 | 95 | rootView.itemText.setOnClickListener { v -> 96 | if (this.canShowConversations) { 97 | postDetailIntent(v) 98 | } 99 | } 100 | 101 | Log.i("PostViewHolder", "${item.content}, ${item.authorName}") 102 | rootView.itemText.text = item.getParsedContent(rootView.context) 103 | rootView.itemText.movementMethod = LinkMovementMethod.getInstance() // make links open in browser when tapped 104 | rootView.author.text = item.authorName 105 | rootView.username.text = "@${item.username}" 106 | if (!item.isConversation) { 107 | rootView.conversationButton.visibility = View.GONE 108 | } else { 109 | rootView.conversationButton.visibility = View.VISIBLE 110 | } 111 | 112 | rootView.timestamp.text = DateUtils.getRelativeTimeSpanString(rootView.context, item.date.time) 113 | 114 | val picasso = Picasso.get() 115 | // picasso.setIndicatorsEnabled(true) // Uncomment this line to see coloured corners on images, indicating where they're loading from 116 | // Red = network, blue = disk, green = memory 117 | picasso.load(item.authorAvatarURL).transform(CropCircleTransformation()).into(rootView.avatar) 118 | 119 | for (i in item.imageSources) { 120 | val imageView = LayoutInflater.from(rootView.context).inflate( 121 | R.layout.layout_post_image, 122 | null, 123 | false 124 | ) 125 | rootView.post_layout.addView(imageView) 126 | picasso.load(i).into(imageView.post_image) 127 | } 128 | 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/ProfileActivity.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.SharedPreferences 6 | import android.support.v7.app.AppCompatActivity 7 | import android.os.Bundle 8 | import android.support.design.widget.AppBarLayout 9 | import android.support.design.widget.Snackbar 10 | import android.support.v4.widget.SwipeRefreshLayout 11 | import android.support.v7.widget.LinearLayoutManager 12 | import android.util.Log 13 | import android.view.View 14 | import co.hellocode.micro.newpost.NewPostActivity 15 | import co.hellocode.micro.utils.NEW_POST_REQUEST_CODE 16 | import co.hellocode.micro.utils.PREFS_FILENAME 17 | import co.hellocode.micro.utils.TOKEN 18 | import com.android.volley.AuthFailureError 19 | import com.android.volley.Request 20 | import com.android.volley.Response 21 | import com.android.volley.TimeoutError 22 | import com.android.volley.toolbox.JsonObjectRequest 23 | import com.android.volley.toolbox.StringRequest 24 | import com.android.volley.toolbox.Volley 25 | import com.squareup.picasso.Picasso 26 | import jp.wasabeef.picasso.transformations.CropCircleTransformation 27 | import kotlinx.android.synthetic.main.activity_main.* 28 | import kotlinx.android.synthetic.main.activity_profile_collapsing.* 29 | import org.json.JSONArray 30 | import org.json.JSONObject 31 | import java.util.ArrayList 32 | import java.util.HashMap 33 | 34 | class ProfileActivity : AppCompatActivity() { 35 | 36 | var url = "https://micro.blog/posts/" 37 | var title = "" 38 | private lateinit var linearLayoutManager: LinearLayoutManager 39 | open lateinit var adapter: TimelineRecyclerAdapter 40 | open var posts = ArrayList() 41 | private lateinit var refresh: SwipeRefreshLayout 42 | var following = false 43 | lateinit var username: String 44 | 45 | override fun onCreate(savedInstanceState: Bundle?) { 46 | super.onCreate(savedInstanceState) 47 | setContentView(contentView()) 48 | setSupportActionBar(toolbar) 49 | supportActionBar?.title = title 50 | this.linearLayoutManager = LinearLayoutManager(this) 51 | profile_recyclerView.layoutManager = this.linearLayoutManager 52 | this.adapter = TimelineRecyclerAdapter(this.posts) 53 | profile_recyclerView.adapter = this.adapter 54 | Log.i("BaseTimeline", "recycler: $profile_recyclerView") 55 | collapsing_toolbar.setCollapsedTitleTextColor(resources.getColor(R.color.colorWhite)) 56 | refresh = profile_refresher 57 | refresh.setOnRefreshListener { refresh() } 58 | initialLoad() 59 | } 60 | 61 | fun initialLoad() { 62 | this.username = intent.getStringExtra("username") 63 | this.url = this.url + this.username 64 | this.refresh.isRefreshing = true 65 | refresh() 66 | } 67 | 68 | private fun refresh() { 69 | Log.i("BaseTimeline", "refresh") 70 | getTimeline() 71 | } 72 | 73 | fun contentView(): Int { 74 | return R.layout.activity_profile_collapsing 75 | } 76 | 77 | fun prefs() : SharedPreferences { 78 | return getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 79 | } 80 | 81 | private fun getTimeline() { 82 | Log.i("BaseTimeline", "getTimeline") 83 | val rq = object : JsonObjectRequest( 84 | this.url, 85 | null, 86 | Response.Listener { response -> 87 | // Log.i("MainActivity", "resp: $response") 88 | val items = response["items"] as JSONArray 89 | createPosts(items) 90 | getRequestComplete(response) 91 | this.adapter.notifyDataSetChanged() 92 | this.refresh.isRefreshing = false 93 | }, 94 | Response.ErrorListener { error -> 95 | Log.i("MainActivity", "err: $error msg: ${error.message}") 96 | this.refresh.isRefreshing = false 97 | if (error is TimeoutError) { 98 | Snackbar.make(this.profile_recyclerView, "Request timed out; trying again", Snackbar.LENGTH_SHORT) 99 | this.getTimeline() 100 | } 101 | }) { 102 | @Throws(AuthFailureError::class) 103 | override fun getHeaders(): Map { 104 | val headers = HashMap() 105 | val prefs = prefs() 106 | val token: String? = prefs.getString(TOKEN, null) 107 | headers["Authorization"] = "Bearer $token" 108 | return headers 109 | } 110 | } 111 | val queue = Volley.newRequestQueue(this) 112 | queue.add(rq) 113 | } 114 | 115 | fun createPosts(items: JSONArray) { 116 | this.posts.clear() 117 | for (i in 0 until items.length()) { 118 | val item = items[i] as JSONObject 119 | this.posts.add(Post(item)) 120 | } 121 | } 122 | 123 | fun getRequestComplete(response: JSONObject) { 124 | val author = response.getJSONObject("author") 125 | val microBlog = response.getJSONObject("_microblog") 126 | setProfileData(author, microBlog) 127 | collapsing_profile_follow_button.setOnClickListener { followButtonTapped(this.username) } 128 | setToolbarTitle(this.username) 129 | setFABListener() 130 | } 131 | 132 | fun setProfileData(author: JSONObject, microBlogData: JSONObject) { 133 | collapsing_profile_name_view.text = author.getString("name") 134 | val website = author.getString("url") 135 | if (website.length > 0) { 136 | collapsing_profile_website.text = website 137 | } else { 138 | collapsing_profile_website.visibility = View.GONE 139 | } 140 | val avatarURL = author.getString("avatar") 141 | Picasso.get().load(avatarURL).transform(CropCircleTransformation()).into(collapsing_profile_avatar) 142 | collapsing_profile_username.text = microBlogData.getString("username") 143 | 144 | val bio = microBlogData.getString("bio") 145 | if (bio.length > 0) { 146 | collapsing_profile_bio.text = bio 147 | } else { 148 | collapsing_profile_bio.visibility = View.GONE 149 | } 150 | val isYou = microBlogData.getBoolean("is_you") 151 | if (isYou) { 152 | collapsing_profile_follow_button.visibility = View.GONE 153 | } else { 154 | following = microBlogData.getBoolean("is_following") 155 | collapsing_profile_follow_button.text = if (this.following == true) "Unfollow" else "Follow" 156 | } 157 | 158 | } 159 | 160 | fun setFABListener() { 161 | profile_fab.setOnClickListener { 162 | val intent = Intent(this, NewPostActivity::class.java) 163 | Log.i("ProfileAct", "author: $username") 164 | intent.putExtra("@string/reply_intent_extra_author", this.username) 165 | startActivityForResult(intent, NEW_POST_REQUEST_CODE) 166 | } 167 | } 168 | 169 | private fun setToolbarTitle(username: String) { 170 | collapsing_profile_appbar.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { 171 | var isShown = false 172 | var scrollRange = -1 173 | 174 | override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { 175 | if (scrollRange == -1) { 176 | scrollRange = appBarLayout.totalScrollRange 177 | } 178 | Log.i("ProfileAct", "scroll range: $scrollRange offset: $verticalOffset") 179 | if (scrollRange + verticalOffset == 0) { 180 | collapsing_profile_view.visibility = View.INVISIBLE 181 | collapsing_toolbar.title = username 182 | isShown = true 183 | } else { 184 | collapsing_profile_view.visibility = View.VISIBLE 185 | collapsing_toolbar.title = " " 186 | isShown = false 187 | } 188 | 189 | } 190 | }) 191 | } 192 | 193 | private fun followButtonTapped(username: String) { 194 | val followURL = if (this.following) { 195 | "https://micro.blog/users/unfollow" 196 | } else { 197 | "https://micro.blog/users/follow" 198 | } 199 | val rq = object : StringRequest( 200 | Request.Method.POST, 201 | followURL, 202 | Response.Listener { response -> 203 | if (this.following) { 204 | // this.following is already true, so we just unfollowed 205 | collapsing_profile_follow_button.text = "Follow" 206 | this.following = false 207 | } else { 208 | // this.following is false, so we just followed 209 | collapsing_profile_follow_button.text = "Unfollow" 210 | this.following = true 211 | } 212 | }, 213 | Response.ErrorListener { error -> 214 | Log.i("ProfileAct", "err following or unfollowing: $error msg: ${error.message}") 215 | }) { 216 | @Throws(AuthFailureError::class) 217 | override fun getHeaders(): Map { 218 | val headers = HashMap() 219 | val prefs = prefs() 220 | val token: String? = prefs.getString(TOKEN, null) 221 | headers["Authorization"] = "Bearer $token" 222 | return headers 223 | } 224 | 225 | @Throws(AuthFailureError::class) 226 | override fun getParams(): Map { 227 | Log.i("MainActivity", "getParams") 228 | val params = HashMap() 229 | params["username"] = username 230 | return params 231 | } 232 | } 233 | val queue = Volley.newRequestQueue(this) 234 | queue.add(rq) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/TimelineRecyclerAdapter.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro 2 | 3 | import android.content.Intent 4 | import android.support.v7.widget.RecyclerView 5 | import android.text.format.DateUtils 6 | import android.text.method.LinkMovementMethod 7 | import android.util.Log 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import android.widget.ImageView 12 | import co.hellocode.micro.newpost.NewPostActivity 13 | import co.hellocode.micro.utils.inflate 14 | import com.squareup.picasso.Picasso 15 | import jp.wasabeef.picasso.transformations.CropCircleTransformation 16 | import kotlinx.android.synthetic.main.layout_post_image.view.* 17 | import kotlinx.android.synthetic.main.timeline_item.view.* 18 | 19 | 20 | open class TimelineRecyclerAdapter(private val posts: ArrayList, private val canShowConversations: Boolean = true) : RecyclerView.Adapter() { 21 | 22 | override fun onCreateViewHolder(p0: ViewGroup, p1: Int): TimelineRecyclerAdapter.PostHolder { 23 | val inflatedView = p0.inflate(R.layout.timeline_item, false) 24 | return PostHolder(inflatedView, canShowConversations) 25 | } 26 | 27 | override fun getItemCount() = posts.size 28 | 29 | override fun onBindViewHolder(p0: TimelineRecyclerAdapter.PostHolder, p1: Int) { 30 | val itemPost = posts[p1] 31 | p0.bindPost(itemPost) 32 | } 33 | 34 | class PostHolder(v: View, private var canShowConversations: Boolean) : RecyclerView.ViewHolder(v), View.OnClickListener { 35 | private var view: View = v 36 | private var post: Post? = null 37 | 38 | init { 39 | if (this.canShowConversations) { 40 | v.setOnClickListener(this) 41 | } 42 | v.setOnLongClickListener { 43 | if (post == null) { 44 | return@setOnLongClickListener false 45 | } 46 | newPostIntent(it) 47 | true 48 | } 49 | v.itemText.setOnLongClickListener { 50 | if (post == null) { 51 | return@setOnLongClickListener false 52 | } 53 | newPostIntent(it) 54 | true 55 | } 56 | v.avatar.setOnClickListener { 57 | avatarClick(it) 58 | } 59 | } 60 | 61 | private fun avatarClick(view: View) { 62 | if (this.post?.username == null) { 63 | return 64 | } 65 | val intent = Intent(view.context, ProfileActivity::class.java) 66 | intent.putExtra("username", this.post?.username) 67 | view.context.startActivity(intent) 68 | } 69 | 70 | override fun onClick(v: View) { 71 | if (this.canShowConversations) { 72 | postDetailIntent(v) 73 | } 74 | } 75 | 76 | private fun newPostIntent(view: View) { 77 | val intent = Intent(view.context, NewPostActivity::class.java) 78 | intent.putExtra("@string/reply_intent_extra_postID", this.post?.ID) 79 | intent.putExtra("@string/reply_intent_extra_author", this.post?.username) 80 | if (this.post?.mentions != null) { 81 | intent.putStringArrayListExtra("@string/reply_intent_extra_mentions", this.post?.mentions) 82 | } 83 | view.context.startActivity(intent) 84 | } 85 | 86 | private fun postDetailIntent(view: View) { 87 | val intent = Intent(view.context, ConversationActivity::class.java) 88 | intent.putExtra("@string/reply_intent_extra_postID", this.post?.ID) 89 | intent.putExtra("@string/reply_intent_extra_author", this.post?.username) 90 | if (this.post?.mentions != null) { 91 | intent.putStringArrayListExtra("@string/reply_intent_extra_mentions", this.post?.mentions) 92 | } 93 | view.context.startActivity(intent) 94 | } 95 | 96 | fun bindPost(post: Post) { 97 | // remove any image views leftover from reusing views 98 | for (i in 0 until view.post_layout.childCount) { 99 | val v = view.post_layout.getChildAt(i) 100 | if (v is ImageView) { 101 | view.post_layout.removeViewAt(i) 102 | } 103 | } 104 | // and remove user avatar image 105 | view.avatar.setImageDrawable(null) 106 | 107 | view.itemText.setOnClickListener(View.OnClickListener { v -> 108 | if (this.canShowConversations) { 109 | postDetailIntent(v) 110 | } 111 | }) 112 | 113 | this.post = post 114 | view.itemText.text = post.getParsedContent(view.context) 115 | view.itemText.movementMethod = LinkMovementMethod.getInstance() // make links open in browser when tapped 116 | view.author.text = post.authorName 117 | view.username.text = "@${post.username}" 118 | if (!post.isConversation) { 119 | view.conversationButton.visibility = View.GONE 120 | } else { 121 | view.conversationButton.visibility = View.VISIBLE 122 | } 123 | 124 | view.timestamp.text = DateUtils.getRelativeTimeSpanString(view.context, post.date.time) 125 | 126 | val picasso = Picasso.get() 127 | // picasso.setIndicatorsEnabled(true) // Uncomment this line to see coloured corners on images, indicating where they're loading from 128 | // Red = network, blue = disk, green = memory 129 | picasso.load(post.authorAvatarURL).transform(CropCircleTransformation()).into(view.avatar) 130 | 131 | for (i in post.imageSources) { 132 | val imageView = LayoutInflater.from(view.context).inflate( 133 | R.layout.layout_post_image, 134 | null, 135 | false 136 | ) 137 | view.post_layout.addView(imageView) 138 | picasso.load(i).into(imageView.post_image) 139 | } 140 | } 141 | 142 | companion object { 143 | private val POST_KEY = "POST" 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/extensions/EditTextExtensions.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.extensions 2 | 3 | import android.text.Editable 4 | import android.text.TextWatcher 5 | import android.widget.EditText 6 | 7 | fun EditText.onChange(cb: (String) -> Unit) { 8 | this.addTextChangedListener(object: TextWatcher { 9 | override fun afterTextChanged(s: Editable?) { cb(s.toString()) } 10 | override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} 11 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} 12 | }) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/services/APIService.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.services 2 | 3 | import android.content.Context 4 | import co.hellocode.micro.utils.PREFS_FILENAME 5 | import co.hellocode.micro.utils.TOKEN 6 | import com.android.volley.AuthFailureError 7 | import com.android.volley.DefaultRetryPolicy 8 | import com.android.volley.Request 9 | import com.android.volley.Response 10 | import com.android.volley.toolbox.JsonObjectRequest 11 | import com.android.volley.toolbox.StringRequest 12 | import com.android.volley.toolbox.Volley 13 | import kotlinx.android.synthetic.main.activity_new_post.* 14 | import org.json.JSONArray 15 | import org.json.JSONObject 16 | import java.util.HashMap 17 | 18 | const val TIMELINE_URL = "" 19 | const val MENTIONS_URL = "" 20 | const val DISCOVER_URL = "" 21 | const val PHOTOS_URL = "" 22 | const val CONVERSATION_URL = "" 23 | const val PROFILE_URL = "" 24 | const val FOLLOW_URL = "" 25 | const val UNFOLLOW_URL = "" 26 | const val NEW_POST_URL = "" 27 | const val REPLY_URL = "" 28 | 29 | sealed class GetEndpoint { 30 | class Timeline : GetEndpoint() 31 | class Mentions : GetEndpoint() 32 | class Discover : GetEndpoint() 33 | class Photos : GetEndpoint() 34 | class Profile(val username: String) : GetEndpoint() 35 | class Conversation(val postID: Int) : GetEndpoint() 36 | } 37 | 38 | sealed class PostEndpoint { 39 | class NewPost(val content: String) : PostEndpoint() 40 | class Follow(val username: String) : PostEndpoint() 41 | class Unfollow(val username: String) : PostEndpoint() 42 | class Reply(val postID: Int, val content: String) : PostEndpoint() 43 | } 44 | 45 | class APIService { 46 | 47 | public fun get(endpoint: GetEndpoint, listener: Response.Listener, errorListener: Response.ErrorListener, context: Context) { 48 | var url = "https://micro.blog/posts/all" 49 | when (endpoint) { 50 | is GetEndpoint.Timeline -> { 51 | url = "https://micro.blog/posts/all" 52 | } 53 | is GetEndpoint.Mentions -> { 54 | url = "https://micro.blog/posts/mentions" 55 | } 56 | is GetEndpoint.Discover -> { 57 | url = "https://micro.blog/posts/discover" 58 | } 59 | is GetEndpoint.Photos -> { 60 | url = "https://micro.blog/posts/photos" 61 | } 62 | is GetEndpoint.Profile -> { 63 | url = "https://micro.blog/posts/" + endpoint.username 64 | } 65 | is GetEndpoint.Conversation -> { 66 | url = "https://micro.blog/posts/conversation?id=" + endpoint.postID 67 | } 68 | } 69 | get(url, listener, errorListener, context) 70 | } 71 | 72 | public fun postTo(endpoint: PostEndpoint, listener: Response.Listener, errorListener: Response.ErrorListener, context: Context) { 73 | var url = "https://micro.blog/micropub" 74 | val params = HashMap() 75 | when (endpoint) { 76 | is PostEndpoint.NewPost -> { 77 | url = "https://micro.blog/micropub" 78 | params["h"] = "entry" 79 | params["content"] = endpoint.content 80 | } 81 | is PostEndpoint.Follow -> { 82 | url = "https://micro.blog/users/follow" 83 | params["username"] = endpoint.username 84 | } 85 | is PostEndpoint.Unfollow -> { 86 | url = "https://micro.blog/users/unfollow" 87 | params["username"] = endpoint.username 88 | } 89 | is PostEndpoint.Reply -> { 90 | url = "https://micro.blog/posts/reply?id=" + endpoint.postID 91 | params["h"] = "entry" 92 | params["text"] = endpoint.content 93 | } 94 | } 95 | post(url, params, listener, errorListener, context) 96 | } 97 | 98 | private fun get(url: String, listener: Response.Listener, errorListener: Response.ErrorListener, context: Context) { 99 | val rq = object : JsonObjectRequest( 100 | url, 101 | null, 102 | listener, 103 | errorListener 104 | ) { 105 | @Throws(AuthFailureError::class) 106 | override fun getHeaders(): Map { 107 | val headers = HashMap() 108 | val prefs = context.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 109 | val token: String? = prefs?.getString(TOKEN, null) 110 | headers["Authorization"] = "Bearer $token" 111 | return headers 112 | } 113 | } 114 | // set timeout to zero so Volley won't send multiple of the same request 115 | // seems like a Volley bug: https://groups.google.com/forum/#!topic/volley-users/8PE9dBbD6iA 116 | rq.retryPolicy = DefaultRetryPolicy(0, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) 117 | val queue = Volley.newRequestQueue(context) 118 | queue.add(rq) 119 | } 120 | 121 | private fun post(toUrl: String, params: HashMap, listener: Response.Listener, errorListener: Response.ErrorListener, context: Context) { 122 | val rq = object : StringRequest( 123 | Request.Method.POST, 124 | toUrl, 125 | listener, 126 | errorListener 127 | ) { 128 | @Throws(AuthFailureError::class) 129 | override fun getHeaders(): Map { 130 | val headers = HashMap() 131 | val prefs = context.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 132 | val token: String? = prefs?.getString(TOKEN, null) 133 | headers["Authorization"] = "Bearer $token" 134 | return headers 135 | } 136 | 137 | @Throws(AuthFailureError::class) 138 | override fun getParams(): Map { 139 | return params 140 | } 141 | } 142 | // set timeout to zero so Volley won't send multiple of the same request 143 | // seems like a Volley bug: https://groups.google.com/forum/#!topic/volley-users/8PE9dBbD6iA 144 | rq.retryPolicy = DefaultRetryPolicy(0, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) 145 | val queue = Volley.newRequestQueue(context) 146 | queue.add(rq) 147 | } 148 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/tablayout/TabAdapter.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.tablayout 2 | 3 | import android.support.v4.app.Fragment 4 | import android.support.v4.app.FragmentManager 5 | import android.support.v4.app.FragmentPagerAdapter 6 | import co.hellocode.micro.tablayout.fragments.DiscoverFragment 7 | import co.hellocode.micro.tablayout.fragments.MediaFragment 8 | import co.hellocode.micro.tablayout.fragments.MentionsFragment 9 | import co.hellocode.micro.tablayout.fragments.TimelineFragment 10 | 11 | class TabAdapter(fm: FragmentManager) : FragmentPagerAdapter(fm) { 12 | 13 | override fun getItem(position: Int): Fragment? = when (position) { 14 | 0 -> TimelineFragment.newInstance() 15 | 1 -> MentionsFragment.newInstance() 16 | 2 -> DiscoverFragment.newInstance() 17 | 3 -> MediaFragment.newInstance() 18 | else -> null 19 | } 20 | 21 | override fun getPageTitle(position: Int): CharSequence = when (position) { 22 | 0 -> "Timeline" 23 | 1 -> "Mentions" 24 | 2 -> "Discover" 25 | 3 -> "Photos" 26 | else -> "" 27 | } 28 | 29 | override fun getCount(): Int = 4 30 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/tablayout/fragments/BaseTimelineFragment.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.tablayout.fragments 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.os.Bundle 6 | import android.support.v4.app.Fragment 7 | import android.support.v4.widget.SwipeRefreshLayout 8 | import android.support.v7.widget.LinearLayoutManager 9 | import android.util.Log 10 | import android.view.LayoutInflater 11 | import android.view.View 12 | import android.view.ViewGroup 13 | import co.hellocode.micro.* 14 | import co.hellocode.micro.utils.PREFS_FILENAME 15 | import co.hellocode.micro.utils.TOKEN 16 | import com.android.volley.AuthFailureError 17 | import com.android.volley.Response 18 | import com.android.volley.toolbox.JsonObjectRequest 19 | import com.android.volley.toolbox.Volley 20 | import kotlinx.android.synthetic.main.baselayout_timeline.view.* 21 | import org.json.JSONArray 22 | import org.json.JSONObject 23 | import java.util.ArrayList 24 | import java.util.HashMap 25 | 26 | open class BaseTimelineFragment: Fragment() { 27 | open var url = "https://micro.blog/posts/all" 28 | open var title = "Timeline" 29 | open lateinit var linearLayoutManager: LinearLayoutManager 30 | open lateinit var adapter: BaseRecyclerAdapter 31 | open var posts = ArrayList() 32 | open lateinit var refresh: SwipeRefreshLayout 33 | 34 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 35 | val view = inflater.inflate(R.layout.baselayout_timeline, container, false) 36 | this.linearLayoutManager = LinearLayoutManager(context) 37 | view.recyclerView.layoutManager = this.linearLayoutManager 38 | this.adapter = BaseRecyclerAdapter({ PostViewHolder(it, true) }, this.posts) 39 | view.recyclerView.adapter = this.adapter 40 | this.refresh = view.refresher 41 | this.refresh.setOnRefreshListener { refresh() } 42 | return view 43 | } 44 | 45 | override fun onStart() { 46 | super.onStart() 47 | Log.i("TimelineFragment", "onStart") 48 | if (this.posts.count() == 0){ 49 | initialLoad() 50 | } 51 | } 52 | 53 | open fun initialLoad() { 54 | Log.i("BaseTimeline", "initialLoad") 55 | this.refresh.isRefreshing = true 56 | refresh() 57 | } 58 | 59 | open fun refresh() { 60 | Log.i("BaseTimeline", "refresh") 61 | getTimeline() 62 | } 63 | 64 | fun prefs() : SharedPreferences? { 65 | return this.activity?.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) 66 | } 67 | 68 | open fun getTimeline(url: String = this.url) { 69 | Log.i("BaseTimeline", "getTimeline url: $url") 70 | val rq = object : JsonObjectRequest( 71 | this.url, 72 | null, 73 | Response.Listener { response -> 74 | Log.i("BaseTimelineFrag", "resp: $response") 75 | val items = response["items"] as JSONArray 76 | this.posts.clear() 77 | for (i in 0 until items.length()) { 78 | val item = items[i] as JSONObject 79 | this.posts.add(Post(item)) 80 | } 81 | this.adapter.notifyDataSetChanged() 82 | this.refresh.isRefreshing = false 83 | }, 84 | Response.ErrorListener { error -> 85 | Log.i("BaseTimelineFrag", "err: $error") 86 | if (error.networkResponse != null && error.networkResponse.data != null) { 87 | Log.i("BaseTimelineFrag", "error is: ${error.networkResponse} msg: ${error.networkResponse.data.toString()}") 88 | } 89 | this.refresh.isRefreshing = false 90 | 91 | // TODO: Handle error 92 | }) { 93 | @Throws(AuthFailureError::class) 94 | override fun getHeaders(): Map { 95 | Log.i("BaseTimelineFrag", "getHeaders") 96 | val headers = HashMap() 97 | val prefs = prefs() 98 | val token: String? = prefs?.getString(TOKEN, null) 99 | headers["Authorization"] = "Bearer $token" 100 | return headers 101 | } 102 | } 103 | val queue = Volley.newRequestQueue(this.activity) 104 | queue.add(rq) 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/tablayout/fragments/DiscoverFragment.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.tablayout.fragments 2 | 3 | class DiscoverFragment : BaseTimelineFragment() { 4 | 5 | override var url = "https://micro.blog/posts/discover" 6 | override open var title = "Discover" 7 | 8 | companion object { 9 | fun newInstance(): DiscoverFragment = DiscoverFragment() 10 | } 11 | 12 | override fun refresh() { 13 | getTimeline(this.url) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/tablayout/fragments/MediaFragment.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.tablayout.fragments 2 | 3 | import android.os.Bundle 4 | import android.support.v7.widget.LinearLayoutManager 5 | import android.util.Log 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import co.hellocode.micro.* 10 | import co.hellocode.micro.utils.inflate 11 | import kotlinx.android.synthetic.main.baselayout_timeline.view.* 12 | 13 | class MediaFragment : BaseTimelineFragment() { 14 | 15 | override var url = "https://micro.blog/posts/photos" 16 | override var title = "Photos" 17 | 18 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 19 | val view = inflater.inflate(R.layout.baselayout_timeline, container, false) 20 | this.linearLayoutManager = LinearLayoutManager(context) 21 | view.recyclerView.layoutManager = this.linearLayoutManager 22 | this.adapter = BaseRecyclerAdapter({ MediaPostViewHolder(container!!, true) }, this.posts) 23 | view.recyclerView.adapter = this.adapter 24 | this.refresh = view.refresher 25 | this.refresh.setOnRefreshListener { refresh() } 26 | return view 27 | } 28 | 29 | companion object { 30 | fun newInstance(): MediaFragment = MediaFragment() 31 | } 32 | 33 | override fun refresh() { 34 | getTimeline(this.url) 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/tablayout/fragments/MentionsFragment.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.tablayout.fragments 2 | 3 | import android.util.Log 4 | 5 | class MentionsFragment : BaseTimelineFragment() { 6 | 7 | override var url = "https://micro.blog/posts/mentions" 8 | override open var title = "Mentions" 9 | 10 | companion object { 11 | fun newInstance(): MentionsFragment = MentionsFragment() 12 | } 13 | 14 | override fun refresh() { 15 | Log.i("MentionsFrag", "url: ${ this.url }") 16 | getTimeline(this.url) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/tablayout/fragments/TimelineFragment.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.tablayout.fragments 2 | 3 | class TimelineFragment : BaseTimelineFragment() { 4 | 5 | override var url = "https://micro.blog/posts/all" 6 | override open var title = "Timeline" 7 | 8 | companion object { 9 | fun newInstance(): TimelineFragment = TimelineFragment() 10 | } 11 | 12 | override fun refresh() { 13 | getTimeline(this.url) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | const val PREFS_FILENAME = "co.hellocode.micro.prefs" 4 | const val TOKEN = "token" 5 | const val NEW_POST_REQUEST_CODE = 13 -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/CustomQuoteSpan.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | 4 | import android.content.Context 5 | import android.graphics.Canvas 6 | import android.graphics.Paint 7 | import android.text.Layout 8 | import android.text.Spannable 9 | import android.text.style.LeadingMarginSpan 10 | import android.text.style.LineBackgroundSpan 11 | import android.text.style.QuoteSpan 12 | import co.hellocode.micro.R 13 | 14 | /** 15 | * Created by Josh on 2/12/2015. 16 | */ 17 | class CustomQuoteSpan(private val backgroundColor: Int, private val stripeColor: Int, private val stripeWidth: Float, private val gap: Float) : LeadingMarginSpan, LineBackgroundSpan { 18 | 19 | override fun getLeadingMargin(first: Boolean): Int { 20 | return (stripeWidth + gap).toInt() 21 | } 22 | 23 | override fun drawLeadingMargin(c: Canvas, p: Paint, x: Int, dir: Int, top: Int, baseline: Int, bottom: Int, 24 | text: CharSequence, start: Int, end: Int, first: Boolean, layout: Layout) { 25 | val style = p.style 26 | val paintColor = p.color 27 | 28 | p.style = Paint.Style.FILL 29 | p.color = stripeColor 30 | 31 | c.drawRect(x.toFloat(), top.toFloat(), x + dir * stripeWidth, bottom.toFloat(), p) 32 | 33 | p.style = style 34 | p.color = paintColor 35 | } 36 | 37 | override fun drawBackground(c: Canvas, p: Paint, left: Int, right: Int, top: Int, baseline: Int, bottom: Int, text: CharSequence, start: Int, end: Int, lnum: Int) { 38 | val paintColor = p.color 39 | p.color = backgroundColor 40 | c.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), p) 41 | p.color = paintColor 42 | } 43 | 44 | companion object { 45 | 46 | fun replaceQuoteSpans(spannable: Spannable, context: Context) { 47 | val quoteSpans = spannable.getSpans(0, spannable.length, QuoteSpan::class.java) 48 | 49 | val backgroundColor = context.resources.getColor(R.color.colorBackground) 50 | val borderColor = context.resources.getColor(R.color.colorPrimary) 51 | 52 | for (quoteSpan in quoteSpans) { 53 | val start = spannable.getSpanStart(quoteSpan) 54 | val end = spannable.getSpanEnd(quoteSpan) 55 | val flags = spannable.getSpanFlags(quoteSpan) 56 | spannable.removeSpan(quoteSpan) 57 | spannable.setSpan(CustomQuoteSpan( 58 | backgroundColor, 59 | borderColor, 60 | 8f, 61 | 32f), 62 | start, 63 | end, 64 | flags) 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | import android.support.annotation.LayoutRes 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | 8 | fun ViewGroup.inflate(@LayoutRes layoutRes: Int, attachToRoot: Boolean = false): View { 9 | return LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot) 10 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/FabOffsetter.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | import android.support.design.widget.AppBarLayout 4 | import android.support.design.widget.FloatingActionButton 5 | import android.support.design.widget.CoordinatorLayout 6 | import android.support.v7.widget.Toolbar 7 | import co.hellocode.micro.R 8 | 9 | 10 | class FabOffsetter(private val parent: CoordinatorLayout, private val fab: FloatingActionButton) : AppBarLayout.OnOffsetChangedListener { 11 | 12 | override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { 13 | // fab should scroll out down in sync with the appBarLayout scrolling out up. 14 | // let's see how far along the way the appBarLayout is 15 | // (if displacementFraction == 0.0f then no displacement, appBar is fully expanded; 16 | // if displacementFraction == 1.0f then full displacement, appBar is totally collapsed) 17 | val toolbarHeight = appBarLayout.findViewById(R.id.toolbar).height 18 | val displacementFraction = -verticalOffset / toolbarHeight.toFloat() 19 | 20 | val diff = parent.height - fab.top 21 | fab.translationY = diff * displacementFraction 22 | 23 | } 24 | 25 | override fun equals(other: Any?): Boolean { 26 | if (this === other) return true 27 | if (other == null || javaClass != other.javaClass) return false 28 | 29 | val that = other as FabOffsetter? 30 | return parent == that!!.parent && fab == that.fab 31 | 32 | } 33 | 34 | override fun hashCode(): Int { 35 | var result = parent.hashCode() 36 | result = 31 * result + fab.hashCode() 37 | return result 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/HtmlTagHandler.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | import android.content.Context 4 | import android.text.Editable 5 | import android.text.Html 6 | import android.text.Spannable 7 | import android.text.style.BackgroundColorSpan 8 | import android.text.style.BulletSpan 9 | import android.text.style.ForegroundColorSpan 10 | import android.text.style.LeadingMarginSpan 11 | import android.text.style.RelativeSizeSpan 12 | import android.text.style.TypefaceSpan 13 | import android.util.Log 14 | 15 | import org.xml.sax.XMLReader 16 | 17 | import java.util.Vector 18 | 19 | /** 20 | * Created by Josh on 2/12/2015. 21 | */ 22 | class HtmlTagHandler(private val context: Context) : Html.TagHandler { 23 | private var mListItemCount = 0 24 | private val mListParents = Vector() 25 | 26 | override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { 27 | 28 | when (tag.toLowerCase()) { 29 | "ul", "ol" -> { 30 | if (opening) { 31 | mListParents.add(tag) 32 | //output.append('\n'); 33 | } else 34 | mListParents.remove(tag) 35 | 36 | mListItemCount = 0 37 | } 38 | "li" -> if (!opening) { 39 | handleListTag(output) 40 | } 41 | "code" -> if (opening) { 42 | output.setSpan(TypefaceSpan("monospace"), output.length, output.length, Spannable.SPAN_MARK_MARK) 43 | } else { 44 | Log.d("Code Tag", "Code tag encountered") 45 | val obj = getLast(output, TypefaceSpan::class.java) 46 | val where = output.getSpanStart(obj) 47 | 48 | output.setSpan(TypefaceSpan("monospace"), where, output.length, 0) 49 | output.setSpan(ForegroundColorSpan(-0x1000000), where, output.length, 0) 50 | output.setSpan(BackgroundColorSpan(-0x11000001), where, output.length, 0) 51 | output.setSpan(RelativeSizeSpan(0.9f), where, output.length, 0) 52 | } 53 | } 54 | 55 | } 56 | 57 | private fun getLast(text: Editable, kind: Class<*>): Any? { 58 | val objs = text.getSpans(0, text.length, kind) 59 | if (objs.size == 0) { 60 | return null 61 | } else { 62 | for (i in objs.size downTo 1) { 63 | if (text.getSpanFlags(objs[i - 1]) == Spannable.SPAN_MARK_MARK) { 64 | return objs[i - 1] 65 | } 66 | } 67 | return null 68 | } 69 | } 70 | 71 | private fun handleListTag(output: Editable) { 72 | if (mListParents.lastElement() == "ul") { 73 | output.append('\n') 74 | val split = output.toString().split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() 75 | 76 | val lastIndex = split.size - 1 77 | 78 | //Log.d("li1",split[lastIndex]); 79 | 80 | val start = output.length - split[lastIndex].length - 1 81 | // 82 | // if (!output.subSequence(start,output.length()).toString().equals(split[lastIndex])){ 83 | // start -= 2; 84 | // } 85 | 86 | //Log.d("li2",output.subSequence(start,output.length()).toString()); 87 | 88 | output.setSpan(BulletSpan(30), start, output.length, 0) 89 | //output.append("\n"); 90 | } else if (mListParents.lastElement() == "ol") { 91 | mListItemCount++ 92 | 93 | output.append("\n") 94 | val split = output.toString().split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() 95 | 96 | val lastIndex = split.size - 1 97 | val start = output.length - split[lastIndex].length - 1 98 | output.insert(start, mListItemCount.toString() + ". ") 99 | output.setSpan(LeadingMarginSpan.Standard(30), start, output.length, 0) 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/ScrollAwareFabBehaviour.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | import android.content.Context 4 | import android.support.design.widget.CoordinatorLayout 5 | import android.support.design.widget.AppBarLayout 6 | import android.support.design.widget.FloatingActionButton 7 | import android.util.AttributeSet 8 | import android.view.View 9 | import co.hellocode.micro.utils.FabOffsetter 10 | 11 | 12 | 13 | class ScrollAwareFabBehavior(context: Context, attrs: AttributeSet) : FloatingActionButton.Behavior() { 14 | 15 | override fun layoutDependsOn(parent: CoordinatorLayout, fab: FloatingActionButton, dependency: View): Boolean { 16 | // return dependency is AppBarLayout 17 | if (dependency is AppBarLayout) { 18 | dependency.addOnOffsetChangedListener(FabOffsetter(parent, fab)) 19 | } 20 | return dependency is AppBarLayout || super.layoutDependsOn(parent, fab, dependency) 21 | } 22 | 23 | override fun onDependentViewChanged(parent: CoordinatorLayout, fab: FloatingActionButton, dependency: View): Boolean { 24 | // if (dependency is AppBarLayout) { 25 | // val lp = fab.layoutParams as CoordinatorLayout.LayoutParams 26 | // Log.i("ScrollAwareFab", "layout: ${lp.bottomMargin} fab height: ${fab.height} ${fab.width}") 27 | // val fabBottomMargin = lp.bottomMargin 28 | // val distanceToScroll = fab.height + fabBottomMargin 29 | // val ratio = dependency.getY() / toolbarHeight.toFloat() 30 | // fab.translationY = -distanceToScroll * ratio 31 | // } 32 | // return true 33 | if (dependency is AppBarLayout) { 34 | // if the dependency is an AppBarLayout, do not allow super to react on that 35 | // we don't want that behavior 36 | return true; 37 | } 38 | return super.onDependentViewChanged(parent, fab, dependency); 39 | } 40 | 41 | 42 | } -------------------------------------------------------------------------------- /app/src/main/java/co/hellocode/micro/utils/URLSpanNoUnderline.kt: -------------------------------------------------------------------------------- 1 | package co.hellocode.micro.utils 2 | 3 | 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.text.Html 8 | import android.text.Spannable 9 | import android.text.TextPaint 10 | import android.text.style.URLSpan 11 | import android.util.Log 12 | import android.view.View 13 | import co.hellocode.micro.ProfileActivity 14 | import co.hellocode.micro.R 15 | 16 | 17 | /** 18 | * Created by Josh on 30/11/2015. 19 | */ 20 | class URLSpanNoUnderline(p_Url: String, context: Context) : URLSpan(p_Url) { 21 | internal var url: String 22 | internal val context: Context 23 | 24 | init { 25 | var p_Url = p_Url 26 | 27 | if (p_Url.startsWith("/")) { 28 | p_Url = "https://micro.blog$p_Url" 29 | } 30 | this.url = p_Url 31 | this.context = context 32 | } 33 | 34 | override fun onClick(widget: View) { 35 | val urlText = Html.fromHtml("" + this.getURL() + "") 36 | val url = this.url 37 | // Snackbar.make(widget, urlText, Snackbar.LENGTH_LONG).setAction( 38 | // "Open") { 39 | // val i = Intent(Intent.ACTION_VIEW) 40 | // i.data = Uri.parse(url) 41 | // widget.context.startActivity(i) 42 | // }.setActionTextColor(widget.resources.getColor(R.color.colorAccent)).show() 43 | val data = Uri.parse(url) 44 | Log.i("URLSpanNoUnderline", "url: $data") 45 | if (data.host == "micro.blog" && !data.path.contains("/posts") && !data.path.contains("/discover")) { 46 | // This is probably a username, so try to open the profile activity 47 | val intent = Intent(this.context, ProfileActivity::class.java) 48 | val username = data.path.drop(1) 49 | intent.putExtra("username", username) 50 | this.context.startActivity(intent) 51 | } else { 52 | // Normal link 53 | val i = Intent(Intent.ACTION_VIEW) 54 | i.data = data 55 | widget.context.startActivity(i) 56 | } 57 | 58 | //super.onClick(widget); 59 | } 60 | 61 | override fun updateDrawState(p_DrawState: TextPaint) { 62 | super.updateDrawState(p_DrawState) 63 | p_DrawState.isUnderlineText = false 64 | p_DrawState.color = context.resources.getColor(R.color.colorAccent) 65 | } 66 | 67 | companion object { 68 | 69 | fun removeUnderlines(p_Text: Spannable, context: Context): Spannable { 70 | val spans = p_Text.getSpans(0, p_Text.length, URLSpan::class.java) 71 | 72 | for (span in spans) { 73 | 74 | val start = p_Text.getSpanStart(span) 75 | val end = p_Text.getSpanEnd(span) 76 | p_Text.removeSpan(span) 77 | val newSpan = URLSpanNoUnderline(span.url, context) 78 | p_Text.setSpan(newSpan, start, end, 0) 79 | } 80 | return p_Text 81 | } 82 | } 83 | 84 | 85 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/chat_bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellebethcooper/pico/d360b703e7663dfcf3133b78210b7e77b25456b8/app/src/main/res/drawable/chat_bubble.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle_button_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellebethcooper/pico/d360b703e7663dfcf3133b78210b7e77b25456b8/app/src/main/res/drawable/compose.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/custom_button.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 35 | 37 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellebethcooper/pico/d360b703e7663dfcf3133b78210b7e77b25456b8/app/src/main/res/drawable/photo.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/pico_ic_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellebethcooper/pico/d360b703e7663dfcf3133b78210b7e77b25456b8/app/src/main/res/drawable/pico_ic_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/reply.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellebethcooper/pico/d360b703e7663dfcf3133b78210b7e77b25456b8/app/src/main/res/drawable/reply.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/reply_edit_text.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/white_button_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_conversation.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 17 | 18 | 28 | 29 | 30 | 31 | 36 | 37 | 41 | 42 | 50 | 51 | 52 | 53 | 59 | 60 | 63 | 64 | 71 | 72 | 80 | 81 | 87 | 88 | 89 | 90 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | 27 | 28 | 37 | 38 | 39 | 40 | 45 | 46 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_new_post.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 27 | 28 | 29 | 30 | 40 | 41 | 47 | 48 | 58 | 59 | 60 | 63 | 64 | 75 | 76 |