1
0
mirror of https://github.com/akaessens/NoFbEventScraper synced 2025-06-05 23:29:13 +02:00

9 Commits

22 changed files with 284 additions and 69 deletions

View File

@ -1,4 +1,11 @@
# Changelog
## v0.4.0 (10)
- Support pages with upcoming events *beta*
- Display events in a scrollable card-based view
- Improve intent handling
- Add history for scraped events
- Tap image preview to open fullscreen
- Scrape name and image even if no event data found
## v0.3.3 (9)
- Update about section with download and changelog information.
- Improve high-res preview scraping.

View File

@ -8,8 +8,8 @@ android {
applicationId "com.akdev.nofbeventscraper"
minSdkVersion 23
targetSdkVersion 29
versionCode 10
versionName "0.4.0"
versionCode 11
versionName "0.4.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -23,6 +23,12 @@
<data
android:host="facebook.com"
android:scheme="https" />
<data
android:host="*.fb.me"
android:scheme="https" />
<data
android:host="fb.me"
android:scheme="https" />
</intent-filter>
<!-- Accepts share intents -->

View File

@ -60,6 +60,10 @@ public class EventAdapter extends
// Set item views based on your views and data model
holder.text_view_event_name.setText(event.name);
/*
* initialize all text views with event information
* hide fields and image views if no information is available
*/
if (!event.location.equals("")) {
holder.text_view_event_location.setText(event.location);
} else {
@ -86,7 +90,6 @@ public class EventAdapter extends
}
if (!event.description.equals("")) {
holder.text_view_event_description.setText(event.description);
} else {
@ -120,13 +123,14 @@ public class EventAdapter extends
};
holder.image_view_event_location.setOnClickListener(location_click_listener);
holder.text_view_event_location.setOnClickListener(location_click_listener);
/*
* Add to calendar button: launch calendar application with current event
*/
holder.button_add_to_calendar.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// calendar event intent expects epoch time format
Long start_epoch = dateTimeToEpoch(event.start_date);
Long end_epoch = dateTimeToEpoch(event.end_date);
@ -163,7 +167,7 @@ public class EventAdapter extends
});
/*
* Image dialog
* Image preview click creates fullscreen dialog
*/
View.OnClickListener listener = new View.OnClickListener() {
@ -198,11 +202,20 @@ public class EventAdapter extends
});
}
};
holder.image_view_event_image.setOnClickListener(listener);
holder.image_view_share.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent share_intent = new Intent(android.content.Intent.ACTION_SEND);
share_intent.setType("text/plain");
share_intent.putExtra(Intent.EXTRA_TEXT, event.url);
view.getContext().startActivity(Intent.createChooser(share_intent, null));
}
});
}
@ -212,6 +225,9 @@ public class EventAdapter extends
return events.size();
}
/**
* access item view elements via holder class
*/
public static class ViewHolder extends RecyclerView.ViewHolder {
protected TextView text_view_event_name;
@ -222,6 +238,7 @@ public class EventAdapter extends
protected ImageView image_view_event_image;
protected ImageView image_view_event_location;
protected ImageView image_view_event_time;
protected ImageView image_view_share;
protected Button button_add_to_calendar;
protected boolean description_collapsed = true;
@ -237,6 +254,7 @@ public class EventAdapter extends
image_view_event_image = item_view.findViewById(R.id.image_view_event_image);
image_view_event_location = item_view.findViewById(R.id.image_view_event_location);
image_view_event_time = item_view.findViewById(R.id.image_view_event_time);
image_view_share = item_view.findViewById(R.id.image_view_share);
button_add_to_calendar = item_view.findViewById(R.id.button_add_to_calendar);
}

View File

