Showing posts with label android. Show all posts
Showing posts with label android. Show all posts

YouTube Shared

Android app that converts YouTube video link into ads-free browser video link.

🎧 Love listening to audiobooks on YouTube, but dealing with ads and the inability to play videos in the background without a premium subscription can be a challenge. 🤔

I noticed that these two drawbacks are absent in embedded YouTube videos found on external websites. By obtaining the video ID and using it in an embedded link template, you can turn any video link into an ad-free, background-playable version. This link can be opened directly in your browser, allowing for uninterrupted playback.

Initially, I manually performed these steps since I lacked Android app development skills. However, after learning a few things, I decided to automate the process of redirecting videos from the YouTube app to the browser. 🔄

Disclaimer

This application is created with no malicious intent. It is not intended to harm or interfere with any actions of Google or advertisers on the YouTube platform. The application solely automates specific actions that a regular user could manually perform. The functionality of the application adheres to all laws and does not alter any standard functionalities of the Android platform or other applications. It merely utilizes the capabilities provided by the Android platform and other applications.

Functionality requirements

Let's first determine the functions our application should have:

  • No GUI - this app is just a 'proxy.'
  • It should be able to receive shared text, including links from the YouTube app.
  • Actions with the link:
    1. Extract ID.
    2. Create an 'embedded link' with the received ID.
    3. Open the 'embedded link' in the browser (avoid launching the YouTube app as the default app for YouTube links).

Main Activity implementation

This is what an embedded link looks like:

https://www.youtube.com/embed/video-ID

YouTube may share the link of the video in two formats:

  • https://youtu.be/video-ID
  • https://youtube.com/watch?v=video-ID&other=parameters

To extract the video ID, we can use RegExp:

https?://youtu(\.be/|be\.com/watch\?v=)(?<videoId>[A-Za-z0-9_\-]+)

where "videoId" is a group name for symbols which should be extracted.

Then, concatenate https://www.youtube.com/embed/ with the extracted video ID.

To create an intent to launch the browser instead of the YouTube app by default:

final Intent intent = Intent.makeMainSelectorActivity( Intent.ACTION_MAIN, Intent.CATEGORY_APP_BROWSER) .setData(uri);

Configuration of the Manifest.xml

To prevent our app from having duplicate instances:

<application
  android:launchMode="singleTask"

Ability to receive shared text:

<activity>
  <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="text/plain" />
  </intent-filter>

Results

Let's check the log to be sure that our app works fine:

11:49:06.283 onCreate 11:49:06.283 receivedText = https://youtube.com/watch?v=video-ID&si=a_bCDEfj1-HIjK-m 11:49:06.283 videoId = video-ID 11:49:06.284 uri = https://www.youtube.com/embed/video-ID 11:49:06.313 processReceivedText(): finished 11:49:07.134 onDestroy()

Here, you can see the next sequence:

  1. The app receives shared text with the link to the YouTube video.
  2. The video ID is successfully extracted.
  3. An embedded link for the browser is generated.
  4. After sending the converted link to the browser, our app quickly finishes its execution.

When I finished implementing the 'share with the browser' functionality, I decided to expand the capabilities of my app a bit. There is another drawback on YouTube that my application can address – the absence of the "Open as Regular Video" button for "Shorts" in order to access the playback speed control feature.

There is a "Shorts" link format:

https://youtube.com/shorts/video-ID

So, we need just convert it to:

https://youtube.com/watch?v=video-ID

or:

vnd.youtube:video-ID

and open in a YouTube app:

final Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(uri);

And since our application has completed its tasks, it is necessary to notify Android that it can be unloaded from memory by calling the finish() method.

Special Cases

When I tested my app on my old smartphone with MIUI Android, I encountered an issue where the intended app wouldn't start. I discovered that it could not run when my app was shut down by calling finish(), but it started as designed without this call. I received the error message in the logs:

com.android.server.am.ExtraActivityManagerService
MIUILOG-
Permission Denied Activity :
Intent {
  sel=act=android.intent.action.MAIN
  cat=[android.intent.category.APP_BROWSER]}
....

After spending some time searching on the Internet, I found out that the problem is related to a specific MIUI security permission - Start in background, and the user should manually enable it for our app:

Another case

If the author of the video has disabled embedding, it won't be possible to watch such a video using the method described in this article. However, it's worth noting that such cases are extremely rare.

