story viewmodel (wip)

hiding the storylist doesn't work yet but everything else should be good
This commit is contained in:
Austin Huang 2021-07-05 20:11:58 -04:00
parent 31ea42d105
commit bb5244665b
No known key found for this signature in database
GPG Key ID: 84C23AA04587A91F
23 changed files with 1555 additions and 1458 deletions

View File

@ -1,5 +1,7 @@
package awais.instagrabber.adapters;
import java.util.List;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -72,6 +74,16 @@ public final class StoriesAdapter extends ListAdapter<StoryMedia, StoriesAdapter
}
}
public void paginate(final int newIndex) {
final List<StoryMedia> list = getCurrentList();
for (int i = 0; i < list.size(); i++) {
final StoryMedia item = list.get(i);
if (!item.isCurrentSlide() && i != newIndex) continue;
item.setCurrentSlide(i == newIndex);
notifyItemChanged(i, item);
}
}
public interface OnItemClickListener {
void onItemClick(StoryMedia storyModel, int position);
}

View File

@ -0,0 +1,902 @@
package awais.instagrabber.fragments
import android.annotation.SuppressLint
import android.content.DialogInterface.OnClickListener
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.util.Log
import android.view.*
import android.view.GestureDetector.SimpleOnGestureListener
import android.widget.*
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.GestureDetectorCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.LinearLayoutManager
import awais.instagrabber.BuildConfig
import awais.instagrabber.R
import awais.instagrabber.adapters.StoriesAdapter
import awais.instagrabber.customviews.helpers.SwipeGestureListener
import awais.instagrabber.databinding.FragmentStoryViewerBinding
import awais.instagrabber.fragments.settings.PreferenceKeys
import awais.instagrabber.interfaces.SwipeEvent
import awais.instagrabber.models.Resource
import awais.instagrabber.models.enums.FavoriteType
import awais.instagrabber.models.enums.MediaItemType
import awais.instagrabber.models.enums.StoryPaginationType
import awais.instagrabber.repositories.requests.StoryViewerOptions
import awais.instagrabber.repositories.responses.directmessages.RankedRecipient
import awais.instagrabber.repositories.responses.stories.*
import awais.instagrabber.utils.*
import awais.instagrabber.utils.DownloadUtils.download
import awais.instagrabber.utils.TextUtils.epochSecondToString
import awais.instagrabber.utils.extensions.TAG
import awais.instagrabber.viewmodels.ArchivesViewModel
import awais.instagrabber.viewmodels.FeedStoriesViewModel
import awais.instagrabber.viewmodels.HighlightsViewModel
import awais.instagrabber.viewmodels.StoryFragmentViewModel
import awais.instagrabber.webservices.MediaRepository
import awais.instagrabber.webservices.StoriesRepository
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.drawee.controller.BaseControllerListener
import com.facebook.drawee.interfaces.DraweeController
import com.facebook.imagepipeline.image.ImageInfo
import com.facebook.imagepipeline.request.ImageRequestBuilder
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.source.*
import com.google.android.exoplayer2.source.dash.DashMediaSource
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import com.google.android.material.textfield.TextInputEditText
import java.io.IOException
import java.text.NumberFormat
import java.util.*
class StoryViewerFragment : Fragment() {
private val TAG = "StoryViewerFragment"
private val cookie = Utils.settingsHelper.getString(Constants.COOKIE)
private var root: View? = null
private var currentStoryUsername: String? = null
private var highlightTitle: String? = null
private var storiesAdapter: StoriesAdapter? = null
private var swipeEvent: SwipeEvent? = null
private var gestureDetector: GestureDetectorCompat? = null
private val storiesRepository: StoriesRepository? = null
private val mediaRepository: MediaRepository? = null
private var live: Broadcast? = null
private var menuProfile: MenuItem? = null
private var profileVisible: Boolean = false
private var player: SimpleExoPlayer? = null
private var actionBarTitle: String? = null
private var actionBarSubtitle: String? = null
private var fetching = false
private val sticking = false
private var shouldRefresh = true
private var dmVisible = false
private var currentFeedStoryIndex = 0
private var sliderValue = 0.0
private var options: StoryViewerOptions? = null
private var listViewModel: ViewModel? = null
private var backStackSavedStateResultLiveData: MutableLiveData<Any?>? = null
private lateinit var fragmentActivity: AppCompatActivity
private lateinit var storiesViewModel: StoryFragmentViewModel
private lateinit var binding: FragmentStoryViewerBinding
@Suppress("UNCHECKED_CAST")
private val backStackSavedStateObserver = Observer<Any?> { result ->
if (result == null) return@Observer
if ((result is RankedRecipient)) {
if (context != null) {
Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show()
}
storiesViewModel.shareDm(result)
} else if ((result is Set<*>)) {
try {
if (context != null) {
Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show()
}
storiesViewModel.shareDm(result as Set<RankedRecipient>)
} catch (e: Exception) {
Log.e(TAG, "share: ", e)
}
}
// clear result
backStackSavedStateResultLiveData?.postValue(null)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fragmentActivity = requireActivity() as AppCompatActivity
storiesViewModel = ViewModelProvider(this).get(StoryFragmentViewModel::class.java)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
if (root != null) {
shouldRefresh = false
return root
}
binding = FragmentStoryViewerBinding.inflate(inflater, container, false)
root = binding.root
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!shouldRefresh) return
init()
shouldRefresh = false
}
override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.story_menu, menu)
menuProfile = menu.findItem(R.id.action_profile)
menuProfile!!.isVisible = profileVisible
}
override fun onPrepareOptionsMenu(menu: Menu) {
// hide menu items from activity
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val context = context ?: return false
val itemId = item.itemId
if (itemId == R.id.action_profile) {
val username = storiesViewModel.getCurrentStory().value?.user?.username
openProfile(Pair(username, FavoriteType.USER))
return true
}
return false
}
override fun onPause() {
super.onPause()
player?.pause() ?: return
}
override fun onResume() {
super.onResume()
setHasOptionsMenu(true)
try {
val backStackEntry = NavHostFragment.findNavController(this).currentBackStackEntry
if (backStackEntry != null) {
backStackSavedStateResultLiveData = backStackEntry.savedStateHandle.getLiveData("result")
backStackSavedStateResultLiveData?.observe(viewLifecycleOwner, backStackSavedStateObserver)
}
} catch (e: Exception) {
Log.e(TAG, "onResume: ", e)
}
val actionBar = fragmentActivity.supportActionBar ?: return
actionBar.title = storiesViewModel.getTitle().value
actionBar.subtitle = storiesViewModel.getDate().value
}
override fun onDestroy() {
releasePlayer()
val actionBar = fragmentActivity.supportActionBar
actionBar?.subtitle = null
super.onDestroy()
}
private fun init() {
val args = arguments
if (args == null) return
val fragmentArgs = StoryViewerFragmentArgs.fromBundle(args)
options = fragmentArgs.options
currentFeedStoryIndex = options!!.currentFeedStoryIndex
val type = options!!.type
if (currentFeedStoryIndex >= 0) {
listViewModel = when (type) {
StoryViewerOptions.Type.HIGHLIGHT -> ViewModelProvider(fragmentActivity).get(
HighlightsViewModel::class.java
)
StoryViewerOptions.Type.STORY_ARCHIVE -> ViewModelProvider(fragmentActivity).get(
ArchivesViewModel::class.java
)
StoryViewerOptions.Type.FEED_STORY_POSITION -> ViewModelProvider(fragmentActivity).get(
FeedStoriesViewModel::class.java
)
else -> ViewModelProvider(fragmentActivity).get(
FeedStoriesViewModel::class.java
)
}
}
setupButtons()
setupStories()
}
private fun setupStories() {
setupListeners()
val context = context ?: return
binding.storiesList.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
storiesAdapter = StoriesAdapter { model: StoryMedia?, position: Int ->
storiesViewModel.setMedia(position)
}
binding.storiesList.adapter = storiesAdapter
storiesViewModel.getCurrentStory().observe(fragmentActivity, {
if (it?.items != null) {
val storyMedias = it.items.toMutableList()
val newItem = storyMedias.get(0)
newItem.isCurrentSlide = true
storyMedias.set(0, newItem)
storiesAdapter!!.submitList(storyMedias)
storiesViewModel.setMedia(0)
}
})
storiesViewModel.getDate().observe(fragmentActivity, {
val actionBar = fragmentActivity.supportActionBar
if (actionBar != null && it != null) actionBar.subtitle = it
})
storiesViewModel.getTitle().observe(fragmentActivity, {
val actionBar = fragmentActivity.supportActionBar
if (actionBar != null && it != null) actionBar.title = it
})
storiesViewModel.getCurrentMedia().observe(fragmentActivity, { refreshStory(it) })
storiesViewModel.getCurrentIndex().observe(fragmentActivity, {
storiesAdapter!!.paginate(it)
})
storiesViewModel.getOptions().observe(fragmentActivity, {
binding.stickers.isEnabled = it.first.size > 0
})
resetView()
}
private fun setupButtons() {
binding.btnDownload.setOnClickListener({ _ -> downloadStory() })
binding.btnForward.setOnClickListener({ _ -> storiesViewModel.skip(false) })
binding.btnBackward.setOnClickListener({ _ -> storiesViewModel.skip(true) })
binding.btnShare.setOnClickListener({ _ -> shareStoryViaDm() })
binding.btnReply.setOnClickListener({ _ -> createReplyDialog(null) })
binding.stickers.setOnClickListener({ _ -> showStickerMenu() })
}
@SuppressLint("ClickableViewAccessibility")
private fun setupListeners() {
val hasFeedStories: Boolean
var models: List<Story>? = null
if (currentFeedStoryIndex >= 0) {
val type = options!!.type
when (type) {
StoryViewerOptions.Type.HIGHLIGHT -> {
val highlightsViewModel = listViewModel as HighlightsViewModel?
models = highlightsViewModel!!.list.value
}
StoryViewerOptions.Type.FEED_STORY_POSITION -> {
val feedStoriesViewModel = listViewModel as FeedStoriesViewModel?
models = feedStoriesViewModel!!.list.value
}
StoryViewerOptions.Type.STORY_ARCHIVE -> {
val archivesViewModel = listViewModel as ArchivesViewModel?
models = archivesViewModel!!.list.value
}
}
}
hasFeedStories = models != null && !models.isEmpty()
storiesViewModel.getPagination().observe(fragmentActivity, {
if (models != null) {
when (it) {
StoryPaginationType.FORWARD -> {
paginateStories(false, currentFeedStoryIndex == models.size - 2)
}
StoryPaginationType.BACKWARD -> {
paginateStories(true, false)
}
StoryPaginationType.ERROR -> {
Toast.makeText(
context,
R.string.downloader_unknown_error,
Toast.LENGTH_SHORT
).show()
}
StoryPaginationType.DO_NOTHING -> {
} // do nothing
}
}
})
val context = context ?: return
swipeEvent = label@ SwipeEvent { isRightSwipe: Boolean ->
storiesViewModel.paginate(isRightSwipe)
}
gestureDetector = GestureDetectorCompat(context, SwipeGestureListener(swipeEvent))
binding.playerView.setOnTouchListener { _, event -> gestureDetector!!.onTouchEvent(event) }
val simpleOnGestureListener: SimpleOnGestureListener = object : SimpleOnGestureListener() {
override fun onFling(
e1: MotionEvent,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
val diffX = e2.x - e1.x
try {
if (Math.abs(diffX) > Math.abs(e2.y - e1.y) && Math.abs(diffX) > SwipeGestureListener.SWIPE_THRESHOLD && Math.abs(
velocityX
) > SwipeGestureListener.SWIPE_VELOCITY_THRESHOLD
) {
storiesViewModel.paginate(diffX > 0)
return true
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.e(TAG, "Error", e)
}
return false
}
}
if (hasFeedStories) {
binding.btnBackward.isEnabled = currentFeedStoryIndex != 0
binding.btnForward.isEnabled = currentFeedStoryIndex != models!!.size - 1
}
binding.imageViewer.setTapListener(simpleOnGestureListener)
// process stickers
}
private fun resetView() {
val context = context ?: return
live = null
if (menuProfile != null) menuProfile!!.isVisible = false
profileVisible = false
binding.imageViewer.controller = null
releasePlayer()
val type = options!!.type
var fetchOptions: StoryViewerOptions? = null
when (type) {
StoryViewerOptions.Type.HIGHLIGHT -> {
val highlightsViewModel = listViewModel as HighlightsViewModel?
val models = highlightsViewModel!!.list.value
if (models == null || models.isEmpty() || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) {
Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT)
.show()
return
}
val (id, _, _, _, _, _, _, _, _, title) = models[currentFeedStoryIndex]
fetchOptions = StoryViewerOptions.forHighlight(id)
}
StoryViewerOptions.Type.FEED_STORY_POSITION -> {
val feedStoriesViewModel = listViewModel as FeedStoriesViewModel?
val models = feedStoriesViewModel!!.list.value
if (models == null || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) return
val (_, _, _, _, user, _, _, _, _, _, _, broadcast) = models[currentFeedStoryIndex]
currentStoryUsername = user!!.username
fetchOptions = StoryViewerOptions.forUser(user.pk, currentStoryUsername)
live = broadcast
}
StoryViewerOptions.Type.STORY_ARCHIVE -> {
val archivesViewModel = listViewModel as ArchivesViewModel?
val models = archivesViewModel!!.list.value
if (models == null || models.isEmpty() || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) {
Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT)
.show()
return
}
val (id, _, _, _, _, _, _, _, _, title) = models[currentFeedStoryIndex]
currentStoryUsername = title
fetchOptions = StoryViewerOptions.forStoryArchive(id)
}
StoryViewerOptions.Type.USER -> {
currentStoryUsername = options!!.name
fetchOptions = StoryViewerOptions.forUser(options!!.id, currentStoryUsername)
}
}
if (type == StoryViewerOptions.Type.STORY) {
storiesViewModel.fetchSingleMedia(options!!.id)
return
}
if (live != null) {
refreshLive()
return
}
storiesViewModel.fetchStory(fetchOptions).observe(fragmentActivity, {
// toast error if necessary?
})
}
@Synchronized
private fun refreshLive() {
releasePlayer()
setupLive(live!!.dashPlaybackUrl ?: live!!.dashAbrPlaybackUrl ?: return)
val actionBar = fragmentActivity.supportActionBar
actionBarSubtitle = epochSecondToString(live!!.publishedTime!!)
if (actionBar != null) {
try {
actionBar.setSubtitle(actionBarSubtitle)
} catch (e: Exception) {
Log.e(TAG, "refreshLive: ", e)
}
}
}
@Synchronized
private fun refreshStory(currentStory: StoryMedia) {
val itemType = currentStory.type
val url = if (itemType === MediaItemType.MEDIA_TYPE_IMAGE) ResponseBodyUtils.getImageUrl(currentStory)
else ResponseBodyUtils.getVideoUrl(currentStory)
releasePlayer()
binding.btnDownload.isEnabled = false
binding.btnShare.isEnabled = currentStory.canReshare
binding.btnReply.isEnabled = currentStory.canReply
if (itemType === MediaItemType.MEDIA_TYPE_VIDEO) setupVideo(url) else setupImage(url)
// if (Utils.settingsHelper.getBoolean(MARK_AS_SEEN)) storiesRepository!!.seen(
// csrfToken,
// userId,
// deviceId,
// currentStory!!.id!!,
// currentStory!!.takenAt,
// System.currentTimeMillis() / 1000
// )
}
private fun downloadStory() {
val context = context ?: return
val currentStory = storiesViewModel.getMedia().value
if (currentStory == null) {
Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show()
return
}
download(context, currentStory)
}
private fun setupImage(url: String) {
binding.progressView.visibility = View.VISIBLE
binding.playerView.visibility = View.GONE
binding.imageViewer.visibility = View.VISIBLE
val requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(url))
.setLocalThumbnailPreviewsEnabled(true)
.setProgressiveRenderingEnabled(true)
.build()
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setImageRequest(requestBuilder)
.setOldController(binding.imageViewer.controller)
.setControllerListener(object : BaseControllerListener<ImageInfo?>() {
override fun onFailure(id: String, throwable: Throwable) {
binding.btnDownload.isEnabled = false
binding.progressView.visibility = View.GONE
}
override fun onFinalImageSet(
id: String,
imageInfo: ImageInfo?,
animatable: Animatable?
) {
binding.btnDownload.isEnabled = true
binding.progressView.visibility = View.GONE
}
})
.build()
binding.imageViewer.controller = controller
}
private fun setupVideo(url: String) {
binding.playerView.visibility = View.VISIBLE
binding.progressView.visibility = View.GONE
binding.imageViewer.visibility = View.GONE
binding.imageViewer.controller = null
val context = context ?: return
player = SimpleExoPlayer.Builder(context).build()
binding.playerView.player = player
player!!.playWhenReady =
Utils.settingsHelper.getBoolean(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES)
val uri = Uri.parse(url)
val mediaItem = MediaItem.fromUri(uri)
val mediaSource =
ProgressiveMediaSource.Factory(DefaultDataSourceFactory(context, "instagram"))
.createMediaSource(mediaItem)
mediaSource.addEventListener(Handler(), object : MediaSourceEventListener {
override fun onLoadCompleted(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData
) {
binding.btnDownload.isEnabled = true
binding.progressView.visibility = View.GONE
}
override fun onLoadStarted(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData
) {
binding.btnDownload.isEnabled = true
binding.progressView.visibility = View.VISIBLE
}
override fun onLoadCanceled(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData
) {
binding.progressView.visibility = View.GONE
}
override fun onLoadError(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData,
error: IOException,
wasCanceled: Boolean
) {
binding.btnDownload.isEnabled = false
if (menuProfile != null) {
profileVisible = false
menuProfile!!.isVisible = false
}
binding.progressView.visibility = View.GONE
}
})
player!!.setMediaSource(mediaSource)
player!!.prepare()
binding.playerView.setOnClickListener { v: View? ->
if (player != null) {
if (player!!.playbackState == Player.STATE_ENDED) player!!.seekTo(0)
player!!.playWhenReady =
player!!.playbackState == Player.STATE_ENDED || !player!!.isPlaying
}
}
}
private fun setupLive(url: String) {
binding.playerView.visibility = View.VISIBLE
binding.progressView.visibility = View.GONE
binding.imageViewer.visibility = View.GONE
binding.imageViewer.controller = null
val context = context ?: return
player = SimpleExoPlayer.Builder(context).build()
binding.playerView.player = player
player!!.playWhenReady =
Utils.settingsHelper.getBoolean(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES)
val uri = Uri.parse(url)
val mediaItem = MediaItem.fromUri(uri)
val mediaSource = DashMediaSource.Factory(DefaultDataSourceFactory(context, "instagram"))
.createMediaSource(mediaItem)
mediaSource.addEventListener(Handler(), object : MediaSourceEventListener {
override fun onLoadCompleted(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData
) {
binding.progressView.visibility = View.GONE
}
override fun onLoadStarted(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData
) {
binding.progressView.visibility = View.VISIBLE
}
override fun onLoadCanceled(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData
) {
binding.progressView.visibility = View.GONE
}
override fun onLoadError(
windowIndex: Int,
mediaPeriodId: MediaSource.MediaPeriodId?,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData,
error: IOException,
wasCanceled: Boolean
) {
binding.progressView.visibility = View.GONE
}
})
player!!.setMediaSource(mediaSource)
player!!.prepare()
binding.playerView.setOnClickListener { _ ->
if (player != null) {
if (player!!.playbackState == Player.STATE_ENDED) player!!.seekTo(0)
player!!.playWhenReady =
player!!.playbackState == Player.STATE_ENDED || !player!!.isPlaying
}
}
}
private fun openProfile(data: Pair<String?, FavoriteType>) {
val navController: NavController = NavHostFragment.findNavController(this)
val bundle = Bundle()
if (data.first == null) {
// toast
return
}
val actionBar = fragmentActivity.supportActionBar
if (actionBar != null) {
actionBar.title = null
actionBar.subtitle = null
}
when (data.second) {
FavoriteType.USER -> {
bundle.putString("username", data.first)
navController.navigate(R.id.action_global_profileFragment, bundle)
}
FavoriteType.HASHTAG -> {
bundle.putString("hashtag", data.first)
navController.navigate(R.id.action_global_hashTagFragment, bundle)
}
FavoriteType.LOCATION -> {
bundle.putLong("locationId", data.first!!.toLong())
navController.navigate(R.id.action_global_locationFragment, bundle)
}
}
}
private fun releasePlayer() {
if (player == null) return
try {
player!!.stop(true)
} catch (ignored: Exception) {
}
try {
player!!.release()
} catch (ignored: Exception) {
}
player = null
}
private fun paginateStories(
backward: Boolean,
last: Boolean
) {
binding.btnBackward.isEnabled = currentFeedStoryIndex != 1 || !backward
binding.btnForward.isEnabled = !last
currentFeedStoryIndex = if (backward) currentFeedStoryIndex - 1 else currentFeedStoryIndex + 1
resetView()
}
private fun createChoiceDialog(
title: String?,
tallies: List<Tally>,
onClickListener: OnClickListener,
viewerVote: Int?,
correctAnswer: Int?
) {
val context = context ?: return
val choices = tallies.map {
(if (viewerVote == tallies.indexOf(it)) "" else "") +
(if (correctAnswer == tallies.indexOf(it)) "*** " else "") +
it.text + " (" + it.count + ")" }
val builder = AlertDialog.Builder(context)
if (title != null) builder.setTitle(title)
if (viewerVote != null) builder.setMessage(R.string.story_quizzed)
builder.setPositiveButton(if (viewerVote == null) R.string.cancel else R.string.ok, null)
val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, choices.toTypedArray())
builder.setAdapter(adapter, onClickListener)
builder.show()
}
private fun createMentionDialog() {
val context = context ?: return
val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, storiesViewModel.getMentionTexts())
val builder = AlertDialog.Builder(context)
.setPositiveButton(R.string.ok, null)
.setAdapter(adapter, { _, w ->
val data = storiesViewModel.getMention(w)
if (data != null) openProfile(Pair(data.second, data.third))
})
builder.show()
}
private fun createSliderDialog() {
val slider = storiesViewModel.getSlider().value ?: return
val context = context ?: return
val percentage: NumberFormat = NumberFormat.getPercentInstance()
percentage.maximumFractionDigits = 2
val sliderView = LinearLayout(context)
sliderView.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
sliderView.orientation = LinearLayout.VERTICAL
val tv = TextView(context)
tv.gravity = Gravity.CENTER_HORIZONTAL
val input = SeekBar(context)
val avg: Double = slider.sliderVoteAverage ?: 0.5
input.progress = (avg * 100).toInt()
var onClickListener: OnClickListener? = null
if (slider.viewerVote == null && slider.viewerCanVote == true) {
input.isEnabled = true
input.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
sliderValue = progress / 100.0
tv.text = percentage.format(sliderValue)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
onClickListener = OnClickListener { _, _ -> storiesViewModel.answerSlider(sliderValue) }
}
else {
input.isEnabled = false
tv.text = getString(R.string.slider_answer, percentage.format(slider.viewerVote))
}
sliderView.addView(input)
sliderView.addView(tv)
val builder = AlertDialog.Builder(context)
.setTitle(if (slider.question.isNullOrEmpty()) slider.emoji else slider.question)
.setMessage(
resources.getQuantityString(R.plurals.slider_info,
slider.sliderVoteCount ?: 0,
slider.sliderVoteCount ?: 0,
percentage.format(avg)))
.setView(sliderView)
.setPositiveButton(R.string.ok, onClickListener)
builder.show()
}
private fun createReplyDialog(question: String?) {
val context = context ?: return
val input = TextInputEditText(context)
input.setHint(R.string.reply_hint)
val builder = AlertDialog.Builder(context)
.setTitle(question ?: context.getString(R.string.reply_story))
.setView(input)
val onClickListener = OnClickListener{ _, _ ->
val result =
if (question != null) storiesViewModel.answerQuestion(input.text.toString())
else storiesViewModel.reply(input.text.toString())
if (result == null) {
Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT)
.show()
}
else result.observe(viewLifecycleOwner, {
when (it.status) {
Resource.Status.SUCCESS -> {
Toast.makeText(context, R.string.answered_story, Toast.LENGTH_SHORT)
.show()
}
Resource.Status.ERROR -> {
Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT)
.show()
}
Resource.Status.LOADING -> {
Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show()
}
}
})
}
builder.setPositiveButton(R.string.confirm, onClickListener)
builder.show()
}
private fun shareStoryViaDm() {
val actionGlobalUserSearch = UserSearchFragmentDirections.actionGlobalUserSearch().apply {
title = getString(R.string.share)
setActionLabel(getString(R.string.send))
showGroups = true
multiple = true
setSearchMode(UserSearchFragment.SearchMode.RAVEN)
}
try {
val navController = NavHostFragment.findNavController(this@StoryViewerFragment)
navController.navigate(actionGlobalUserSearch)
} catch (e: Exception) {
Log.e(TAG, "shareStoryViaDm: ", e)
}
}
private fun showStickerMenu() {
val data = storiesViewModel.getOptions().value
if (data == null) return
val themeWrapper = ContextThemeWrapper(context, R.style.popupMenuStyle)
val popupMenu = PopupMenu(themeWrapper, binding.stickers)
val menu = popupMenu.menu
data.first.map {
if (it.second != 0) menu.add(0, it.first, 0, it.second)
if (it.first == R.id.swipeUp) menu.add(0, R.id.swipeUp, 0, data.second)
if (it.first == R.id.spotify) menu.add(0, R.id.spotify, 0, data.third)
}
popupMenu.setOnMenuItemClickListener { item: MenuItem ->
val itemId = item.itemId
if (itemId == R.id.spotify) openExternalLink(storiesViewModel.getAppAttribution())
else if (itemId == R.id.swipeUp) openExternalLink(storiesViewModel.getSwipeUp())
else if (itemId == R.id.mentions) createMentionDialog()
else if (itemId == R.id.slider) createSliderDialog()
else if (itemId == R.id.question) {
val question = storiesViewModel.getQuestion().value
if (question != null) createReplyDialog(question.question)
}
else if (itemId == R.id.quiz) {
val quiz = storiesViewModel.getQuiz().value
if (quiz != null) createChoiceDialog(
quiz.question,
quiz.tallies,
{ _, w -> storiesViewModel.answerQuiz(w) },
quiz.viewerAnswer,
quiz.correctAnswer
)
}
else if (itemId == R.id.poll) {
val poll = storiesViewModel.getPoll().value
if (poll != null) createChoiceDialog(
poll.question,
poll.tallies,
{ _, w -> storiesViewModel.answerPoll(w) },
poll.viewerVote,
null
)
}
else if (itemId == R.id.viewStoryPost) {
storiesViewModel.getLinkedPost().observe(viewLifecycleOwner, {
if (it == null) Toast.makeText(context, "Error: LiveData is null", Toast.LENGTH_SHORT).show()
else when (it.status) {
Resource.Status.SUCCESS -> {
if (it.data != null) {
val actionBar = fragmentActivity.supportActionBar
if (actionBar != null) {
actionBar.title = null
actionBar.subtitle = null
}
val navController =
NavHostFragment.findNavController(this@StoryViewerFragment)
val bundle = Bundle()
bundle.putSerializable(PostViewV2Fragment.ARG_MEDIA, it.data)
try {
navController.navigate(R.id.action_global_post_view, bundle)
} catch (e: Exception) {
Log.e(TAG, "openPostDialog: ", e)
}
}
}
Resource.Status.ERROR -> {
Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT)
.show()
}
Resource.Status.LOADING -> {
Toast.makeText(context, R.string.opening_post, Toast.LENGTH_SHORT)
.show()
}
}
})
}
false
}
popupMenu.show()
}
private fun openExternalLink(url: String?) {
val context = context ?: return
if (url == null) return
AlertDialog.Builder(context)
.setTitle(R.string.swipe_up_confirmation)
.setMessage(url).setPositiveButton(R.string.yes, { _, _ -> Utils.openURL(context, url) })
.setNegativeButton(R.string.no, null)
.show()
}
}