@ -163,6 +163,7 @@ public class FbEventScraper extends AsyncTask<Void, Void, Void> {
JSONObject reader = new JSONObject(json);
// get all fields from json event information
name = readFromJson(reader, "name");
start_date = parseToDate(readFromJson(reader, "startDate"));
end_date = parseToDate(readFromJson(reader, "endDate"));
@ -170,6 +171,7 @@ public class FbEventScraper extends AsyncTask<Void, Void, Void> {
location = fixLocation(readFromJson(reader, "location"));
image_url = readFromJson(reader, "image");
// try to find a high-res image
try {
image_url = document.select("div[id=event_header_primary]")
.select("img").first().attr("src");
@ -177,6 +179,7 @@ public class FbEventScraper extends AsyncTask<Void, Void, Void> {
}
} catch (JSONException | NullPointerException e) {
// json event information mot found. get at least title and image
name = document.title();
description = scraper.main.get().getString(R.string.error_scraping);
try {
@ -186,9 +189,6 @@ public class FbEventScraper extends AsyncTask<Void, Void, Void> {
}
}
this.event = new FbEvent(url, name, start_date, end_date, description, location, image_url);
} catch (IOException e) {
@ -212,6 +212,7 @@ public class FbEventScraper extends AsyncTask<Void, Void, Void> {
*
* @param aVoid
*/
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);

View File

@ -22,7 +22,7 @@ public class FbPageScraper extends AsyncTask<Void, Void, Void> {
private FbScraper scraper;
private int error;
private String url;
private List<String> event_links = new ArrayList<String>();
private List<String> event_links = new ArrayList<>();
/**
* Constructor with reference to scraper to return results.
@ -58,23 +58,29 @@ public class FbPageScraper extends AsyncTask<Void, Void, Void> {
throw new IOException();
}
/*
* get all event id's from current url and add to the list
*/
String regex = "(/events/[0-9]*)(/\\?event_time_id=[0-9]*)?";
List<String> event_links_href = document
.getElementsByAttributeValueMatching("href", Pattern.compile(regex))
.eachAttr("href");
for (String link : event_links_href) {
this.event_links.add("https://www.facebook.com" + link);
for (String event_id : event_links_href) {
this.event_links.add("https://mbasic.facebook.com" + event_id);
}
/*
* check if more events should be scraped
*/
SharedPreferences shared_prefs = PreferenceManager
.getDefaultSharedPreferences(scraper.main.get());
int max = shared_prefs.getInt("page_event_max", 5);
if (event_links.size() < max) {
// find "next page
try {
String next_url = document
.getElementsByAttributeValueMatching("href", "has_more=1")
@ -83,7 +89,6 @@ public class FbPageScraper extends AsyncTask<Void, Void, Void> {
this.url = "https://mbasic.facebook.com" + next_url;
} catch (NullPointerException e) {
url = null;
event_links = event_links.subList(0, max);
}
@ -101,6 +106,10 @@ public class FbPageScraper extends AsyncTask<Void, Void, Void> {
}
} while (url != null);
if (this.event_links.size() == 0) {
this.error = R.string.error_no_events;
}
return null;
}
@ -114,6 +123,7 @@ public class FbPageScraper extends AsyncTask<Void, Void, Void> {
*
* @param aVoid
*/
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);

View File

@ -0,0 +1,40 @@
package com.akdev.nofbeventscraper;
import android.os.AsyncTask;
import java.net.HttpURLConnection;
import java.net.URL;
public class FbRedirectionResolver extends AsyncTask<Void, Void, Void> {
private String input_url;
private FbScraper scraper;
private String redirected_url;
public FbRedirectionResolver (FbScraper scraper, String input_url) {
this.input_url = input_url;
this.scraper = scraper;
}
protected Void doInBackground(Void... voids) {
try {
HttpURLConnection con = (HttpURLConnection) new URL(input_url).openConnection();
con.setInstanceFollowRedirects(false);
con.connect();
redirected_url = con.getHeaderField("Location");
} catch (Exception e) {
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);
scraper.redirectionResultCallback(redirected_url);
}
}

View File

@ -5,24 +5,23 @@ import android.os.AsyncTask;
import androidx.preference.PreferenceManager;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.akdev.nofbeventscraper.FbEvent.createEventList;
public class FbScraper {
protected List<FbEvent> events;
protected List<AsyncTask> tasks;
protected WeakReference<MainActivity> main; // no context leak with WeakReference
url_type_enum url_type = url_type_enum.EVENT;
private String input_url;
protected WeakReference<MainActivity> main; // no context leak with WeakReference
/**
* Constructor with WeakReference to the main activity, to add events.
@ -33,10 +32,40 @@ public class FbScraper {
FbScraper(WeakReference<MainActivity> main, String input_url) {
this.main = main;
this.input_url = input_url;
this.events = createEventList();
this.tasks = new ArrayList<>();
}
protected String getShortened(String url) throws IOException, URISyntaxException {
// check for url format
new URL(url).toURI();
String regex = "(fb.me/)(e/)?([^/?]*)|(facebook.com/event_invite/[a-zA-Z0-9]*)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(url);
if (matcher.find()) {
//only mbasic does have event ids displayed in HTML
String url_prefix = "https://mbasic.";
// create URL
return url_prefix + matcher.group();
} else {
throw new URISyntaxException(url, "Does not contain page.");
}
}
/**
* Checks if valid URL,
* strips the facebook page id from the input link and create an URL that can be scraped from.
*
* @param url input URL
* @return new mbasic url that can be scraped for event id's
* @throws URISyntaxException if page not found
* @throws MalformedURLException
*/
protected String getPageUrl(String url) throws URISyntaxException, MalformedURLException {
// check for url format
@ -48,10 +77,11 @@ public class FbScraper {
Matcher matcher = pattern.matcher(url);
if (matcher.find()) {
//only mbasic does have event ids displayed in HTML
String url_prefix = "https://mbasic.facebook.com/";
String url_suffix = "?v=events";
// create URL
return url_prefix + matcher.group(3) + url_suffix;
} else {
@ -60,7 +90,7 @@ public class FbScraper {
}
/**
* Strips the facebook event link of the input url.
* Strips the facebook event link from the input event url.
*
* @param url input url
* @return facebook event url String if one was found
@ -98,12 +128,40 @@ public class FbScraper {
}
/**
* cancel vestigial async tasks
*/
void killAllTasks() {
if (!tasks.isEmpty()) {
for (AsyncTask task : tasks) {
try {
task.cancel(true);
task = null;
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
/**
* start an EventScraper async task and add to tasks list
*
* @param event_url
*/
void scrapeEvent(String event_url) {
FbEventScraper scraper = new FbEventScraper(this, event_url);
tasks.add(scraper);
scraper.execute();
}
/**
* Callback for finished EventSCraper async task
*
* @param event Contains event information if scraping successful
* @param error resId for error message
*/
void scrapeEventResultCallback(FbEvent event, int error) {
if (event != null) {
@ -115,20 +173,10 @@ public class FbScraper {
}
/**
* cancel vestigial async tasks
* start a page scraper and add to list of tasks
*
* @param page_url
*/
void killAllTasks() {
try {
for (AsyncTask task : tasks) {
task.cancel(true);
task = null;
}
} catch (Exception e) {
e.printStackTrace();
}
}
void scrapePage(String page_url) {
FbPageScraper scraper = new FbPageScraper(this, page_url);
@ -136,6 +184,12 @@ public class FbScraper {
scraper.execute();
}
/**
* Callback for page scraper async task
*
* @param event_urls List of event urls scraped from the event
* @param error resId of error message if task list is empty
*/
protected void scrapePageResultCallback(List<String> event_urls, int error) {
if (event_urls.size() > 0) {
@ -153,8 +207,36 @@ public class FbScraper {
}
}
protected void redirectUrl (String url) {
FbRedirectionResolver resolver = new FbRedirectionResolver(this, url);
resolver.execute();
}
protected void redirectionResultCallback(String url) {
this.input_url = url;
// now try again with expanded url
this.run();
}
/**
* Start scraping input url
*/
void run() {
// check if shortened url
try {
String shortened = getShortened(input_url);
url_type = url_type_enum.SHORT;
redirectUrl(shortened);
return;
} catch (IOException | URISyntaxException e) {
url_type = url_type_enum.INVALID;
}
// check if input url is an event
try {
String event_url = getEventUrl(input_url);
url_type = url_type_enum.EVENT;
@ -165,7 +247,7 @@ public class FbScraper {
} catch (URISyntaxException | MalformedURLException e) {
url_type = url_type_enum.INVALID;
}
// check if input url is a page
try {
String page_url = getPageUrl(input_url);
url_type = url_type_enum.PAGE;
@ -177,6 +259,6 @@ public class FbScraper {
}
}
enum url_type_enum {EVENT, PAGE, INVALID}
// enum for storing url type in this class
enum url_type_enum {SHORT, EVENT, PAGE, INVALID}
}

View File

@ -1,11 +1,10 @@
package com.akdev.nofbeventscraper;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
public class IntentReceiver extends AppCompatActivity {
@Override

View File

@ -1,16 +1,17 @@
package com.akdev.nofbeventscraper;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.menu.MenuBuilder;
@ -65,17 +66,24 @@ public class MainActivity extends AppCompatActivity {
return list;
}
/**
* Callback for Restoring data
/*
* On resume from other activities, e.g. settings
*/
@Override
public void onResume() {
super.onResume();
events.clear();
events.addAll(getSavedEvents());
adapter.notifyDataSetChanged();
/*
* Clear events after saved events deleted from settings
*/
if (getSavedEvents().isEmpty()) {
events.clear();
adapter.notifyDataSetChanged();
}
/*
* Intent from IntentReceiver - read only once
*/
Intent intent = getIntent();
String data = intent.getStringExtra("InputLink");
@ -199,6 +207,13 @@ public class MainActivity extends AppCompatActivity {
//If the key event is a key-down event on the "enter" button
if ((keyevent.getAction() == KeyEvent.ACTION_DOWN) && (keycode == KeyEvent.KEYCODE_ENTER)) {
startScraping();
// do not focus next view, just release it
edit_text_uri_input.clearFocus();
// close soft keyboard
InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(Activity.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
return true;
}
return false;
@ -209,7 +224,7 @@ public class MainActivity extends AppCompatActivity {
}
/**
* launch the FbScraper asynchronous task with the current text in the input text field.
* launch the FbScraper with the current text in the input text field.
*/
public void startScraping() {
@ -222,6 +237,12 @@ public class MainActivity extends AppCompatActivity {
scraper.run();
}
/**
* manage Helper text on uri_input
*
* @param str What should be displayed
* @param error True if should be displayed as error
*/
public void input_helper(String str, boolean error) {
if (str == null) {
@ -255,14 +276,22 @@ public class MainActivity extends AppCompatActivity {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_main, menu);
/*
* Display icons, restricted API, maybe find other solution?
*/
if (menu instanceof MenuBuilder) {
MenuBuilder m = (MenuBuilder) menu;
//noinspection RestrictedApi
m.setOptionalIconsVisible(true);
}
return true;
}
/**
* Dispatch menu item to new activity
*
* @param item
* @return
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {

View File

@ -1,20 +1,15 @@
package com.akdev.nofbeventscraper;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar;
import com.google.gson.Gson;
public class SettingsActivity extends AppCompatActivity {
@ -37,6 +32,9 @@ public class SettingsActivity extends AppCompatActivity {
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.root_preferences, rootKey);
/*
* reset events click action: delete saved events and display snackbar
*/
Preference button = findPreference("event_reset");
if (button != null) {
button.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
@ -52,11 +50,11 @@ public class SettingsActivity extends AppCompatActivity {
getString(R.string.preferences_event_snackbar), Snackbar.LENGTH_SHORT)
.setAction(R.string.undo, new View.OnClickListener() {
@Override
public void onClick(View v) {
prefs.edit().putString("events", undo).apply();
}
}).show();
@Override
public void onClick(View v) {
prefs.edit().putString("events", undo).apply();
}
}).show();
return true;
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"
android:fillColor="#000000"/>
</vector>

View File

@ -51,9 +51,9 @@
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:background="?android:attr/selectableItemBackground"
android:scaleType="centerCrop"
android:src="@drawable/ic_map"
android:background="?android:attr/selectableItemBackground"
app:tint="@color/material_on_surface_emphasis_high_type" />
<TextView
@ -73,8 +73,8 @@
android:orientation="horizontal">
<ImageView
style="?android:attr/borderlessButtonStyle"
android:id="@+id/image_view_event_time"
style="?android:attr/borderlessButtonStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
@ -128,14 +128,26 @@
<Button
android:id="@+id/button_add_to_calendar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/button_add"
android:textColor="@android:color/white"
app:icon="@drawable/ic_event_available"
app:iconGravity="textStart"
app:iconTint="@android:color/white" />
<ImageView
android:id="@+id/image_view_share"
style="?android:attr/borderlessButtonStyle"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="16dp"
android:background="?android:attr/selectableItemBackground"
android:scaleType="centerCrop"
android:src="@drawable/ic_share"
app:tint="@color/material_on_surface_emphasis_high_type" />
</LinearLayout>

View File

@ -9,7 +9,7 @@
<string name="button_add">Zum Kalender hinzufügen</string>
<string name="tooltip_paste">Einfügen von Inhalten aus der Zwischenablage in das URL-Eingabefeld</string>
<string name="preferences_url_setting">Welcher URL-Präfix ist zu verwenden?</string>
<string name="preferences_url_setting_summary">Die Nutzung von m.facebook.com ist stabiler und schneller. Die Verwendung von www.facebook.com funktioniert besser bei Ereignissen mit mehreren Instanzen und zeigt eine hochauflösende Vorschau an, geht aber irgendwann kaputt, wenn Facebook das klassische Design deaktiviert.</string>
<string name="preferences_url_setting_summary">Die Nutzung von mbasic.facebook.com ist stabiler und schneller. Die Verwendung von www.facebook.com funktioniert besser bei Ereignissen mit mehreren Instanzen und zeigt eine hochauflösende Vorschau an, geht aber irgendwann kaputt, wenn Facebook das klassische Design deaktiviert.</string>
<string name="error_clipboard_empty">Fehler: Zwischenablage leer</string>
<string name="error_scraping">Fehler: Veranstaltungsdaten nicht gefunden</string>
<string name="error_url">Fehler: URL ungültig</string>
@ -22,4 +22,5 @@
<string name="undo">Rückgängig</string>
<string name="preferences_page_event_max_summary">Maximale Anzahl Events, die von einer einzelnen Seite geladen werden sollen.</string>
<string name="preferences_page_event_max">Veranstaltungslimit für Seiten</string>
<string name="error_no_events">Fehler: keine bevorstehenden Veranstaltungen</string>
</resources>

View File

@ -1,12 +1,12 @@
<resources>
<!-- Reply Preference -->
<string-array name="url_to_scrape">
<item>m.facebook.com</item>
<item>mbasic.facebook.com</item>
<item>www.facebook.com</item>
</string-array>
<string-array name="url_prefix">
<item>https://m.</item>
<item>https://mbasic.</item>
<item>https://www.</item>
</string-array>

View File

@ -18,13 +18,13 @@
<string name="error_url">Error: URL invalid</string>
<string name="error_connection">Error: Unable to connect</string>
<string name="error_unknown">Error: Unknown Error</string>
<string name="error_no_events">Error: No upcoming events</string>
<!-- Preferences -->
<string name="preferences_scraper_header" translatable="false">Scraper</string>
<string name="preferences_url_setting">Which URL prefix to use</string>
<string name="preferences_url_setting_summary">"Using m.facebook.com is more stable and faster. Using www.facebook.com works better with multiple instance events and will display a high resolution preview but will eventually break when Facebook disables the classic design. "</string>
<string name="preferences_url_setting_summary">"Using mbasic.facebook.com is more stable and faster. Using www.facebook.com works better with multiple instance events and will display a high resolution preview but will eventually break when Facebook disables the classic design."</string>
<string name="preferences_events_header">Events</string>
<string name="preferences_event_setting">Clear event list</string>

View File

@ -6,7 +6,7 @@
<ListPreference
android:summary="@string/preferences_url_setting_summary"
app:defaultValue="https://m."
app:defaultValue="https://mbasic."
app:entries="@array/url_to_scrape"
app:entryValues="@array/url_prefix"
app:key="url_preference"
@ -19,7 +19,7 @@
<SeekBarPreference
android:defaultValue="5"
app:showSeekBarValue="true"
app:min="5"
app:min="1"
android:max="30"
android:summary="@string/preferences_page_event_max_summary"
android:key="page_event_max"

View File

@ -0,0 +1,3 @@
- Fix events not displaying correctly after activity resume
- add share action on each event
- add URL shortener redirection for fb.me

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 78 KiB