Sources

GitHub: https://github.com/asilichenko/android-youtube-shared

Releases APK

Android edge-to-edge layout

Default layout

Base theme without action bar:

<style name="Base.Theme.ShortEdges" parent="Theme.Material3.Light.NoActionBar">
</style>

Default layout

Set background color for status and navigation bars

<style name="Base.Theme.ShortEdges" parent="Theme.Material3.Light.NoActionBar">
  <item name="android:statusBarColor">@android:color/holo_purple</item>
  <item name="android:navigationBarColor">@android:color/holo_red_dark</item>
</style>

Colored status and navigation bars

Make status and navigation bars transparent

<style name="Base.Theme.ShortEdges" parent="Theme.Material3.Light.NoActionBar">
  <item name="android:statusBarColor">@android:color/transparent</item>
  <item name="android:navigationBarColor">@android:color/transparent</item>
</style>

Transparent status and navigation bars

Make status bar text color contrasted

<style name="Base.Theme.ShortEdges" parent="Theme.Material3.Light.NoActionBar">
  <item name="android:windowLightStatusBar">true</item>
  ...
</style>

windowLightStatusBar = true -> status bar will be drawn compatible with a light background

Status bar text color compatible with a light background

Make the layout fit the screen edge-to-edge

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
  ...
  WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
}

Layout fits the screen edge-to-edge

Layout is letterboxed in landscape mode

Layout is cutout in landscape mode

This 'bug' is present even in Google products like Maps and Google Earth; Sky Map is even displayed in full cutout mode.

Make layout fit the screen edge-to-edge in landscape mode

<style name="Base.Theme.ShortEdges" parent="Theme.Material3.Light.NoActionBar">
  ...
  <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>

windowLayoutInDisplayCutoutMode can take one of three values:

  • default: content renders into the cutout area when displayed in portrait mode, but is letterboxed when in landscape mode
  • shortEdges: content always renders into the cutout area
  • never: content never renders into the cutout area

Layout edge-to-edge in landscape mode

windowLayoutInDisplayCutoutMode requires API level 27, so if your app supports lower API level, then extract it into "values-v27/themes.xml".

  • values/themes.xml:
<style name="Base.Theme.ShortEdges" parent="Theme.Material3.Light.NoActionBar">
  ...
</style>

<style name="Theme.ShortEdges" parent="Base.Theme.ShortEdges" />
  • values-v27/themes.xml:
<style name="Theme.ShortEdges" parent="Base.Theme.ShortEdges">
  <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>

How to determine safe region

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
  ...
  ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.my_view), this::onApplyWindowInsets);
}

@NonNull
public WindowInsetsCompat onApplyWindowInsets(@NonNull View v, @NonNull WindowInsetsCompat windowInsets) {
  final Insets displayCutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout());
  final Insets systemBarsInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
  
  final Insets safeInsets = Insets.of(
    max(displayCutoutInsets.left, systemBarsInsets.left),
    max(displayCutoutInsets.top, systemBarsInsets.top),
    max(displayCutoutInsets.right, systemBarsInsets.right),
    max(displayCutoutInsets.bottom, systemBarsInsets.bottom)
  );

  return WindowInsetsCompat.CONSUMED;
}

Set component view margins

final ViewGroup.MarginLayoutParams mlp = 
  (ViewGroup.MarginLayoutParams) view.getLayoutParams();

mlp.leftMargin = safeInsets.left;
mlp.topMargin = safeInsets.top;
mlp.bottomMargin = safeInsets.bottom;
mlp.rightMargin = safeInsets.right;

view.setLayoutParams(mlp);

The button is in the safe zone

Resources

How to receive shared text in the Android App

First of all, we need to register our app as the plain text receiver by adding the intent filter into the Manifest for a certain activity:

<intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="text/plain" />
</intent-filter>

The standard way to receive shared text is by calling the getStringExtra method for the intent:

final Intent intent = getIntent();
final String stringExtra = intent.getStringExtra(Intent.EXTRA_TEXT);

It is also useful to preliminarily check the intent action:

final Intent intent = getIntent();
if (Intent.ACTION_SEND.equals(action)) {
  ...
}

This code can be called either from the onCreate method if we expect to receive data when the app is started, or from the onResume method if the app has already started and is in the background.