View File

@ -199,7 +199,7 @@ class ProfileFragment : Fragment(), OnRefreshListener, ConfirmDialogFragmentCall
}
}
private val onProfilePicClickListener = View.OnClickListener {
val hasStories = viewModel.userStories.value?.data?.isNotEmpty() ?: false
val hasStories = viewModel.userStories.value?.data != null
if (!hasStories) {
showProfilePicDialog()
return@OnClickListener
@ -514,7 +514,7 @@ class ProfileFragment : Fragment(), OnRefreshListener, ConfirmDialogFragmentCall
highlightsAdapter?.submitList(it.data)
}
viewModel.userStories.observe(viewLifecycleOwner) {
binding.header.mainProfileImage.setStoriesBorder(if (it.data.isNullOrEmpty()) 0 else 1)
binding.header.mainProfileImage.setStoriesBorder(if (it.data == null) 0 else 1)
}
viewModel.eventLiveData.observe(viewLifecycleOwner) {
val event = it?.getContentIfNotHandled() ?: return@observe

View File

@ -0,0 +1,7 @@
package awais.instagrabber.models.enums
import java.io.Serializable
enum class StoryPaginationType : Serializable {
FORWARD, BACKWARD, DO_NOTHING, ERROR
}

View File

@ -34,7 +34,7 @@ interface StoriesService {
@FormUrlEncoded
@POST("/api/v1/media/{storyId}/{stickerId}/{action}/")
suspend fun respondToSticker(
@Path("storyId") storyId: String,
@Path("storyId") storyId: Long,
@Path("stickerId") stickerId: Long,
@Path("action") action: String, // story_poll_vote, story_question_response, story_slider_vote, story_quiz_answer
@FieldMap form: Map<String, String>,

View File

@ -10,8 +10,8 @@ import java.io.Serializable
data class StoryMedia(
// inherited from Media
val pk: String? = null,
val id: String? = null,
val pk: Long = -1,
val id: String = "",
val takenAt: Long = -1,
val user: User? = null,
val canReshare: Boolean = false,

View File

@ -16,7 +16,6 @@ import awais.instagrabber.repositories.responses.User
import awais.instagrabber.repositories.responses.UserProfileContextLink
import awais.instagrabber.repositories.responses.directmessages.RankedRecipient
import awais.instagrabber.repositories.responses.stories.Story
import awais.instagrabber.repositories.responses.stories.StoryMedia
import awais.instagrabber.utils.ControlledRunner
import awais.instagrabber.utils.Event
import awais.instagrabber.utils.SingleRunner
@ -153,9 +152,9 @@ class ProfileFragmentViewModel(
}
}
private val storyFetchControlledRunner = ControlledRunner<List<StoryMedia>?>()
val userStories: LiveData<Resource<List<StoryMedia>?>> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair ->
liveData<Resource<List<StoryMedia>?>>(context = viewModelScope.coroutineContext + ioDispatcher) {
private val storyFetchControlledRunner = ControlledRunner<Story?>()
val userStories: LiveData<Resource<Story?>> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair ->
liveData<Resource<Story?>>(context = viewModelScope.coroutineContext + ioDispatcher) {
val (currentUserResource, profileResource, action) = currentUserAndProfilePair
if (action != INIT && action != REFRESH) {
return@liveData
@ -231,7 +230,7 @@ class ProfileFragmentViewModel(
return graphQLRepository.fetchUser(stateUsername)
}
private suspend fun fetchUserStory(fetchedUser: User): List<StoryMedia> = storiesRepository.getStories(
private suspend fun fetchUserStory(fetchedUser: User): Story? = storiesRepository.getStories(
StoryViewerOptions.forUser(fetchedUser.pk, fetchedUser.fullName)
)

View File

@ -0,0 +1,458 @@
package awais.instagrabber.viewmodels
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import awais.instagrabber.R
import awais.instagrabber.managers.DirectMessagesManager
import awais.instagrabber.models.enums.FavoriteType
import awais.instagrabber.models.enums.MediaItemType
import awais.instagrabber.models.enums.StoryPaginationType
import awais.instagrabber.models.Resource
import awais.instagrabber.models.Resource.Companion.error
import awais.instagrabber.models.Resource.Companion.loading
import awais.instagrabber.models.Resource.Companion.success
import awais.instagrabber.models.enums.BroadcastItemType
import awais.instagrabber.repositories.requests.StoryViewerOptions
import awais.instagrabber.repositories.responses.directmessages.RankedRecipient
import awais.instagrabber.repositories.responses.stories.*
import awais.instagrabber.repositories.responses.Media
import awais.instagrabber.utils.*
import awais.instagrabber.webservices.MediaRepository
import awais.instagrabber.webservices.StoriesRepository
import com.google.common.collect.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class StoryFragmentViewModel : ViewModel() {
// large data
private val currentStory = MutableLiveData<Story>()
private val currentMedia = MutableLiveData<StoryMedia>()
// small data
private val storyTitle = MutableLiveData<String>()
private val date = MutableLiveData<String>()
private val type = MutableLiveData<MediaItemType>()
private val poll = MutableLiveData<PollSticker>()
private val quiz = MutableLiveData<QuizSticker>()
private val question = MutableLiveData<QuestionSticker>()
private val slider = MutableLiveData<SliderSticker>()
private val swipeUp = MutableLiveData<String>()
private val linkedPost = MutableLiveData<String>()
private val appAttribution = MutableLiveData<StoryAppAttribution>()
private val reelMentions = MutableLiveData<List<Triple<String, String?, FavoriteType>>>()
// process
private val currentIndex = MutableLiveData<Int>()
private val pagination = MutableLiveData(StoryPaginationType.DO_NOTHING)
private val options = MutableLiveData<Triple<List<Pair<Int, Int>>, String?, String?>>()
private val seen = MutableLiveData<Triple<String, Long, Long>>()
// utils
private var messageManager: DirectMessagesManager? = null
private val cookie = Utils.settingsHelper.getString(Constants.COOKIE)
private val deviceId = Utils.settingsHelper.getString(Constants.DEVICE_UUID)
private val csrfToken = getCsrfTokenFromCookie(cookie)
private val userId = getUserIdFromCookie(cookie)
private val storiesRepository: StoriesRepository by lazy { StoriesRepository.getInstance() }
private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() }
/* set functions */
fun setStory(story: Story) {
if (story.items == null || story.items.size == 0) {
pagination.postValue(StoryPaginationType.ERROR)
return
}
currentStory.postValue(story)
storyTitle.postValue(story.title ?: story.user?.username)
if (story.broadcast != null) {
date.postValue(story.dateTime)
type.postValue(MediaItemType.MEDIA_TYPE_LIVE)
pagination.postValue(StoryPaginationType.DO_NOTHING)
}
}
fun setMedia(index: Int) {
if (currentStory.value?.items == null) return
if (index < 0 || index >= currentStory.value!!.items!!.size) {
pagination.postValue(if (index < 0) StoryPaginationType.BACKWARD else StoryPaginationType.FORWARD)
return
}
currentIndex.postValue(index)
val story: Story? = currentStory.value
val media = story!!.items!!.get(index)
currentMedia.postValue(media)
date.postValue(media.date)
type.postValue(media.type)
initStickers(media)
}
fun setSingleMedia(media: StoryMedia) {
currentStory.postValue(null)
currentIndex.postValue(0)
currentMedia.postValue(media)
date.postValue(media.date)
type.postValue(media.type)
}
private fun initStickers(media: StoryMedia) {
val builder = ImmutableList.builder<Pair<Int, Int>>()
var linkedText: String? = null
var appText: String? = null
if (setMentions(media)) builder.add(Pair(R.id.mentions, R.string.story_mentions))
if (setQuiz(media)) builder.add(Pair(R.id.quiz, R.string.story_quiz))
if (setQuestion(media)) builder.add(Pair(R.id.question, R.string.story_question))
if (setPoll(media)) builder.add(Pair(R.id.poll, R.string.story_poll))
if (setSlider(media)) builder.add(Pair(R.id.slider, R.string.story_slider))
if (setLinkedPost(media)) builder.add(Pair(R.id.viewStoryPost, R.string.view_post))
if (setStoryCta(media)) {
linkedText = media.linkText
builder.add(Pair(R.id.swipeUp, 0))
}
if (setStoryAppAttribution(media)) {
appText = media.storyAppAttribution!!.appActionText
builder.add(Pair(R.id.spotify, 0))
}
options.postValue(Triple(builder.build(), linkedText, appText))
}
private fun setMentions(media: StoryMedia): Boolean {
val mentions: MutableList<Triple<String, String?, FavoriteType>> = mutableListOf()
if (media.reelMentions != null)
mentions.addAll(media.reelMentions.map{
Triple("@" + it.user?.username, it.user?.username, FavoriteType.USER)
})
if (media.storyHashtags != null)
mentions.addAll(media.storyHashtags.map{
Triple("#" + it.hashtag?.name, it.hashtag?.name, FavoriteType.HASHTAG)
})
if (media.storyLocations != null)
mentions.addAll(media.storyLocations.map{
Triple(it.location?.name ?: "", it.location?.pk?.toString(10), FavoriteType.LOCATION)
})
reelMentions.postValue(mentions.filterNot { it.second.isNullOrEmpty() } .distinct())
return !mentions.isEmpty()
}
private fun setPoll(media: StoryMedia): Boolean {
poll.postValue(media.storyPolls?.get(0)?.pollSticker ?: return false)
return true
}
private fun setQuiz(media: StoryMedia): Boolean {
quiz.postValue(media.storyQuizs?.get(0)?.quizSticker ?: return false)
return true
}
private fun setQuestion(media: StoryMedia): Boolean {
val questionSticker = media.storyQuestions?.get(0)?.questionSticker ?: return false
if (questionSticker.questionType.equals("music")) return false
question.postValue(questionSticker)
return true
}
private fun setSlider(media: StoryMedia): Boolean {
slider.postValue(media.storySliders?.get(0)?.sliderSticker ?: return false)
return true
}
private fun setLinkedPost(media: StoryMedia): Boolean {
linkedPost.postValue(media.storyFeedMedia?.get(0)?.mediaId ?: return false)
return true
}
private fun setStoryCta(media: StoryMedia): Boolean {
val webUri = media.storyCta?.get(0)?.links?.get(0)?.webUri ?: return false
val parsedUri = Uri.parse(webUri)
val cleanUri = if (parsedUri.host.equals("l.instagram.com")) parsedUri.getQueryParameter("u")
else null
swipeUp.postValue(if (cleanUri != null && Uri.parse(cleanUri).scheme?.startsWith("http") == true) cleanUri
else webUri)
return true
}
private fun setStoryAppAttribution(media: StoryMedia): Boolean {
appAttribution.postValue(media.storyAppAttribution ?: return false)
return true
}
/* get functions */
fun getCurrentStory(): LiveData<Story> {
return currentStory
}
fun getCurrentIndex(): LiveData<Int> {
return currentIndex
}
fun getCurrentMedia(): LiveData<StoryMedia> {
return currentMedia
}
fun getPagination(): LiveData<StoryPaginationType> {
return pagination
}
fun getDate(): LiveData<String> {
return date
}
fun getTitle(): LiveData<String> {
return storyTitle
}
fun getType(): LiveData<MediaItemType> {
return type
}
fun getMedia(): LiveData<StoryMedia> {
return currentMedia
}
fun getMention(index: Int): Triple<String, String?, FavoriteType>? {
return reelMentions.value?.get(index)
}
fun getMentionTexts(): Array<String> {
return reelMentions.value!!.map { it.first } .toTypedArray()
}
fun getPoll(): LiveData<PollSticker> {
return poll
}
fun getQuestion(): LiveData<QuestionSticker> {
return question
}
fun getQuiz(): LiveData<QuizSticker> {
return quiz
}
fun getSlider(): LiveData<SliderSticker> {
return slider
}
fun getLinkedPost(): LiveData<Resource<Media?>> {
val data = MutableLiveData<Resource<Media?>>()
data.postValue(loading(null))
val postId = linkedPost.value
if (postId == null) data.postValue(error("No post ID supplied", null))
else viewModelScope.launch(Dispatchers.IO) {
try {
val media = mediaRepository.fetch(postId.toLong())
data.postValue(success(media))
}
catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
fun getSwipeUp(): String? {
return swipeUp.value
}
fun getAppAttribution(): String? {
return appAttribution.value?.url
}
fun getOptions(): LiveData<Triple<List<Pair<Int, Int>>, String?, String?>> {
return options
}
/* action functions */
fun answerPoll(w: Int): LiveData<Resource<Any?>> {
val data = MutableLiveData<Resource<Any?>>()
data.postValue(loading(null))
viewModelScope.launch(Dispatchers.IO) {
try {
val oldPoll: PollSticker = poll.value!!
val response = storiesRepository.respondToPoll(
csrfToken!!,
userId,
deviceId,
currentMedia.value!!.pk,
oldPoll.pollId,
w
)
if (!"ok".equals(response.status))
throw Exception("Instagram returned status \"" + response.status + "\"")
val tally = oldPoll.tallies.get(w)
val newTally = tally.copy(count = tally.count + 1)
val newTallies = oldPoll.tallies.toMutableList()
newTallies.set(w, newTally)
poll.postValue(oldPoll.copy(viewerVote = w, tallies = newTallies.toList()))
data.postValue(success(null))
}
catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
fun answerQuiz(w: Int): LiveData<Resource<Any?>> {
val data = MutableLiveData<Resource<Any?>>()
data.postValue(loading(null))
viewModelScope.launch(Dispatchers.IO) {
try {
val oldQuiz = quiz.value!!
val response = storiesRepository.respondToQuiz(
csrfToken!!,
userId,
deviceId,
currentMedia.value!!.pk,
oldQuiz.quizId,
w
)
if (!"ok".equals(response.status))
throw Exception("Instagram returned status \"" + response.status + "\"")
val tally = oldQuiz.tallies.get(w)
val newTally = tally.copy(count = tally.count + 1)
val newTallies = oldQuiz.tallies.toMutableList()
newTallies.set(w, newTally)
quiz.postValue(oldQuiz.copy(viewerAnswer = w, tallies = newTallies.toList()))
data.postValue(success(null))
}
catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
fun answerQuestion(a: String): LiveData<Resource<Any?>> {
val data = MutableLiveData<Resource<Any?>>()
data.postValue(loading(null))
viewModelScope.launch(Dispatchers.IO) {
try {
val response = storiesRepository.respondToQuestion(
csrfToken!!,
userId,
deviceId,
currentMedia.value!!.pk,
question.value!!.questionId,
a
)
if (!"ok".equals(response.status))
throw Exception("Instagram returned status \"" + response.status + "\"")
data.postValue(success(null))
}
catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
fun answerSlider(a: Double): LiveData<Resource<Any?>> {
val data = MutableLiveData<Resource<Any?>>()
data.postValue(loading(null))
viewModelScope.launch(Dispatchers.IO) {
try {
val oldSlider = slider.value!!
val response = storiesRepository.respondToSlider(
csrfToken!!,
userId,
deviceId,
currentMedia.value!!.pk,
oldSlider.sliderId,
a
)
if (!"ok".equals(response.status))
throw Exception("Instagram returned status \"" + response.status + "\"")
val newVoteCount = (oldSlider.sliderVoteCount ?: 0) + 1
val newAverage = if (oldSlider.sliderVoteAverage == null) a
else (oldSlider.sliderVoteAverage * oldSlider.sliderVoteCount!! + a) / newVoteCount
slider.postValue(oldSlider.copy(viewerCanVote = false,
sliderVoteCount = newVoteCount,
viewerVote = a,
sliderVoteAverage = newAverage))
data.postValue(success(null))
}
catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
fun reply(a: String): LiveData<Resource<Any?>>? {
if (messageManager == null) {
messageManager = DirectMessagesManager
}
return messageManager?.replyToStory(
currentStory.value?.user?.pk,
currentStory.value?.id,
currentMedia.value?.id,
a,
viewModelScope
)
}
fun shareDm(result: RankedRecipient) {
if (messageManager == null) {
messageManager = DirectMessagesManager
}
val mediaId = currentMedia.value?.id ?: return
val reelId = currentStory.value?.id ?: return
messageManager?.sendMedia(result, mediaId, reelId, BroadcastItemType.STORY, viewModelScope)
}
fun shareDm(recipients: Set<RankedRecipient>) {
if (messageManager == null) {
messageManager = DirectMessagesManager
}
val mediaId = currentMedia.value?.id ?: return
val reelId = currentStory.value?.id ?: return
messageManager?.sendMedia(recipients, mediaId, reelId, BroadcastItemType.STORY, viewModelScope)
}
fun paginate(backward: Boolean) {
var index = currentIndex.value!!
index = if (backward) index - 1 else index + 1
if (index < 0 || index >= currentStory.value!!.items!!.size) skip(backward)
setMedia(index)
}
fun skip(backward: Boolean) {
pagination.postValue(if (backward) StoryPaginationType.BACKWARD else StoryPaginationType.FORWARD)
}
fun fetchStory(fetchOptions: StoryViewerOptions?): LiveData<Resource<Any?>> {
val data = MutableLiveData<Resource<Any?>>()
data.postValue(loading(null))
viewModelScope.launch(Dispatchers.IO) {
try {
val story = storiesRepository.getStories(fetchOptions!!)
setStory(story!!)
data.postValue(success(null))
} catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
fun fetchSingleMedia(mediaId: Long): LiveData<Resource<Any?>> {
val data = MutableLiveData<Resource<Any?>>()
data.postValue(loading(null))
viewModelScope.launch(Dispatchers.IO) {
try {
val storyMedia = storiesRepository.fetch(mediaId)
setSingleMedia(storyMedia!!)
data.postValue(success(null))
} catch (e: Exception) {
data.postValue(error(e.message, null))
}
}
return data
}
}

View File

@ -7,7 +7,6 @@ import awais.instagrabber.repositories.responses.stories.ArchiveResponse
import awais.instagrabber.repositories.responses.stories.Story
import awais.instagrabber.repositories.responses.stories.StoryMedia
import awais.instagrabber.repositories.responses.stories.StoryStickerResponse
import awais.instagrabber.utils.TextUtils.isEmpty
import awais.instagrabber.utils.Utils
import awais.instagrabber.webservices.RetrofitFactory.retrofit
import java.util.UUID
@ -60,35 +59,34 @@ open class StoriesRepository(private val service: StoriesService) {
"is_in_archive_home" to "true",
"include_cover" to "1",
)
if (!isEmpty(maxId)) {
if (!maxId.isNullOrEmpty()) {
form["max_id"] = maxId // NOT TESTED
}
return service.fetchArchive(form)
}
open suspend fun getStories(options: StoryViewerOptions): List<StoryMedia> {
open suspend fun getStories(options: StoryViewerOptions): Story? {
return when (options.type) {
StoryViewerOptions.Type.HIGHLIGHT,
StoryViewerOptions.Type.STORY_ARCHIVE
-> {
val response = service.getReelsMedia(options.name)
val story: Story? = response.reels?.get(options.name)
story?.items ?: emptyList()
response.reels?.get(options.name)
}
StoryViewerOptions.Type.USER -> {
val response = service.getUserStories(options.id.toString())
response.reel?.items ?: emptyList()
response.reel
}
// should not reach beyond this point
StoryViewerOptions.Type.LOCATION -> {
val response = service.getStories("locations", options.id.toString())
response.story?.items ?: emptyList()
response.story
}
StoryViewerOptions.Type.HASHTAG -> {
val response = service.getStories("tags", options.name)
response.story?.items ?: emptyList()
response.story
}
else -> emptyList()
else -> null
}
}
@ -96,7 +94,7 @@ open class StoriesRepository(private val service: StoriesService) {
csrfToken: String,
userId: Long,
deviceUuid: String,
storyId: String,
storyId: Long,
stickerId: Long,
action: String,
arg1: String,
@ -119,7 +117,7 @@ open class StoriesRepository(private val service: StoriesService) {
csrfToken: String,
userId: Long,
deviceUuid: String,
storyId: String,
storyId: Long,
stickerId: Long,
answer: String,
): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_question_response", "response", answer)
@ -128,7 +126,7 @@ open class StoriesRepository(private val service: StoriesService) {
csrfToken: String,
userId: Long,
deviceUuid: String,
storyId: String,
storyId: Long,
stickerId: Long,
answer: Int,
): StoryStickerResponse {
@ -139,7 +137,7 @@ open class StoriesRepository(private val service: StoriesService) {
csrfToken: String,
userId: Long,
deviceUuid: String,
storyId: String,
storyId: Long,
stickerId: Long,
answer: Int,
): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_poll_vote", "vote", answer.toString())
@ -148,7 +146,7 @@ open class StoriesRepository(private val service: StoriesService) {
csrfToken: String,
userId: Long,
deviceUuid: String,
storyId: String,
storyId: Long,
stickerId: Long,
answer: Double,
): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_slider_vote", "vote", answer.toString())

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M22,10l-6,-6L4,4c-1.1,0 -2,0.9 -2,2v12.01c0,1.1 0.9,1.99 2,1.99l16,-0.01c1.1,0 2,-0.89 2,-1.99v-8zM15,5.5l5.5,5.5L15,11L15,5.5z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7,19h10L17,4L7,4v15zM2,17h4L6,6L2,6v11zM18,6v11h4L22,6h-4z"/>
</vector>

View File

@ -9,7 +9,7 @@
android:id="@+id/story_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/postActions"
app:layout_constraintBottom_toTopOf="@id/buttons_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -38,132 +38,150 @@
</FrameLayout>
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/postActions"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/storiesList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
app:layout_constraintTop_toBottomOf="@id/story_container"
app:layout_constraintBottom_toTopOf="@id/storiesList"
android:clipToPadding="false"
app:layout_constraintBottom_toTopOf="@id/buttons_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:background="#0000">
app:layout_constraintStart_toStartOf="parent" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/viewStoryPost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/view_story_post"
android:textColor="@color/btn_green_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_green_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/vote_story_poll"
android:textColor="@color/btn_blue_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_blue_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/answer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/respond_story"
android:textColor="@color/btn_blue_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_blue_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/quiz"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/story_quiz"
android:textColor="@color/btn_blue_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_blue_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/slider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/story_slider"
android:textColor="@color/btn_blue_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_blue_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/swipeUp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SAMPLE TEXT"
android:textColor="@color/btn_blue_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_blue_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/mention"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/story_mentions"
android:textColor="@color/btn_orange_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_orange_background" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/spotify"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/spotify"
android:textColor="@color/btn_green_text_color"
android:visibility="gone"
app:backgroundTint="@color/btn_green_background" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/buttons_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierAllowsGoneWidgets="true"
app:barrierDirection="bottom"
app:layout_constraintTop_toBottomOf="@id/story_container"
app:layout_constraintBottom_toTopOf="@id/btnBackward"
app:constraint_referenced_ids="story_container" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBackward"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="40dp"
android:layout_height="@dimen/story_item_height"
android:layout_width="0dp"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="@drawable/exo_ic_skip_previous"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/storiesList"
app:layout_constraintEnd_toStartOf="@id/btnShare"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/storiesList" />
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/storiesList"
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShare"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipToPadding="false"
app:layout_constraintTop_toBottomOf="@id/postActions"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="?attr/actionModeShareDrawable"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/stickers"
app:layout_constraintStart_toEndOf="@id/btnBackward"
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stickers"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="@drawable/ic_story_sticker"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/list_toggle"
app:layout_constraintStart_toEndOf="@id/btnShare"
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
<com.google.android.material.button.MaterialButton
android:id="@+id/list_toggle"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="@drawable/ic_story_viewer_list"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnDownload"
app:layout_constraintStart_toEndOf="@id/stickers"
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDownload"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="@drawable/ic_download"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnReply"
app:layout_constraintStart_toEndOf="@id/list_toggle"
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnReply"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="@drawable/ic_round_send_24"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnForward"
app:layout_constraintStart_toEndOf="@id/btnBackward" />
app:layout_constraintStart_toEndOf="@id/btnDownload"
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnForward"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="40dp"
android:layout_height="@dimen/story_item_height"
android:layout_width="0dp"
android:layout_height="48dp"
android:enabled="false"
android:visibility="visible"
app:icon="@drawable/exo_ic_skip_next"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="@color/ic_read_button_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/storiesList"
app:layout_constraintTop_toTopOf="@id/storiesList" />
app:layout_constraintStart_toEndOf="@id/btnReply"
app:layout_constraintTop_toBottomOf="@id/buttons_barrier"
app:rippleColor="@color/grey_300" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2,21 +2,9 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_dms"
android:icon="@drawable/ic_round_send_24"
android:title="@string/reply_story"
android:titleCondensed="@string/reply_story"
app:showAsAction="never" />
<item
android:id="@+id/action_profile"
android:title="@string/open_profile"
android:titleCondensed="@string/open_profile"
app:showAsAction="never" />
<item
android:id="@+id/action_download"
android:icon="@drawable/ic_download"
android:title="@string/action_download"
android:titleCondensed="@string/action_download"
app:showAsAction="never" />
</menu>

View File

@ -188,7 +188,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument
android:name="options"

View File

@ -140,7 +140,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument
android:name="options"

View File

@ -103,7 +103,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument
android:name="options"

View File

@ -110,7 +110,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument
android:name="options"

View File

@ -93,7 +93,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument
android:name="options"

View File

@ -203,7 +203,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument

View File

@ -40,7 +40,6 @@
<fragment
android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment"
android:label="StoryViewerFragment"
tools:layout="@layout/fragment_story_viewer">
<argument
android:name="options"

View File

@ -8,4 +8,13 @@
<item name="share_dm" type="id" />
<item name="download_current" type="id" />
<item name="download_all" type="id" />
<!-- story stickers -->
<item name="mentions" type="id" />
<item name="spotify" type="id" />
<item name="poll" type="id" />
<item name="question" type="id" />
<item name="quiz" type="id" />
<item name="slider" type="id" />
<item name="viewStoryPost" type="id" />
<item name="swipeUp" type="id" />
</resources>

View File

@ -69,8 +69,7 @@
<string name="be_patient">Be patient!</string>
<string name="view_story_post">View Post</string>
<string name="view_post">View Post</string>
<string name="spotify" translatable="false">Spotify</string>
<string name="vote_story_poll">Vote</string>
<string name="story_poll">Poll</string>
<string name="votef_story_poll">Vote successful!</string>
<string name="voted_story_poll">You have already voted!</string>
<string name="respond_story">Respond</string>
@ -87,6 +86,7 @@
<string name="story_slider">Slider</string>
<string name="story_quizzed">You have already answered!</string>
<string name="story_mentions">Mentions</string>
<string name="story_question">Question</string>
<string name="priv_acc">This Account is Private</string>
<string name="priv_acc_confirm">You won\'t be able to access posts after unfollowing! Are you sure?</string>
<string name="are_you_sure">Are you sure?</string>
@ -104,7 +104,7 @@
<string name="delete_collection">Delete collection</string>
<string name="delete_collection_confirm">Are you sure you want to delete this collection?</string>
<string name="delete_collection_note">All contained media will remain in other collections.</string>
<string name="add_to_collection">Add to collection...</string>
<string name="add_to_collection">Add to collection</string>
<string name="remove_from_collection">Remove from collection</string>
<string name="liked">Liked</string>
<string name="saved">Saved</string>
@ -183,9 +183,9 @@
<string name="dms_inbox_raven_media_screenshot">Screenshotted</string>
<string name="dms_inbox_raven_media_cant_deliver">Cannot deliver</string>
<string name="dms_inbox_error_null_count">Unseen count response is null!</string>
<string name="dms_thread_message_hint">Message...</string>
<string name="dms_thread_message_hint">Message</string>
<string name="dms_thread_audio_hint">Press and hold to record audio</string>
<string name="dms_thread_updating">Updating...</string>
<string name="dms_thread_updating">Updating</string>
<string name="dms_action_leave">Leave chat</string>
<string name="dms_action_leave_question">Leave this chat?</string>
<string name="dms_action_kick">Kick</string>
@ -333,7 +333,7 @@
<string name="comment">Comment</string>
<string name="layout">Layout</string>
<string name="feed_stories">Feed stories</string>
<string name="opening_post">Opening post...</string>
<string name="opening_post">Opening post</string>
<string name="share">Share</string>
<string name="layout_style">Layout style</string>
<string name="column_count">Column count</string>
@ -511,7 +511,7 @@
<string name="click_to_show_full">Click to show full like count</string>
<string name="no_profile_pic_found">No profile pic found!</string>
<string name="swipe_up_confirmation">Are you sure you want to open this link?</string>
<string name="sending">Sending...</string>
<string name="sending">Sending</string>
<string name="share_via_dm">Share via DM</string>
<string name="share_link">Share link…</string>
<string name="slide_to_cancel">Slide to Cancel</string>