However, there is an issue when the app is called from the background - sometimes we receive the android.intent.action.MAIN action instead of SEND, and the extra is empty. But why does this happen? When does "sometimes" occur? Let's explore this issue.

Test case #1

  1. Our app is shut down
  2. Open Web-browser
  3. Share some link to the app
  4. The app is opened
  5. Method onCreate is called
  6. Shared text successfully received
  7. Switch back to the Web-browser
  8. Share link again
  9. The onResume method is called this time, and the data can be received.

Android activity lifecycle: Test case #1
 Nothing odd so far. Let's take a look Test case #2:

Test case #2

  1. Our app is shut down
  2. Start the app
  3. Hide the app into background
  4. Open the Web-browser
  5. Share link to the app
  6. The onResume method is called, but this time we got no data

Android activity lifecycle: Test case #2

Let's look into the logs, there we can see a pair of interesting records:

ActivityTaskManager I START 
  u0 {act = android.intent.action.SEND
    typ = text/plain
    flg = 0x13080001 
    cmp = ua.in.asilichenko.sharedtextreceiver/.MainActivity 
    clip = {text/plain {T(475)}}
    (has extras)
  } from uid 10191

ActivityTaskManager I Launching r: 
  ActivityRecord {
    90f60f2
    u0
    ua.in.asilichenko.sharedtextreceiver/.MainActivity
  }
  from background: 
    ActivityRecord {
      90e8155
      u0
      com.android.chrome/com.google.android.apps.chrome.Main
    } 
    t24314}. 
    New task: false

The conclusions that can be drawn from this log are:

  • Action android.intent.action.SEND with some data was sent indeed, but the app did not receive it
  • There is some flag: 0x13080001
  • The ActivityTaskManager determined that the app had already run, so it didn't start a new instance but restored the one from the background

The solution to this problem lies in overriding the onNewIntent method:

This is called for activities that set launchMode to "singleTop" in their package, or if a client used the Intent.FLAG_ACTIVITY_SINGLE_TOP flag when calling startActivity. In either case, when the activity is re-launched while at the top of the activity stack instead of a new instance of the activity being started, onNewIntent() will be called on the existing instance with the Intent that was used to re-launch it.
An activity can never receive a new intent in the resumed state. You can count on onResume being called after this method, though not necessarily immediately after the completion this callback. If the activity was resumed, it will be paused and new intent will be delivered, followed by onResume. If the activity wasn't in the resumed state, then new intent can be delivered immediately, with onResume() called sometime later when activity becomes active again.
Note that getIntent still returns the original Intent. You can use setIntent to update it to this new Intent. Dispatches this call to all listeners added via addOnNewIntentListener(Consumer). Handle onNewIntent() to inform the fragment manager that the state is not saved. If you are handling new intents and may be making changes to the fragment state, you want to be sure to call through to the super-class here first. Otherwise, if your state is saved but the activity is not stopped, you could get an onNewIntent() call which happens before onResume() and trying to perform fragment operations at that point will throw IllegalStateException because the fragment manager thinks the state is still saved.

So it turns out that if the sending application launches our application, the data will continue to be successfully received through the intent. However, in the case where the application was launched separately from the sender, a new intent will be created each time, and the sent data will not be transferred to the new intent. Nevertheless, the original intent from the sender can be obtained in the onNewIntent method.

@Override
protected void onNewIntent(Intent intent) {
  super.onNewIntent(intent);
  setIntent(intent);
  receiveText();
}
Android activity lifecycle: Test case #2. Fix

Now, both test cases works fine, but I found another bug.

Test case #3

  1. Our app is in the background
  2. Open Web-browser
  3. Share a link
  4. The app is opened
  5. Method onCreate is called

Android activity lifecycle: Test case #3

Now, if we look in the background stack, we can see two instances of our app, and that's not good.

To fix this, we need to set launchMode for the main activity to "singleTask":

<activity
  android:name=".MainActivity"
  android:exported="true"
  android:launchMode="singleTask">

In this mode, if there is already an existing instance of the activity in the system, a new one will not be created, and the existing one will be called. 

Android activity lifecycle: Test case #3. Fix

Let's check one more case.

Test case #4

  1. Share data into our app
  2. Data is received
  3. Rotate the device
  4. The same data is received again

This happens because when the Android device is rotated, it recreates the activity, but the intent remains the same. As a result, the processing of shared text in the onCreate method is triggered again, and the read data is also re-read.

Android activity lifecycle: Test case #4

To avoid re-reading the data, you need to remove them from the intent using the removeExtra method:

if (Intent.ACTION_SEND.equals(action)) {
  final String stringExtra = intent.getStringExtra(Intent.EXTRA_TEXT);
  if (null != stringExtra) {
    ...
    intent.removeExtra(Intent.EXTRA_TEXT);
  }
}

Android activity lifecycle: Test case #4. Fix

That's all for today.

How Android Handles Image Sizes for Different Screen Densities

When making images for Android apps, it's important to make sure they look good on all types of screens. Android achieves this by creating different versions of the same image that fit various screen types.

Here's how it works

1. Base Image Size (mdpi): You start with a base image size, typically known as mdpi (medium-density pixel). This serves as the reference point for other pixel densities.

2. Scaling Factors: Android uses scaling factors to determine the size of an image for each pixel density. For instance, hdpi (high-density pixel) images are 1.5 times larger than the mdpi base, xhdpi images are twice as large, xxhdpi images are three times larger, and xxxhdpi images are four times larger.

3. Calculating Image Sizes: Using the scaling factors, you can calculate the dimensions of images for various pixel densities. For example, if your base image is 48x48 pixels (mdpi), the sizes for different densities will be:

  • mdpi: 48x48 pixels (base size)
  • hdpi: 72x72 pixels (48 * 1.5)
  • xhdpi: 96x96 pixels (48 * 2)
  • xxhdpi: 144x144 pixels (48 * 3)
  • xxxhdpi: 192x192 pixels (48 * 4)

By providing images in different sizes for different pixel densities, Android ensures that your app's visuals look crisp and well-suited for various devices, whether they have standard or high-resolution screens.

#AndroidDevelopment #ImageAdaptability #UserExperience #AndroidImageAssets

How to use API KEY in the Adnroid app

How to Hide & Protect API Keys in Your Android App.

  1. Get a Maps API key.
  2. Create a file in the root directory of your project called secure.properties (this file should NOT be under version control to protect your API key)
  3. Add a single line to secure.properties that looks like: API_KEY=YOUR_API_KEY, where YOUR_API_KEY is the API key you obtained in the first step.
  4. Put following code into your module build.gradle file:
    android {
      ...
      defaultConfig {
        ...
          def secureProps = new Properties()
          def securePropsFile = rootProject.file("secure.properties")
          if (securePropsFile.exists()) secureProps.load(new FileInputStream(securePropsFile))
          resValue "string", "api_key", (secureProps.getProperty("API_KEY") ?: "")
        ...
  5. Use api_key in your Manifest:
    <meta-data
      android:name="com.google.android.geo.API_KEY"
      android:value="@string/api_key" />
  6. Your API KEY can be accessed at runtime using the statement: getString(R.string.api_key)
  7. Build and run

Adnroid: How switch between two activites by screen orientation

There are two activities: MainActivity and SecondActivity.

  • MainActivity should be displayed in the portrait orientation
  • SecondActivity should be displayed in the landscape orientation

We need such attributes in Manifest:

<activity
        android:name=".MainActivity"
        android:screenOrientation="fullSensor"
        android:configChanges="orientation|screenSize"
...
<activity
        android:name=".SecondActivity"
        android:screenOrientation="fullSensor"
...

MainActivity starts SecondActivity when the screen is rotated into LANDSCAPE:

import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
...
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
  super.onConfigurationChanged(newConfig);

  if (ORIENTATION_LANDSCAPE == newConfig.orientation) {
    startActivity(new Intent(this, SecondActivity.class));
  }
}

SecondActivity checks on create if current orientation is LANDSCAPE, otherwise it finishes:

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    if (ORIENTATION_PORTRAIT == getResources().getConfiguration().orientation) {
      finish();
      return;
    }

    setContentView(R.layout.second_activity);
    ...
  }

When attribute configChanges="orientation|screenSize" is not present - activity will be recreated on each screen rotation, otherwise - method onConfigurationChanged is called.

Example on Github 

Desperate Housewives Susan's Art s05 e16

Desperate Housewives Susan's Art s05 e16